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

1#!/usr/bin/env python3 

2"""Write a rhiza-managed repo's one non-synced file: `.rhiza/template.yml`. 

3 

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: 

7 

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 

14 

15The file is created **only if absent** — an existing `template.yml` is left 

16untouched (bumping one is `/rhiza:update`'s job). 

17 

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""" 

25 

26from __future__ import annotations 

27 

28import argparse 

29import json 

30import sys 

31from pathlib import Path 

32from typing import Any 

33 

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} 

43 

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} 

65 

66 

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] 

71 

72 

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). 

77 

78 Two independent facts, which must not be conflated: 

79 

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. 

84 

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) 

98 

99 

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] = [] 

113 

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") 

122 

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 } 

133 

134 

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) 

170 

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 ) 

179 

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 

188 

189 

190if __name__ == "__main__": 

191 raise SystemExit(main())