77
88from __future__ import annotations
99
10+ import argparse
1011import json
12+ import os
1113import re
1214import sys
1315from pathlib import Path
112114 "verified" ,
113115}
114116
117+ GAME_LIST_FIELDS = (
118+ "platforms" ,
119+ "genres" ,
120+ "stores" ,
121+ "developers" ,
122+ "publishers" ,
123+ "tags" ,
124+ )
125+
126+ GAME_ESRB_RATINGS = {
127+ "Everyone" ,
128+ "Everyone 10+" ,
129+ "Teen" ,
130+ "Mature" ,
131+ "Adults Only" ,
132+ "Rating Pending" ,
133+ }
134+
135+
136+ def _game_shard (slug : str ) -> str :
137+ """Return the stable on-disk shard for a game slug."""
138+ return slug [:2 ]
139+
115140SOFTWARE_REQUIRED = {
116141 "slug" ,
117142 "name" ,
@@ -159,6 +184,37 @@ def _check_date(name: str, value: object, errors: list[str]) -> None:
159184 errors .append (f"{ name } : release_date '{ value } ' must be ISO 8601 YYYY-MM-DD (§14.2)" )
160185
161186
187+ def _check_title (name : str , value : object , errors : list [str ]) -> None :
188+ if not isinstance (value , str ) or not value .strip ():
189+ errors .append (f"{ name } : name must be a non-empty string" )
190+
191+
192+ def _check_bool (name : str , field : str , value : object , errors : list [str ]) -> None :
193+ if not isinstance (value , bool ):
194+ errors .append (f"{ name } : { field } must be a boolean" )
195+
196+
197+ def _check_string_list (name : str , field : str , value : object , errors : list [str ]) -> None :
198+ """Validate optional game classification lists without constraining vocabulary."""
199+ if value is None :
200+ return
201+ if not isinstance (value , list ) or not all (
202+ isinstance (item , str ) and item .strip () for item in value
203+ ):
204+ errors .append (f"{ name } : { field } must be a list of non-empty strings" )
205+ return
206+ normalized = [item .casefold () for item in value ]
207+ if len (set (normalized )) != len (normalized ):
208+ errors .append (f"{ name } : { field } contains duplicate values" )
209+
210+
211+ def _check_optional_url (name : str , field : str , value : object , errors : list [str ]) -> None :
212+ if value is not None and (
213+ not isinstance (value , str ) or not value .startswith (("http://" , "https://" ))
214+ ):
215+ errors .append (f"{ name } : { field } must be an http(s) URL when present" )
216+
217+
162218def _check_unique_slugs (
163219 category : str , records : list [tuple [str , dict [str , Any ]]], errors : list [str ]
164220) -> None :
@@ -230,6 +286,83 @@ def _check_variant_path(
230286 errors .append (f"{ fname } : filename must match slug '{ rec .get ('slug' )} '" )
231287
232288
289+ def _check_game_path (fname : str , rec : dict [str , Any ], errors : list [str ]) -> None :
290+ """Keep imports in the documented ``game/<shard>/<slug>.json`` layout."""
291+ parts = Path (fname ).parts
292+ slug = rec .get ("slug" )
293+ if not isinstance (slug , str ):
294+ return
295+ if len (parts ) != 3 :
296+ errors .append (f"{ fname } : game records must live at 'game/<shard>/<slug>.json'" )
297+ return
298+ _ , shard , filename = parts
299+ expected_shard = _game_shard (slug )
300+ if shard != expected_shard :
301+ errors .append (
302+ f"{ fname } : lives in shard '{ shard } ' but slug '{ slug } ' belongs in '{ expected_shard } '"
303+ )
304+ if filename != f"{ slug } .json" :
305+ errors .append (f"{ fname } : filename must match slug '{ slug } '" )
306+
307+
308+ def _validate_game_record (fname : str , rec : dict [str , Any ], errors : list [str ]) -> None :
309+ _check_required (fname , rec , GAME_REQUIRED , errors )
310+ _check_source_urls (fname , rec , errors )
311+ _check_slug (fname , rec .get ("slug" ), errors )
312+ _check_title (fname , rec .get ("name" ), errors )
313+ _check_bool (fname , "verified" , rec .get ("verified" ), errors )
314+ if rec .get ("release_date" ) is not None :
315+ _check_date (fname , rec ["release_date" ], errors )
316+ if rec .get ("rating" ) is not None :
317+ _check_range (fname , "rating" , rec .get ("rating" ), 0 , 5 , errors )
318+ if rec .get ("rating_count" ) is not None :
319+ _check_range (fname , "rating_count" , rec .get ("rating_count" ), 0 , 2_000_000_000 , errors )
320+ if rec .get ("metacritic" ) is not None :
321+ _check_range (fname , "metacritic" , rec .get ("metacritic" ), 0 , 100 , errors )
322+ if rec .get ("playtime_hours" ) is not None :
323+ _check_range (fname , "playtime_hours" , rec .get ("playtime_hours" ), 0 , 100_000 , errors )
324+ for field in GAME_LIST_FIELDS :
325+ _check_string_list (fname , field , rec .get (field ), errors )
326+ esrb_rating = rec .get ("esrb_rating" )
327+ if esrb_rating is not None and esrb_rating not in GAME_ESRB_RATINGS :
328+ errors .append (f"{ fname } : esrb_rating '{ esrb_rating } ' not in { sorted (GAME_ESRB_RATINGS )} " )
329+ _check_optional_url (fname , "background_image" , rec .get ("background_image" ), errors )
330+ _check_game_path (fname , rec , errors )
331+
332+
333+ def validate_games (data_dir : Path | None = None ) -> list [str ]:
334+ """Stream game records so the import-scale dataset is not retained in memory."""
335+ errors : list [str ] = []
336+ root = (data_dir or DATA_DIR ) / "game"
337+ if not root .exists ():
338+ return errors
339+
340+ seen : dict [str , str ] = {}
341+ for directory , directories , filenames in os .walk (root ):
342+ directories .sort ()
343+ for filename in sorted (filenames ):
344+ if not filename .endswith (".json" ):
345+ continue
346+ path = Path (directory ) / filename
347+ fname = str (path .relative_to (data_dir or DATA_DIR ))
348+ try :
349+ rec = json .loads (path .read_text (encoding = "utf-8-sig" ))
350+ except (OSError , json .JSONDecodeError ) as exc :
351+ errors .append (f"{ fname } : invalid JSON ({ exc } )" )
352+ continue
353+ if not isinstance (rec , dict ):
354+ errors .append (f"{ fname } : game record must be a JSON object" )
355+ continue
356+ slug = rec .get ("slug" )
357+ if isinstance (slug , str ):
358+ if slug in seen :
359+ errors .append (f"{ fname } : duplicate game slug '{ slug } ' (also in { seen [slug ]} )" )
360+ else :
361+ seen [slug ] = fname
362+ _validate_game_record (fname , rec , errors )
363+ return errors
364+
365+
233366def validate () -> list [str ]:
234367 errors : list [str ] = []
235368
@@ -416,15 +549,7 @@ def validate() -> list[str]:
416549 _check_variant_path (fname , rec , "monitor" , errors , allow_flat = True )
417550
418551 for fname , rec in games :
419- _check_required (fname , rec , GAME_REQUIRED , errors )
420- _check_source_urls (fname , rec , errors )
421- _check_slug (fname , rec .get ("slug" ), errors )
422- if rec .get ("release_date" ) is not None :
423- _check_date (fname , rec ["release_date" ], errors )
424- if rec .get ("rating" ) is not None :
425- _check_range (fname , "rating" , rec .get ("rating" ), 0 , 5 , errors )
426- if rec .get ("metacritic" ) is not None :
427- _check_range (fname , "metacritic" , rec .get ("metacritic" ), 0 , 100 , errors )
552+ _validate_game_record (fname , rec , errors )
428553
429554 for fname , rec in software :
430555 _check_required (fname , rec , SOFTWARE_REQUIRED , errors )
@@ -436,12 +561,20 @@ def validate() -> list[str]:
436561 return errors
437562
438563
439- def run () -> int :
564+ def run (argv : list [str ] | None = None ) -> int :
565+ parser = argparse .ArgumentParser (description = "Validate TechAPI record data" )
566+ parser .add_argument (
567+ "--category" ,
568+ choices = ("all" , "game" ),
569+ default = "all" ,
570+ help = "validate all records (default) or stream the game dataset only" ,
571+ )
572+ args = parser .parse_args (argv )
440573 try :
441574 sys .stdout .reconfigure (encoding = "utf-8" ) # type: ignore[union-attr]
442575 except Exception :
443576 pass
444- errors = validate ()
577+ errors = validate_games () if args . category == "game" else validate ()
445578 if errors :
446579 print (f"❌ Data validation failed ({ len (errors )} issue(s)):" )
447580 for err in errors :
0 commit comments