feat: add new fields, validate.py, GitHub Action, updated README
This commit is contained in:
149
validate.py
Normal file
149
validate.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CupStore repo validation script.
|
||||
Usage: python3 validate.py [--check-urls]
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
import argparse
|
||||
import urllib.request
|
||||
|
||||
REQUIRED_MOD_FIELDS = ["id", "name", "author", "version", "description", "download", "type"]
|
||||
REQUIRED_GAME_FIELDS = ["name", "titleIds"]
|
||||
VALID_TYPES = ["mod", "modpack"]
|
||||
ID_PATTERN = re.compile(r'^[a-z0-9\-_]+$')
|
||||
URL_PATTERN = re.compile(r'^https?://.+\..+')
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
def err(msg): errors.append(msg)
|
||||
def warn(msg): warnings.append(msg)
|
||||
|
||||
def validate_url(url, field, context):
|
||||
if not URL_PATTERN.match(url):
|
||||
err(f"{context}: {field} is not a valid URL: {url!r}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_url_reachable(url, context):
|
||||
try:
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
warn(f"{context}: URL not reachable: {url} ({e})")
|
||||
|
||||
def validate_mod(mod, game_name, seen_ids, check_urls):
|
||||
ctx = f"Game '{game_name}' / Mod '{mod.get('id', '?')}'"
|
||||
|
||||
# Required fields
|
||||
for f in REQUIRED_MOD_FIELDS:
|
||||
if f not in mod:
|
||||
err(f"{ctx}: missing required field '{f}'")
|
||||
|
||||
mod_id = mod.get("id", "")
|
||||
|
||||
# ID format
|
||||
if mod_id and not ID_PATTERN.match(mod_id):
|
||||
err(f"{ctx}: id '{mod_id}' contains invalid characters (only a-z, 0-9, - _ allowed)")
|
||||
|
||||
# ID uniqueness (global)
|
||||
if mod_id in seen_ids:
|
||||
err(f"{ctx}: duplicate id '{mod_id}' (also in '{seen_ids[mod_id]}')")
|
||||
else:
|
||||
seen_ids[mod_id] = game_name
|
||||
|
||||
# Type
|
||||
if mod.get("type") not in VALID_TYPES:
|
||||
err(f"{ctx}: type must be one of {VALID_TYPES}, got {mod.get('type')!r}")
|
||||
|
||||
# URLs
|
||||
if "download" in mod: validate_url(mod["download"], "download", ctx)
|
||||
if "thumbnail" in mod: validate_url(mod["thumbnail"], "thumbnail", ctx)
|
||||
for i, s in enumerate(mod.get("screenshots", [])):
|
||||
validate_url(s, f"screenshots[{i}]", ctx)
|
||||
|
||||
if check_urls:
|
||||
if "download" in mod: check_url_reachable(mod["download"], ctx)
|
||||
|
||||
# Optional field types
|
||||
if "fileSize" in mod and not isinstance(mod["fileSize"], int):
|
||||
err(f"{ctx}: fileSize must be an integer (bytes)")
|
||||
if "tags" in mod and not isinstance(mod["tags"], list):
|
||||
err(f"{ctx}: tags must be a list of strings")
|
||||
if "requirements" in mod and not isinstance(mod["requirements"], list):
|
||||
err(f"{ctx}: requirements must be a list of strings")
|
||||
|
||||
def validate_game(game_data, game_path, seen_ids, check_urls):
|
||||
name = game_data.get("name", game_path)
|
||||
|
||||
# titleIds (support both camelCase and snake_case)
|
||||
if "titleIds" not in game_data and "title_ids" not in game_data:
|
||||
err(f"Game '{name}': missing 'titleIds'")
|
||||
else:
|
||||
tids = game_data.get("titleIds", game_data.get("title_ids", []))
|
||||
if not tids:
|
||||
err(f"Game '{name}': titleIds is empty")
|
||||
|
||||
if "mods" not in game_data or not game_data["mods"]:
|
||||
warn(f"Game '{name}': no mods defined")
|
||||
return
|
||||
|
||||
for mod in game_data["mods"]:
|
||||
validate_mod(mod, name, seen_ids, check_urls)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate a CupStore repo")
|
||||
parser.add_argument("--check-urls", action="store_true", help="Check if download URLs are reachable")
|
||||
parser.add_argument("--repo", default="repo.json", help="Path to repo.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.repo):
|
||||
print(f"ERROR: {args.repo} not found")
|
||||
sys.exit(1)
|
||||
|
||||
with open(args.repo) as f:
|
||||
try:
|
||||
repo = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: repo.json is invalid JSON: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Repo-level fields
|
||||
for f in ["name", "formatVersion", "games"]:
|
||||
if f not in repo:
|
||||
err(f"repo.json: missing required field '{f}'")
|
||||
|
||||
seen_ids = {}
|
||||
|
||||
for entry in repo.get("games", []):
|
||||
if "path" not in entry:
|
||||
err(f"repo.json: game entry missing 'path'")
|
||||
continue
|
||||
game_path = entry["path"]
|
||||
if not os.path.exists(game_path):
|
||||
err(f"repo.json: game file not found: {game_path!r}")
|
||||
continue
|
||||
with open(game_path) as f:
|
||||
try:
|
||||
game_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
err(f"{game_path}: invalid JSON: {e}")
|
||||
continue
|
||||
validate_game(game_data, game_path, seen_ids, args.check_urls)
|
||||
|
||||
# Report
|
||||
if warnings:
|
||||
print(f"\n⚠ {len(warnings)} warning(s):")
|
||||
for w in warnings: print(f" {w}")
|
||||
|
||||
if errors:
|
||||
print(f"\n✗ {len(errors)} error(s):")
|
||||
for e in errors: print(f" {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"✓ Repo is valid ({len(seen_ids)} mods across {len(repo.get('games', []))} game(s))")
|
||||
if warnings:
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user