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
« 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.
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`.
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.
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"""
22from __future__ import annotations
24import json
25import os
26import shutil
27import tomllib
28from pathlib import Path
29from typing import Annotated, Any, Literal
31from footman import _config, _paths, context
32from footman.context import fail, run
33from footman.params import doc, suggest
34from footman.registry import Group, group
36tasks: Group = group("self", help="Manage this runner's own installation")
39# --- what uv recorded ---------------------------------------------------------
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
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
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()
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())
70def _receipt_requirements() -> tuple[str, ...]:
71 """Everything the tool environment was installed with, per uv's receipt.
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.
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))
92def _extras() -> tuple[str, ...]:
93 """The packages *you* added: the receipt minus what the runner needs.
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)
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
111# --- installing ---------------------------------------------------------------
114def _install(extras: tuple[str, ...]) -> None:
115 """One `uv tool install`, carrying the whole requirement set.
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)
127def _rediscover() -> tuple[str, ...] | None:
128 """Refresh the discovered built-in list, when the mode asks for it.
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.
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
148def _candidates_in(env: Path) -> tuple[tuple[str, ...], str | None]:
149 """Every `footman.builtin` entry point in *env*, and *env*'s own prefix.
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.
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
187@tasks.task(expose="always")
188def install() -> None:
189 """Put this CLI on your PATH with uv, or bring it up to date.
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()
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.
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)}")
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.
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()
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.
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.
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)")
278def _remove_hooks() -> list[str]:
279 """Uninstall every shell completion hook this runner installed.
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.
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
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
302# --- where things are ---------------------------------------------------------
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}
315Place = Literal[
316 "cache",
317 "data",
318 "config-dir",
319 "config-file",
320 "user-tasks",
321 "builtins",
322 "project-root",
323 "tool-dir",
324]
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.
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.
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