|
| 1 | +"""Scaffold a satellite data repository from the proven game-catalog layout. |
| 2 | +
|
| 3 | +Writes the files only. Creating the GitHub repository is left to a person — |
| 4 | +it is an org-level change — and the remaining one-off steps are printed at |
| 5 | +the end, including the one that silently broke game-catalog's first deploys. |
| 6 | +
|
| 7 | +Example: |
| 8 | + python -m machine.new_satellite --repo game-catalog --category game \ |
| 9 | + --title "Game catalog" --plural games --date-field release_date \ |
| 10 | + --range rating:0:5 --range metacritic:0:100 --out ../game-catalog |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import argparse |
| 16 | +import re |
| 17 | +import sys |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +TEMPLATE = Path(__file__).with_name("satellite_template") |
| 21 | +PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}") |
| 22 | +# Template files stored under a name git or pytest would otherwise act on. |
| 23 | +RENAMES = {"gitignore": ".gitignore", "tests_test_validate.py": "tests/test_validate.py"} |
| 24 | + |
| 25 | + |
| 26 | +def parse_range(text: str) -> tuple[str, float, float]: |
| 27 | + field, low, high = text.split(":") |
| 28 | + return field, float(low), float(high) |
| 29 | + |
| 30 | + |
| 31 | +def render(text: str, values: dict[str, str]) -> str: |
| 32 | + def replace(match: re.Match[str]) -> str: |
| 33 | + key = match.group(1) |
| 34 | + if key not in values: |
| 35 | + raise KeyError(f"template placeholder {{{{{key}}}}} has no value") |
| 36 | + return values[key] |
| 37 | + return PLACEHOLDER.sub(replace, text) |
| 38 | + |
| 39 | + |
| 40 | +def values_from(args: argparse.Namespace) -> dict[str, str]: |
| 41 | + ranges = {field: (low, high) for field, low, high in args.range} |
| 42 | + return { |
| 43 | + "repo": args.repo, |
| 44 | + "category": args.category, |
| 45 | + "title": args.title, |
| 46 | + "plural": args.plural, |
| 47 | + "description": args.description, |
| 48 | + "date_fields": repr(tuple(args.date_field)), |
| 49 | + "ranges": repr({k: (int(a) if a.is_integer() else a, int(b) if b.is_integer() else b) |
| 50 | + for k, (a, b) in ranges.items()}), |
| 51 | + } |
| 52 | + |
| 53 | + |
| 54 | +def scaffold(out: Path, values: dict[str, str]) -> list[Path]: |
| 55 | + if out.exists() and any(out.iterdir()): |
| 56 | + raise SystemExit(f"{out} is not empty; refusing to overwrite") |
| 57 | + written = [] |
| 58 | + for source in sorted(TEMPLATE.rglob("*")): |
| 59 | + if source.is_dir(): |
| 60 | + continue |
| 61 | + rel = source.relative_to(TEMPLATE).as_posix() |
| 62 | + target = out / RENAMES.get(rel, rel) |
| 63 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 64 | + if source.suffix in {".py", ".md", ".toml", ".yml", ".html", ""} or source.name == "gitignore": |
| 65 | + target.write_text(render(source.read_text(encoding="utf-8"), values), |
| 66 | + encoding="utf-8", newline="\n") |
| 67 | + else: |
| 68 | + target.write_bytes(source.read_bytes()) |
| 69 | + written.append(target) |
| 70 | + (out / "data" / values["category"]).mkdir(parents=True, exist_ok=True) |
| 71 | + (out / "README.md").write_text(readme(values), encoding="utf-8", newline="\n") |
| 72 | + written.append(out / "README.md") |
| 73 | + return written |
| 74 | + |
| 75 | + |
| 76 | +def readme(v: dict[str, str]) -> str: |
| 77 | + return f"""# {v['repo']} |
| 78 | +
|
| 79 | +[](https://github.com/GetTechAPI/{v['repo']}/actions/workflows/validate-data.yml) |
| 80 | +
|
| 81 | +{v['description']} |
| 82 | +
|
| 83 | +Code is MIT; the records under `data/` are CC BY-SA 4.0 ([DATA_LICENSE.md](DATA_LICENSE.md)). |
| 84 | +
|
| 85 | +## Layout |
| 86 | +
|
| 87 | +``` |
| 88 | +data/{v['category']}/<bucket>/<slug>.json # bucket = first two slug characters |
| 89 | +app/validate.py # schema / slug / date / range checks |
| 90 | +site/build.py # summary.json + history.json |
| 91 | +``` |
| 92 | +
|
| 93 | +A record needs `slug`, `name`, `source_urls` and `verified`. |
| 94 | +
|
| 95 | +## Self-check |
| 96 | +
|
| 97 | +```bash |
| 98 | +python -m app.validate |
| 99 | +python -m pytest -q |
| 100 | +``` |
| 101 | +
|
| 102 | +## Site |
| 103 | +
|
| 104 | +`python site/build.py` writes `summary.json` (`{{"count": N}}`) and |
| 105 | +`history.json` (one point per data commit). The TechAPI homepage reads these |
| 106 | +to count this catalog; there is deliberately no listing of every record. |
| 107 | +
|
| 108 | +## Branching (git-flow) |
| 109 | +
|
| 110 | +`develop` is the default branch; `main` is the released state and deploys the |
| 111 | +site. Pull requests target `develop`; a release is a PR from `develop` to `main`. |
| 112 | +""" |
| 113 | + |
| 114 | + |
| 115 | +def checklist(v: dict[str, str]) -> str: |
| 116 | + repo = f"GetTechAPI/{v['repo']}" |
| 117 | + return f""" |
| 118 | +Next steps (not automated — each changes the org): |
| 119 | +
|
| 120 | + 1. gh repo create {repo} --public |
| 121 | + 2. push the scaffold to develop, then develop:main |
| 122 | + 3. gh api -X POST repos/{repo}/pages -f build_type=workflow |
| 123 | + 4. gh api -X POST repos/{repo}/environments/github-pages/deployment-branch-policies -f name=main |
| 124 | + (skip this and every deploy fails with no log — game-catalog, 2026-09-17) |
| 125 | + 5. add {{"name": "{repo}", "branches": ["develop", "main"]}} to TechMachine machine/repos.json |
| 126 | + and the summary.json URL to its endpoints |
| 127 | + 6. add {{ key: "{v['plural']}", label: "{v['plural']}", base: "https://gettechapi.github.io/{v['repo']}/" }} |
| 128 | + to SATELLITES in TechAPI site/src/scripts/techapi.js |
| 129 | +""" |
| 130 | + |
| 131 | + |
| 132 | +def main(argv: list[str] | None = None) -> int: |
| 133 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 134 | + parser.add_argument("--repo", required=True, help="repository name, e.g. game-catalog") |
| 135 | + parser.add_argument("--category", required=True, help="data directory, e.g. game") |
| 136 | + parser.add_argument("--title", required=True, help='page title, e.g. "Game catalog"') |
| 137 | + parser.add_argument("--plural", required=True, help="count label, e.g. games") |
| 138 | + parser.add_argument("--description", default="Split out of TechAPI.") |
| 139 | + parser.add_argument("--date-field", action="append", default=[], help="YYYY-MM-DD field, repeatable") |
| 140 | + parser.add_argument("--range", action="append", default=[], type=parse_range, |
| 141 | + help="field:low:high, repeatable") |
| 142 | + parser.add_argument("--out", type=Path, required=True) |
| 143 | + args = parser.parse_args(argv) |
| 144 | + |
| 145 | + values = values_from(args) |
| 146 | + written = scaffold(args.out, values) |
| 147 | + print(f"wrote {len(written)} files to {args.out}") |
| 148 | + print(checklist(values)) |
| 149 | + return 0 |
| 150 | + |
| 151 | + |
| 152 | +if __name__ == "__main__": |
| 153 | + sys.exit(main()) |
0 commit comments