#!/usr/bin/env python3 # classify-mods.py — seed/update mods-sides.json from jar manifests (tomllib only). # # Enumerates the distribution's server-required jars and, for each jar NOT already # a key in mods-sides.json, reads META-INF/neoforge.mods.toml and derives a side # from the minecraft/neoforge dependency `side`: # side == "CLIENT" -> client # side == "SERVER" -> server # "BOTH" / unspecified / no manifest / parse error -> both (the default) # Existing entries are PRESERVED verbatim (never clobbers human edits); only new # jars are added. No network calls. Pure stdlib (Python 3.11+ for tomllib). # # Source dir resolution (first match wins): # 1. CLI arg: classify-mods.py # 2. env MODS_DIST_DIR # 3. $DISTRIBUTION_WEB_ROOT from .env / environment # The path may point at either the dist ROOT (…/distribution[/dist]) or directly # at the …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted. import glob import json import os import sys import zipfile try: import tomllib except ModuleNotFoundError: sys.exit("classify-mods.py requires Python 3.11+ (tomllib is stdlib)") REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) SIDES_JSON = os.path.join(REPO_ROOT, "mods-sides.json") REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required") MANIFEST = "META-INF/neoforge.mods.toml" def read_env_dist_root(): """Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env.""" val = os.environ.get("DISTRIBUTION_WEB_ROOT") if val: return val env_path = os.path.join(REPO_ROOT, ".env") if os.path.isfile(env_path): with open(env_path, encoding="utf-8") as fh: for line in fh: line = line.strip() if line.startswith("DISTRIBUTION_WEB_ROOT="): return line.split("=", 1)[1].strip().strip('"').strip("'") return None def resolve_required_dir(): """Resolve the forgemods/required dir from override or .env. Halt if missing.""" src = None origin = None if len(sys.argv) > 1 and sys.argv[1]: src, origin = sys.argv[1], "CLI arg" elif os.environ.get("MODS_DIST_DIR"): src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env" else: src = read_env_dist_root() origin = "$DISTRIBUTION_WEB_ROOT" if not src: sys.exit( "no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env" ) candidates = [os.path.join(src, REQUIRED_SUBPATH), src] for cand in candidates: if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required": return cand, origin for cand in candidates: if os.path.isdir(cand): return cand, origin sys.exit( f"forgemods/required dir not found (from {origin}={src!r}). " f"Tried: {', '.join(candidates)}" ) def derive_side(jar_path): """Return client|server|both from the jar's minecraft/neoforge dep side.""" try: with zipfile.ZipFile(jar_path) as z: data = z.read(MANIFEST) manifest = tomllib.loads(data.decode("utf-8")) except (KeyError, zipfile.BadZipFile, tomllib.TOMLDecodeError, OSError, UnicodeDecodeError): return "both" deps = manifest.get("dependencies", {}) if not isinstance(deps, dict): return "both" decided = None for dep_list in deps.values(): if not isinstance(dep_list, list): continue for d in dep_list: if not isinstance(d, dict): continue if d.get("modId") in ("minecraft", "neoforge"): side = d.get("side") if side == "CLIENT": return "client" if side == "SERVER": return "server" # BOTH or unspecified — keep looking; a later CLIENT/SERVER wins. decided = "both" return decided or "both" def main(): required_dir, origin = resolve_required_dir() jars = sorted(glob.glob(os.path.join(required_dir, "*.jar"))) if not jars: sys.exit(f"no jars found in {required_dir} (from {origin})") if os.path.isfile(SIDES_JSON): with open(SIDES_JSON, encoding="utf-8") as fh: sides = json.load(fh) if not isinstance(sides, dict): sys.exit(f"{SIDES_JSON} is not a JSON object") else: sides = {} existing_count = len(sides) added = {} defaulted_both = [] # newly-added jars that fell back to "both" for jar in jars: name = os.path.basename(jar) if name in sides: continue side = derive_side(jar) sides[name] = side added[name] = side if side == "both": defaulted_both.append(name) # Stable, human-diffable output. ordered = {k: sides[k] for k in sorted(sides)} with open(SIDES_JSON, "w", encoding="utf-8") as fh: json.dump(ordered, fh, indent=2, ensure_ascii=False) fh.write("\n") # Warn about stale keys (in mods-sides.json but no jar in the dist dir). present = {os.path.basename(j) for j in jars} stale = sorted(k for k in sides if k not in present) counts = {"client": 0, "server": 0, "both": 0} for v in sides.values(): if v in counts: counts[v] += 1 print(f"source: {required_dir} (from {origin})") print(f"jars in dist: {len(jars)} | pre-existing entries: {existing_count} | added: {len(added)}") print(f"sides totals — both: {counts['both']} client: {counts['client']} server: {counts['server']}") if added: print(f"\nnewly classified ({len(added)}):") for name, side in sorted(added.items()): print(f" {side:6} {name}") if defaulted_both: print(f"\nDEFAULTED to 'both' ({len(defaulted_both)}) — review for cosmetic-client mods:") for name in defaulted_both: print(f" {name}") if stale: print(f"\nWARNING — {len(stale)} key(s) in mods-sides.json have no jar in the dist dir:") for name in stale: print(f" {name}") print(f"\nwrote {SIDES_JSON}") if __name__ == "__main__": main()