Coverage for plugin/scripts/install_completions.py: 100%

87 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Install shell tab-completion for make targets — the engine behind `/rhiza:completions`. 

3 

4Copies the bundled completion scripts (``scripts/completions/``) into the user's 

5completion directory, so ``make <TAB>`` lists the targets of whatever project the shell 

6is sitting in. 

7 

8**This is a per-machine concern that used to be driven by repo content.** The rhiza 

9template shipped these two scripts into every managed repo and gave each one a 

10``make install-completions`` target that copied them to 

11``${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/make`` — a *global* 

12path for the ``make`` command in general. So N synced repos each carried an identical 

13copy of a script that installs to one shared location, and the last copy to run won. 

14Nothing in the scripts varies per repository: they discover targets by parsing the make 

15database in the current directory. The plugin is installed once per machine, which is 

16where a machine-wide file belongs. 

17 

18Two things this does that ``cp`` did not: 

19 

20* **It refuses to clobber a completion it did not write.** The installed filenames stay 

21 ``make`` and ``_make`` — namespacing them would be politer and would never fire for a 

22 bare ``make <TAB>``, which is the entire point — so the destination is exactly where a 

23 generic make completion from some other source would sit. A destination whose contents 

24 don't carry the ``_rhiza_make`` marker needs ``--force``, reported as exit code 3. 

25* **It can say what it would do.** ``--dry-run`` prints the destinations and the verdict 

26 for each shell without writing anything. 

27 

28Usage: 

29 uv run --python 3.12 --no-project python \ 

30 scripts/install_completions.py [--shell bash|zsh|both] [--dry-run] [--force] \ 

31 [--json] 

32 

33``--shell both`` is the default, for the same reason the retired make target defaulted 

34its ``SHELL_KIND`` to ``both``: nothing here can tell which shell the user actually logs 

35in with, and an unused completion file is inert. Exit code 3 means a destination holds a 

36foreign completion and ``--force`` was not given. 

37""" 

38 

39from __future__ import annotations 

40 

41import argparse 

42import json 

43import os 

44import sys 

45from collections.abc import Mapping 

46from pathlib import Path 

47from typing import Any, NamedTuple 

48 

49# The bundled completion scripts. A sibling directory rather than a location of their 

50# own, for the same reason every script lives in `scripts/`: the tree is the unit that 

51# gates and ships, not the file. 

52_ASSETS = Path(__file__).resolve().parent / "completions" 

53 

54MARKER = "_rhiza_make" 

55"""The function-name prefix both scripts define — how a destination is recognised as ours.""" 

56 

57NEEDS_FORCE = 3 

58"""Exit code for "a destination holds a completion this plugin did not write".""" 

59 

60BOTH = "both" 

61"""The ``--shell`` value that selects every supported shell, and the default.""" 

62 

63 

64class Shell(NamedTuple): 

65 """One shell: its bundled asset, where the asset installs, and the follow-up step. 

66 

67 ``installed_as`` is deliberately the *generic* name — ``make`` for bash, ``_make`` 

68 for zsh — because both shells resolve a completion by the name of the command it 

69 completes. There is no spelling that both avoids shadowing another make completion 

70 and still fires for a bare ``make <TAB>``. 

71 """ 

72 

73 kind: str 

74 asset: str 

75 subdir: str 

76 installed_as: str 

77 hint: str 

78 

79 

80SHELLS: tuple[Shell, ...] = ( 

81 Shell( 

82 kind="bash", 

83 asset="rhiza-completion.bash", 

84 subdir="bash-completion/completions", 

85 installed_as="make", 

86 hint="start a new shell, or run: source {path}", 

87 ), 

88 Shell( 

89 kind="zsh", 

90 asset="rhiza-completion.zsh", 

91 subdir="zsh/site-functions", 

92 installed_as="_make", 

93 hint=( 

94 "if completion does not activate, add to ~/.zshrc: " 

95 "fpath=({parent} $fpath); autoload -U compinit && compinit" 

96 ), 

97 ), 

98) 

99 

100# What happened, and what would have happened. Two maps rather than one verb plus a 

101# prefix: "would installed" is not English, and the dry-run wording is the half a 

102# reader is most likely to mistake for a change that landed. 

103_DONE = { 

104 "install": "installed", 

105 "update": "updated", 

106 "replace": "replaced", 

107 "unchanged": "already up to date", 

108} 

109_PLANNED = { 

110 "install": "would install", 

111 "update": "would update", 

112 "replace": "would replace", 

113 "unchanged": "already up to date", 

114} 

115 

116 

117def shells(kind: str) -> list[Shell]: 

118 """The shells ``--shell KIND`` selects — one of them, or all of them. 

119 

120 >>> [shell.kind for shell in shells("zsh")] 

121 ['zsh'] 

122 >>> [shell.kind for shell in shells(BOTH)] 

123 ['bash', 'zsh'] 

124 """ 

125 return [shell for shell in SHELLS if kind in (shell.kind, BOTH)] 

126 

127 

128def data_home(env: Mapping[str, str] | None = None) -> Path: 

129 """The XDG data home, with the same fallback the shell idiom uses. 

130 

131 ``${XDG_DATA_HOME:-$HOME/.local/share}`` treats an *empty* value as unset, so an 

132 exported-but-blank variable falls back rather than resolving to the current 

133 directory: 

134 

135 >>> data_home({"XDG_DATA_HOME": "/opt/data"}).as_posix() 

136 '/opt/data' 

137 >>> data_home({"XDG_DATA_HOME": "", "HOME": "/home/ada"}).as_posix() 

138 '/home/ada/.local/share' 

139 """ 

140 environ = os.environ if env is None else env 

141 explicit = environ.get("XDG_DATA_HOME", "") 

142 if explicit: 

143 return Path(explicit) 

144 return Path(environ.get("HOME") or "~").expanduser() / ".local" / "share" 

145 

146 

147def destination(shell: Shell, env: Mapping[str, str] | None = None) -> Path: 

148 """Where *shell*'s completion is installed.""" 

149 return data_home(env) / shell.subdir / shell.installed_as 

150 

151 

152def asset(shell: Shell) -> Path: 

153 """The bundled completion script for *shell*.""" 

154 return _ASSETS / shell.asset 

155 

156 

157def is_ours(text: str) -> bool: 

158 """Was *text* written from one of the bundled scripts? 

159 

160 Both define functions prefixed ``_rhiza_make``, which a generic make completion 

161 from another source will not: 

162 

163 >>> is_ours("_rhiza_make_completion() { :; }") 

164 True 

165 >>> is_ours("# some other make completion\\ncomplete -W 'all' make") 

166 False 

167 """ 

168 return MARKER in text 

169 

170 

171def classify(dest: Path, body: str, *, force: bool) -> str: 

172 """What installing *body* at *dest* would amount to. 

173 

174 One of ``install`` (nothing there yet), ``unchanged`` (byte-identical), 

175 ``update`` (an older copy of ours), ``replace`` (a foreign file, forced) or 

176 ``blocked`` (a foreign file, not forced). 

177 """ 

178 if dest.is_dir(): # not ours to replace, whatever --force says 

179 return "blocked" 

180 if not dest.exists(): 

181 return "install" 

182 # `errors="replace"` rather than a guard: a destination that isn't valid UTF-8 is 

183 # certainly not one of ours, and it should reach the `blocked` verdict below by the 

184 # normal route rather than as a traceback. 

185 current = dest.read_text(encoding="utf-8", errors="replace") 

186 if current == body: 

187 return "unchanged" 

188 if is_ours(current): 

189 return "update" 

190 return "replace" if force else "blocked" 

191 

192 

193def install_one( 

194 shell: Shell, *, env: Mapping[str, str] | None, force: bool, dry_run: bool 

195) -> dict[str, Any]: 

196 """Install *shell*'s completion, or work out what doing so would mean.""" 

197 dest = destination(shell, env) 

198 body = asset(shell).read_text(encoding="utf-8") 

199 action = classify(dest, body, force=force) 

200 written = action in ("install", "update", "replace") and not dry_run 

201 if written: 

202 dest.parent.mkdir(parents=True, exist_ok=True) 

203 dest.write_text(body, encoding="utf-8") 

204 return { 

205 "shell": shell.kind, 

206 "source": str(asset(shell)), 

207 "path": str(dest), 

208 "action": action, 

209 "written": written, 

210 "hint": shell.hint.format(path=dest, parent=dest.parent), 

211 } 

212 

213 

214def install( 

215 kind: str = BOTH, 

216 *, 

217 env: Mapping[str, str] | None = None, 

218 force: bool = False, 

219 dry_run: bool = False, 

220) -> dict[str, Any]: 

221 """Install the completion for *kind* (``bash``, ``zsh`` or ``both``); return a summary.""" 

222 entries = [install_one(shell, env=env, force=force, dry_run=dry_run) for shell in shells(kind)] 

223 return { 

224 "dry_run": dry_run, 

225 "force": force, 

226 "shells": entries, 

227 "needs_force": any(entry["action"] == "blocked" for entry in entries), 

228 } 

229 

230 

231def report(summary: dict[str, Any]) -> None: 

232 """Print the human summary — one line per shell, plus its follow-up step.""" 

233 for entry in summary["shells"]: 

234 if entry["action"] == "blocked": 

235 print( 

236 f"{entry['shell']}: {entry['path']} holds a completion this plugin did " 

237 "not write — pass --force to replace it", 

238 file=sys.stderr, 

239 ) 

240 continue 

241 verbs = _DONE if entry["written"] or entry["action"] == "unchanged" else _PLANNED 

242 print(f"{entry['shell']}: {verbs[entry['action']]} {entry['path']}") 

243 if not summary["dry_run"]: 

244 print(f" next: {entry['hint']}") 

245 

246 

247def missing_assets(kind: str) -> list[str]: 

248 """Bundled completion scripts *kind* needs that aren't shipped — a packaging fault.""" 

249 return [shell.asset for shell in shells(kind) if not asset(shell).is_file()] 

250 

251 

252def main(argv: list[str] | None = None) -> int: 

253 """Entry point: install the selected completions and return an exit code.""" 

254 parser = argparse.ArgumentParser(description="Install make tab-completion for your shell.") 

255 parser.add_argument( 

256 "--shell", 

257 choices=[*(shell.kind for shell in SHELLS), BOTH], 

258 default=BOTH, 

259 help="Which shell to install for (default: both — the login shell is not detectable).", 

260 ) 

261 parser.add_argument( 

262 "--force", action="store_true", help="Replace a completion this plugin did not write." 

263 ) 

264 parser.add_argument( 

265 "--dry-run", 

266 dest="dry_run", 

267 action="store_true", 

268 help="Report the destinations and the verdict for each shell; write nothing.", 

269 ) 

270 parser.add_argument( 

271 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

272 ) 

273 args = parser.parse_args(argv) 

274 

275 absent = missing_assets(args.shell) 

276 if absent: 

277 parser.error(f"bundled completion script(s) missing from {_ASSETS}: {', '.join(absent)}") 

278 

279 summary = install(args.shell, force=args.force, dry_run=args.dry_run) 

280 if args.json_output: 

281 print(json.dumps(summary, indent=2)) 

282 else: 

283 report(summary) 

284 return NEEDS_FORCE if summary["needs_force"] else 0 

285 

286 

287if __name__ == "__main__": 

288 raise SystemExit(main())