Coverage for src/footman/tasks/self_.py: 88%

131 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""`fm self.*` — the runner managing its own installation. 

2 

3Putting the CLI on your PATH, adding packages beside it, dropping them 

4again, taking the whole thing off, and saying where it keeps things. Four 

5verbs and a question, because once there is more than one of them a set of 

6global flags is the wrong container: these are commands, with their own 

7options, help, completion and `--json`. 

8 

9Every one of them is `expose="always"`: they are about the *runner*, so 

10they mean the same thing inside a project and outside one. That is the 

11whole reason built-ins had to join the cascade — a self-management command 

12that vanished the moment you stepped into a checkout would be useless 

13exactly when you reach for it. 

14 

15uv owns the installation itself; footman only ever composes the command 

16line and reads back what uv recorded. The receipt 

17(`<uv tool dir>/<dist>/uv-receipt.toml`) is that record, and reading it is 

18what makes `add`/`remove` additive instead of replacing your environment 

19every time. 

20""" 

21 

22from __future__ import annotations 

23 

24import json 

25import os 

26import shutil 

27import tomllib 

28from pathlib import Path 

29from typing import Annotated, Any, Literal 

30 

31from footman import _config, _paths, context 

32from footman.context import fail, run 

33from footman.params import doc, suggest 

34from footman.registry import Group, group 

35 

36tasks: Group = group("self", help="Manage this runner's own installation") 

37 

38 

39# --- what uv recorded --------------------------------------------------------- 

40 

41 

42def _uv() -> str: 

43 """The uv this runner hands off to — its own environment first, then 

44 PATH, exactly as the handoffs resolve it.""" 

45 from footman import _script 

46 

47 found = _script.find_uv() 

48 if found is None: 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true

49 fail( 

50 "no uv found — this runner's environment has none and PATH has " 

51 "none; install uv first (https://docs.astral.sh/uv/)" 

52 ) 

53 return found 

54 

55 

56def _dist() -> str: 

57 """The distribution that ships this runner — the same name the uv 

58 handoffs reason about, so a branded CLI installs itself, not footman.""" 

59 return _paths.dist() 

60 

61 

62def _tool_dir() -> Path: 

63 """uv's tools directory, asked of uv rather than guessed.""" 

64 done = run([_uv(), "tool", "dir"], capture=True, nofail=True, recorded=False) 

65 if done.code != 0 or not done.stdout.strip(): 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 fail(f"uv could not say where its tools live: {done.stderr.strip()}") 

67 return Path(done.stdout.strip()) 

68 

69 

70def _receipt_requirements() -> tuple[str, ...]: 

71 """Everything the tool environment was installed with, per uv's receipt. 

72 

73 uv rewrites the environment from the requirements it is *given*, so an 

74 upgrade that did not repeat your extras would silently drop them. This 

75 is how `install`/`add`/`remove` stay additive: read what is there, and 

76 hand the whole set back. 

77 

78 Empty when nothing is installed yet — which is exactly the state 

79 `install` and `add` are for. 

80 """ 

81 receipt = _tool_dir() / _dist() / "uv-receipt.toml" 

82 try: 

83 with open(receipt, "rb") as fh: 

84 data = tomllib.load(fh) 

85 except (OSError, tomllib.TOMLDecodeError): 

86 return () 

87 entries = data.get("tool", {}).get("requirements", []) 

88 names = [e.get("name") for e in entries if isinstance(e, dict)] 

89 return tuple(n for n in names if isinstance(n, str)) 

90 

91 

92def _extras() -> tuple[str, ...]: 

93 """The packages *you* added: the receipt minus what the runner needs. 

94 

95 The distribution itself is the thing being installed, and `uv` is 

96 bundled so the handoffs work where PATH has none — neither is yours to 

97 remove, so neither is offered. 

98 """ 

99 reserved = {_dist(), "uv"} 

100 return tuple(n for n in _receipt_requirements() if n not in reserved) 

101 

102 

103def _added() -> list[str]: 

104 """Completion candidates for `remove`: what is actually there to drop.""" 

105 try: 

106 return list(_extras()) 

107 except Exception: 

108 return [] # a completer that cannot answer offers nothing, never raises 

109 

110 

111# --- installing --------------------------------------------------------------- 

112 

113 

114def _install(extras: tuple[str, ...]) -> None: 

115 """One `uv tool install`, carrying the whole requirement set. 

116 

117 `--upgrade` means the command never has to ask whether this is a first 

118 install or a move to the latest: both are the same sentence. uv's own 

119 output is the report; nothing here paraphrases it. 

120 """ 

121 cmd = [_uv(), "tool", "install", "--upgrade", _dist(), "--with", "uv"] 

122 for name in sorted(set(extras)): 

123 cmd += ["--with", name] 

124 run(cmd) 

125 

126 

127def _rediscover() -> tuple[str, ...] | None: 

128 """Refresh the discovered built-in list, when the mode asks for it. 

129 

130 `auto` is the only mode where footman writes that list; `manual` says 

131 it is yours, `internal` and `none` ignore it, and rewriting it under 

132 any of those would be footman editing something it was told not to. 

133 

134 Candidates come from the **installed** environment, not this one: the 

135 tool environment is a different world from the process doing the 

136 installing, so its entry points are only knowable by asking its own 

137 interpreter. 

138 """ 

139 if _config.discovery_mode() != "auto": 

140 return None 

141 names, prefix = _candidates_in(_tool_dir() / _dist()) 

142 if prefix is None: 

143 return None # nothing answered, so there is nothing to record 

144 _config.write_discovered(names, prefix) 

145 return names 

146 

147 

148def _candidates_in(env: Path) -> tuple[tuple[str, ...], str | None]: 

149 """Every `footman.builtin` entry point in *env*, and *env*'s own prefix. 

150 

151 A provider declares itself a *candidate* by advertising in that group — 

152 "I am meant to be mounted as a built-in, not merely mountable". Whether 

153 a candidate is actually mounted stays the machine owner's call, through 

154 `builtins.discovery_mode`: a package cannot mount itself just by being 

155 installed, which matters because in a project environment every 

156 dependency shares one metadata space. 

157 

158 The prefix comes back with the names because the record is keyed by the 

159 environment it *describes*, and that is never this process: the probe 

160 runs in the tool environment precisely because its entry points are 

161 only knowable by asking its own interpreter. Keying by the writer's 

162 `sys.prefix` instead files the answer under whoever happened to type 

163 the command — the very bug the key was added to fix, wearing a 

164 different hat. So the interpreter that answers also says who it is, 

165 rather than this side inferring a prefix from the directory it spawned. 

166 """ 

167 python = env / ("Scripts/python.exe" if os.name == "nt" else "bin/python") 

168 if not python.is_file(): 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true

169 return (), None 

170 probe = ( 

171 "import json,sys;from importlib.metadata import entry_points;" 

172 "print(json.dumps([sys.prefix, sorted({e.name for e in " 

173 "entry_points(group='footman.builtin')})]))" 

174 ) 

175 done = run([str(python), "-c", probe], capture=True, nofail=True, recorded=False) 

176 if done.code != 0: 

177 return (), None 

178 try: 

179 prefix, found = json.loads(done.stdout) 

180 except (ValueError, TypeError): 

181 return (), None 

182 if not isinstance(prefix, str) or not isinstance(found, list): 

183 return (), None 

184 return tuple(n for n in found if isinstance(n, str)), prefix 

185 

186 

187@tasks.task(expose="always") 

188def install() -> None: 

189 """Put this CLI on your PATH with uv, or bring it up to date. 

190 

191 Always the latest release from the index — running it from inside a 

192 project (`uv run fm self.install`) installs the *global* copy at 

193 latest, never the version it is running itself. Packages you added 

194 with `self.add` are carried over rather than dropped. 

195 """ 

196 _install(_extras()) 

197 _rediscover() 

198 

199 

200@tasks.task(expose="always") 

201def add( 

202 *packages: Annotated[str, doc("distributions to install beside the runner")], 

203) -> None: 

204 """Install packages alongside the runner, and mount what they offer. 

205 

206 The one-stop call: it installs the runner if it is not there yet, adds 

207 the packages to its environment, and — under the default 

208 `builtins.discovery_mode = "auto"` — records whatever built-in tasks 

209 they advertise, so their commands answer straight away. 

210 """ 

211 if not packages: 

212 fail("name at least one package to add") 

213 _install(_extras() + tuple(packages)) 

214 if (found := _rediscover()) is not None and found: 

215 print(f"built-in tasks now available: {', '.join(found)}") 

216 

217 

218@tasks.task(expose="always") 

219def remove( 

220 *packages: Annotated[ 

221 str, 

222 suggest(_added), 

223 doc("packages to drop from the runner's environment"), 

224 ], 

225) -> None: 

226 """Drop packages you added beside the runner. 

227 

228 Completion offers exactly what is there to drop — the runner's own 

229 distribution and the bundled `uv` are not yours to remove, so they are 

230 never offered, and a name that was never added is refused rather than 

231 handed to uv. 

232 """ 

233 if not packages: 

234 fail("name at least one package to remove") 

235 have = _extras() 

236 for name in packages: 

237 if name not in have: 

238 listed = ", ".join(have) or "nothing" 

239 fail(f"{name} was not added to this runner (added: {listed})") 

240 _install(tuple(n for n in have if n not in packages)) 

241 _rediscover() 

242 

243 

244@tasks.task(expose="always") 

245def uninstall( 

246 purge: Annotated[bool, doc("also delete your config directory")] = False, 

247) -> None: 

248 """Take this CLI off your PATH, and clear what it left behind. 

249 

250 uv removes the tool environment; this removes what the runner placed 

251 outside it — the completion hooks (the script *and* its line in your 

252 shell's rc, or the rc would go on sourcing a file that is gone), the 

253 cache, and the data directory. 

254 

255 **Your config is kept** unless you ask for `--purge`: it is your 

256 writing, and deleting it silently would be the one unforgivable step of 

257 an uninstall. The data directory is shared, though — a plugin keeping 

258 state under `data_dir()` loses it here, which is the price of living in 

259 the runner's home instead of inventing one. 

260 """ 

261 run([_uv(), "tool", "uninstall", _dist()], nofail=True) 

262 for line in _remove_hooks(): 

263 print(line) 

264 gone = [] 

265 for folder in (_paths.footman_cache_dir(), _paths.footman_data_dir()): 

266 if folder.is_dir(): 

267 shutil.rmtree(folder, ignore_errors=True) 

268 gone.append(str(folder)) 

269 if purge and (config := _paths.footman_config_dir()).is_dir(): 

270 shutil.rmtree(config, ignore_errors=True) 

271 gone.append(str(config)) 

272 for where in gone: 

273 print(f"removed {where}") 

274 if not purge: 

275 print(f"kept {_paths.footman_config_dir()} (--purge removes it too)") 

276 

277 

278def _remove_hooks() -> list[str]: 

279 """Uninstall every shell completion hook this runner installed. 

280 

281 The hooks do **not** live under `data_dir()` — they sit *beside* it, at 

282 `<data home>/<prog>/completion.*`, keyed by the command name rather than 

283 the config stem (`~/.local/share/fm/` next to `~/.local/share/footman/`). 

284 Clearing the data directory therefore left them in place, and their rc 

285 lines with them, sourcing a script that no longer existed — which every 

286 new shell then complained about. 

287 

288 Only shells that actually have a script are touched: the script is the 

289 evidence an rc line was written, so nothing edits the rc of a shell this 

290 runner never installed into. 

291 """ 

292 from footman import _shellcomp 

293 

294 prog = _paths.prog() 

295 said: list[str] = [] 

296 for shell in _shellcomp.SHELLS: 

297 if _shellcomp.hook_path(shell, prog).is_file(): 

298 said += _shellcomp.uninstall(shell, prog) 

299 return said 

300 

301 

302# --- where things are --------------------------------------------------------- 

303 

304_PLACES: dict[str, Any] = { 

305 "cache": lambda: _paths.footman_cache_dir(), 

306 "data": lambda: _paths.footman_data_dir(), 

307 "config-dir": lambda: _paths.footman_config_dir(), 

308 "config-file": lambda: _paths.footman_config_file(), 

309 "user-tasks": lambda: context.user_tasks_file(), 

310 "builtins": lambda: _config.discovered_path(), 

311 "project-root": lambda: context.project_root(), 

312 "tool-dir": lambda: _tool_dir() / _dist(), 

313} 

314 

315Place = Literal[ 

316 "cache", 

317 "data", 

318 "config-dir", 

319 "config-file", 

320 "user-tasks", 

321 "builtins", 

322 "project-root", 

323 "tool-dir", 

324] 

325 

326 

327@tasks.task(expose="always") 

328def path( 

329 *which: Annotated[Place, doc("locations to print; none means every one")], 

330) -> dict[str, str] | None: 

331 """Say where this CLI keeps things — for scripts, and for finding out. 

332 

333 Named alone it prints one bare line, so `DIR=$(fm self.path data)` is 

334 the whole idiom. With no name it prints every location, and under 

335 `--json` it returns them as an object, so a script that wants three of 

336 them makes one call instead of three. 

337 

338 A location that does not apply here — `project-root` outside a project 

339 — is empty rather than absent: the question was answered. 

340 """ 

341 if which: 

342 for name in which: 

343 print(_PLACES[name]() or "") 

344 return None 

345 places = {place: str(where() or "") for place, where in _PLACES.items()} 

346 width = max(len(place) for place in places) 

347 for place, where in places.items(): 

348 print(f"{place.ljust(width)} {where}") 

349 return places