Coverage for plugin/scripts/init_scaffold.py: 100%
47 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Write a rhiza-managed repo's one non-synced file: `.rhiza/template.yml`.
4Bundled with this plugin so `/rhiza:init` can point a repo at a template without
5the `rhiza` CLI. That pointer is the only file `/init` writes itself — everything
6else is another step's job:
8 project skeleton the skeleton procedure (scripts/init_skeleton.py)
9 Makefile, CI, docs the template sync, via `/rhiza:update`
10 license the license procedure (scripts/set_license.py)
11 Python version the python-version procedure (scripts/set_python_version.py)
12 README / mkdocs.yml `/rhiza:docs`
13 first module + test the user's own
15The file is created **only if absent** — an existing `template.yml` is left
16untouched (bumping one is `/rhiza:update`'s job).
18Usage:
19 uv run --python 3.12 --no-project python \
20 scripts/init_scaffold.py [TARGET] \
21 [--host github|gitlab] [--template-host github|gitlab] \
22 [--language python|go|rust] \
23 [--template-repo owner/repo] [--ref TAG] [--json]
24"""
26from __future__ import annotations
28import argparse
29import json
30import sys
31from pathlib import Path
32from typing import Any
34# All three languages share `jebel-quant/rhiza`: the template is multi-language, with a
35# per-language toolchain bundle (`python-core` / `rust-core` / `go-core`) layered on a
36# neutral `core`. There is no per-language template repository; an `owner/repo` override
37# is for a fork of this one.
38DEFAULT_TEMPLATE_REPO = {
39 "python": "jebel-quant/rhiza",
40 "go": "jebel-quant/rhiza",
41 "rust": "jebel-quant/rhiza",
42}
44# Which profile a (language, host) pair resolves to. Python's names are unprefixed
45# for backwards compatibility — renaming them would break the pointer of every repo
46# already synced.
47#
48# Rust and Go map both hosts to their `-local` profile, because that is the only profile
49# the template defines for either. Hosted-CI profiles are made almost entirely of
50# workflows, and the template's `github`/`gitlab` bundles still ship Python ones (a
51# release job running `uv build` against PyPI, Dependabot declaring the `uv` ecosystem),
52# so they land together with each language's workflows. Writing a pointer at a
53# `rust-github-project` that does not exist would fail the first sync with
54# "Profile 'rust-github-project' was not found"; a Rust or Go repo gets working local
55# tooling now and gains CI when it exists.
56#
57# `scripts/check_template_profile.py` is what keeps this table honest — `/rhiza:init`
58# checks the profile against the ref it is about to pin, because every wrong entry here
59# has cost a user their first `/rhiza:update` rather than failing at `/init`.
60_PROFILES: dict[str, dict[str, str]] = {
61 "python": {"github": "github-project", "gitlab": "gitlab-project"},
62 "go": {"github": "go-local", "gitlab": "go-local"},
63 "rust": {"github": "rust-local", "gitlab": "rust-local"},
64}
67def profile_for_host(host: str, language: str = "python") -> str:
68 """Return the sync profile matching the git hosting platform and language."""
69 platform = "gitlab" if host == "gitlab" else "github"
70 return _PROFILES.get(language, _PROFILES["python"])[platform]
73def render_template_yml(
74 repo: str, ref: str, host: str, language: str, template_host: str | None = None
75) -> str:
76 """Render `.rhiza/template.yml` (mirrors template.yml.jinja2).
78 Two independent facts, which must not be conflated:
80 * *host* — where **this repo** lives. It selects the ``profiles:`` entry, so a
81 GitLab repo gets ``gitlab-project`` and its CI, not GitHub's.
82 * *template_host* — where the **template** lives. It becomes ``template-host:``,
83 which `sync.py` turns into the clone URL.
85 They are usually different: a GitLab-hosted project following ``jebel-quant/rhiza``
86 is on GitLab, but the template is on GitHub. Deriving one from the other emitted
87 ``template-host: gitlab`` for every GitLab repo, so the first sync tried to clone
88 the template from gitlab.com and died with "could not read Username". Default
89 *template_host* to GitHub — where the rhiza templates are — not to *host*.
90 """
91 lines = [f'repository: "{repo}"', f'ref: "{ref}"']
92 if (template_host or "github") == "gitlab":
93 lines.append("template-host: gitlab")
94 if language != "python":
95 lines.append(f"language: {language}")
96 lines += ["", "profiles:", f" - {profile_for_host(host, language)}", ""]
97 return "\n".join(lines)
100def scaffold(
101 target: Path,
102 *,
103 host: str,
104 language: str,
105 template_repo: str,
106 ref: str,
107 template_host: str | None = None,
108) -> dict[str, Any]:
109 """Write `.rhiza/template.yml` if absent; return a summary dict."""
110 path = target / ".rhiza" / "template.yml"
111 created: list[str] = []
112 skipped: list[str] = []
114 if path.exists():
115 skipped.append(".rhiza/template.yml")
116 else:
117 path.parent.mkdir(parents=True, exist_ok=True)
118 path.write_text(
119 render_template_yml(template_repo, ref, host, language, template_host), encoding="utf-8"
120 )
121 created.append(".rhiza/template.yml")
123 return {
124 "target": str(target),
125 "language": language,
126 "template_repository": template_repo,
127 "ref": ref,
128 "profile": profile_for_host(host, language),
129 "template_host": template_host or "github",
130 "created": created,
131 "skipped": skipped,
132 }
135def main(argv: list[str] | None = None) -> int:
136 """Entry point: parse args, write the pointer, and return an exit code."""
137 parser = argparse.ArgumentParser(
138 description="Write a rhiza-managed repo's .rhiza/template.yml pointer.",
139 )
140 parser.add_argument(
141 "target", nargs="?", default=".", help="Repository root (default: current directory)."
142 )
143 parser.add_argument(
144 "--host",
145 choices=("github", "gitlab"),
146 default="github",
147 help="Where THIS repo lives; selects the profile (and its CI).",
148 )
149 parser.add_argument(
150 "--template-host",
151 choices=("github", "gitlab"),
152 default="github",
153 help="Where the TEMPLATE lives; sets the clone URL. Usually github, even for "
154 "a GitLab-hosted repo, because the rhiza templates are on GitHub.",
155 )
156 parser.add_argument(
157 "--language",
158 choices=tuple(DEFAULT_TEMPLATE_REPO),
159 default="python",
160 help="Project language; selects the default template repo and the profile prefix.",
161 )
162 parser.add_argument(
163 "--template-repo", help="Template repository owner/repo (default: by language)."
164 )
165 parser.add_argument("--ref", default="main", help="Template branch/tag (default: main).")
166 parser.add_argument(
167 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
168 )
169 args = parser.parse_args(argv)
171 summary = scaffold(
172 Path(args.target).resolve(),
173 host=args.host,
174 language=args.language,
175 template_repo=args.template_repo or DEFAULT_TEMPLATE_REPO[args.language],
176 ref=args.ref,
177 template_host=args.template_host,
178 )
180 if args.json_output:
181 print(json.dumps(summary, indent=2))
182 else:
183 for path in summary["created"]:
184 print(f"created {path}")
185 for path in summary["skipped"]:
186 print(f"skipped {path} (already exists)", file=sys.stderr)
187 return 0
190if __name__ == "__main__":
191 raise SystemExit(main())