Coverage for src/footman/app.py: 91%
78 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"""Public application entry: build a custom-branded CLI on top of footman.
3footman's own `fm` / `footman` commands are just the default-branded
4`App`. Point your own console script at an `App` carrying your
5project's names and version, and every message the user sees — errors,
6`--version`, the completion hint — uses them:
8```python
9# acme/cli.py
10from footman import App
12app = App(name="Acme", prog="acme", version="1.4.0")
14def main() -> None:
15 raise SystemExit(app.run())
16```
18```toml
19# your pyproject.toml
20[project.scripts]
21acme = "acme.cli:main"
22```
24Tasks are discovered exactly as they are for `fm`: the `tasks.py` cascade
25from the repo root down to the current directory.
27This module is kept import-light so the completion hot path stays fast: nothing
28here imports the registry, the manifest, or the execution layer at module load —
29those are deferred into `App.run`.
30"""
32from __future__ import annotations
34import sys
35from collections.abc import Sequence
36from dataclasses import dataclass
37from pathlib import Path
39from footman import __version__
42def _prefix_from(prog: str) -> str:
43 """An environment-variable prefix from the command name.
45 `prog`, not the display name: it is the word your users type, so
46 `acme` reading `ACME_CACHE_DIR` needs no explaining — and a command
47 name is already shell-safe, where a display name is free text that
48 would need sanitising to be a variable.
50 Uppercased, every run of non-alphanumerics collapsed to one `_`, and a
51 leading digit padded. Written with `str` methods rather than `re`,
52 which this import-light module should not pay for.
53 """
54 out: list[str] = []
55 for char in prog:
56 if char.isalnum() and char.isascii():
57 out.append(char.upper())
58 elif out and out[-1] != "_": 58 ↛ 55line 58 didn't jump to line 55 because the condition on line 58 was always true
59 out.append("_")
60 cleaned = "".join(out).strip("_")
61 if not cleaned: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 return "FOOTMAN"
63 return f"_{cleaned}" if cleaned[0].isdigit() else cleaned
66@dataclass(frozen=True)
67class Brand:
68 """The names a CLI shows the user, and where it keeps its things.
70 `name` is the long / display name (the `--version` banner); `prog` is
71 the short command name (the error prefix and hints); `version` is *your*
72 version string; `tasks_file` is the filename your users write tasks in
73 (config `tasks` still overrides it per project).
75 `dist` is the distribution that ships your CLI — the name a user would
76 `pip install`. It is what the handoffs reason about: which package a
77 project's lockfile must pin, and which one a tasks file carrying its
78 own PEP 723 dependencies must declare. Left `None` (the default for a
79 custom brand), both handoffs simply stay out of your way.
81 `cache_dir` and `data_dir` are the two folders this CLI keeps things
82 in, and **you** place them, so footman never guesses at your product's
83 layout:
85 ```python
86 acme_home = Path(os.environ.get("ACME_HOME", Path.home() / ".acme"))
87 cache_dir=acme_home / ".cache" / "acme-cli",
88 data_dir=acme_home / "acme-cli",
89 ```
91 They are not anchored to each other — put them wherever each belongs in
92 your world. **Cache** is derived data, safe to delete, and the
93 collector sweeps it; **data** is durable and machine-local (credentials,
94 tokens, generated assets) and is never collected. footman refuses to
95 start if the two name the same directory, because that would point the
96 collector at things it must not delete.
98 `<PREFIX>_CACHE_DIR` and `<PREFIX>_DATA_DIR` override them at run time,
99 which is how two installations run side by side under different
100 identities. Left unset with those variables unset, both fall back to
101 XDG — `~/.cache/acme` and `~/.local/share/acme`.
103 Task authors never see any of this: they call `footman.cache_dir()` and
104 `footman.data_dir()` and get a directory that exists.
106 `env_prefix` is the namespace for every variable this CLI reads, and
107 defaults to `prog` uppercased: the command is `acme`, so the variables
108 are `ACME_*`. A branded CLI reads only its own prefix, so a stray
109 `FOOTMAN_CACHE_DIR` can never move someone else's product.
111 Set it explicitly for either of two reasons. **A terse command makes a
112 poor variable**: footman's own is `fm`, but its variables are
113 `FOOTMAN_*`, because `FOOTMAN_CACHE_DIR` tells a reader who has never
114 heard of the tool what it belongs to and `FM_CACHE_DIR` tells them
115 nothing. **Or the namespace collides with your product's own** —
116 keeping it clear is yours to arrange.
118 `config_name` names the standalone config file (`acme.toml`), the table
119 inside `pyproject.toml` (`[tool.acme]`), and the user-level config
120 corner (`~/.config/acme/`), from one field so the three cannot drift
121 apart. It defaults to `prog` — the machine word your users already type,
122 the same rule the env prefix follows — never the display name, which is
123 free text (`[tool."Acme DevKit"]` is nothing anyone would write, and a
124 capitalised table is one nobody would find). Set it when the identity
125 your users should write isn't the command: footman's own command is
126 `fm`, but its config is `footman.toml`, pinned for the same reason its
127 variables are `FOOTMAN_*`. The *user-level* config file is not placed
128 by the brand: it stays at `~/.config/acme/config.toml`, where a user
129 looks for their own settings, with `<PREFIX>_CONFIG` naming another.
130 """
132 name: str = "footman"
133 prog: str = "fm"
134 version: str = __version__
135 tasks_file: str = "tasks.py"
136 dist: str | None = "footman"
137 cache_dir: Path | None = None
138 data_dir: Path | None = None
139 env_prefix: str | None = None
140 config_name: str | None = None
141 builtin: tuple[str, ...] = ()
142 """The brand's own task surface where there is no project — a tuple of
143 `footman.tasks` entry-point names, mounted as the base of the tree
144 exactly when discovery finds no project task files. The ladder is
145 project > user > built-in: a project ignores the base outright (its
146 tasks file mounts what it wants — nothing is privileged, nothing lost),
147 and the user tasks file overlays it. Strings, never live objects: the
148 detached refresh child rebuilds manifests in a bare process, and a name
149 rides argv where an object cannot. Curate a small global surface — what
150 does this CLI offer someone with no project? — rather than aiming it at
151 the everyday tasks, which belong where their probes work."""
153 @property
154 def prefix(self) -> str:
155 """The environment-variable namespace — `env_prefix`, else from `prog`."""
156 return self.env_prefix or _prefix_from(self.prog)
158 @property
159 def config_stem(self) -> str:
160 """The config identity — `config_name`, else `prog`: the `acme` of
161 `acme.toml`, `[tool.acme]` and `~/.config/acme/`."""
162 return self.config_name or self.prog
164 def env(self, suffix: str) -> str:
165 """This brand's spelling of an environment variable: `ACME_CACHE_DIR`."""
166 return f"{self.prefix}_{suffix}"
168 def config_file(self) -> str:
169 """This brand's standalone config filename — `acme.toml`."""
170 return f"{self.config_stem}.toml"
172 def install(self) -> None:
173 """Point the process's locations at this brand — the one call the
174 real entry (`App.run`) and the embedded harness (`Runner.invoke`)
175 both make, so the two can never configure different worlds (they
176 once each listed the fields by hand, and grew apart by three)."""
177 from footman import _paths
179 _paths.configure(
180 prefix=self.prefix,
181 cache_dir=self.cache_dir,
182 data_dir=self.data_dir,
183 config_name=self.config_stem,
184 tasks_file=self.tasks_file,
185 prog=self.prog,
186 brand_version=self.version,
187 builtin=self.builtin,
188 dist=self.dist,
189 )
192# footman's command is `fm`, but its variables are `FOOTMAN_*` and its config
193# is `footman.toml` — not for compatibility, but because `FOOTMAN_CACHE_DIR`
194# and `[tool.footman]` tell a reader who has never heard of this tool what
195# they belong to, and can be searched for. `FM_CACHE_DIR` and `fm.toml` are
196# neither. A two-letter command is exactly when to pin the longer words.
197DEFAULT_BRAND = Brand(env_prefix="FOOTMAN", config_name="footman")
200class App:
201 """A branded footman CLI — call `run` from your console-script entry.
203 One `App` serves a process: `run` points module-level state (the brand,
204 its locations) at this brand and deliberately never restores it — a
205 process runs one brand, and `run` may not return at all (the uv handoff
206 re-execs). Driving two brands from one process is supported only through
207 `footman.testing.Runner`, which saves and restores around each
208 invocation.
209 """
211 brand: Brand
213 def __init__(
214 self,
215 name: str = "footman",
216 prog: str = "fm",
217 version: str | None = None,
218 tasks_file: str = "tasks.py",
219 dist: str | None = None,
220 cache_dir: Path | str | None = None,
221 data_dir: Path | str | None = None,
222 env_prefix: str | None = None,
223 config_name: str | None = None,
224 builtin: Sequence[str] = (),
225 ) -> None:
226 # The stock pins: an `App` that kept the `fm` command *is* stock
227 # footman, and stock pins the long words its two-letter command
228 # cannot derive — `FOOTMAN_*` variables, `footman.toml` config. The
229 # moment `prog` moves, the pins stop applying and a brand derives
230 # from its own command (pin `env_prefix=` / `config_name=` to
231 # differ). Without this, a bare `App()` would read `FM_CONFIG` and
232 # `[tool.fm]` while the `fm` on PATH reads footman's.
233 if prog == "fm":
234 env_prefix = env_prefix or "FOOTMAN"
235 config_name = config_name or "footman"
236 # `dist` is opt-in for a branded CLI: footman cannot guess which
237 # distribution ships someone else's runner, and a wrong guess would
238 # hand a user's invocation to an environment without it. Unset, the
239 # handoffs stay out of the way (documented in custom-cli.md).
240 self.brand = Brand(
241 name=name,
242 prog=prog,
243 version=version or __version__,
244 tasks_file=tasks_file,
245 dist=dist,
246 cache_dir=Path(cache_dir).expanduser() if cache_dir is not None else None,
247 data_dir=Path(data_dir).expanduser() if data_dir is not None else None,
248 env_prefix=env_prefix,
249 config_name=config_name,
250 builtin=tuple(builtin),
251 )
253 def run(self, argv: list[str] | None = None) -> int:
254 """Resolve and run the CLI, returning the process exit code.
256 Handles the stdlib-only `--complete` hot path before importing the
257 framework, so completion stays fast even through a custom entry point.
258 """
259 args = list(sys.argv[1:] if argv is None else argv)
260 # Locations first, and on both paths: the completion hot path reads
261 # this brand's cache exactly as the execution path writes it.
262 self.brand.install()
263 if args and args[0] == "--complete":
264 from footman._complete import complete_cli
266 return complete_cli(args[1:])
267 # A process started without a stream (the caller closed the fd;
268 # pythonw) gets `None` for it, and every later touch — the
269 # scheduler's isatty, the uv handoff's flush, a body's print —
270 # becomes an AttributeError traceback. A stream nobody connected
271 # means "discard", so it is wired to devnull once, here, and the
272 # whole run downstream never has to ask.
273 import os as _os
275 if sys.stdout is None:
276 sys.stdout = open(_os.devnull, "w", encoding="utf-8") # noqa: SIM115
277 if sys.stderr is None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 sys.stderr = open(_os.devnull, "w", encoding="utf-8") # noqa: SIM115
279 try:
280 _os.getcwd()
281 except OSError:
282 # A shell sitting in a directory another process deleted: every
283 # later step needs a cwd (discovery, config, the cache key), and
284 # each used to fail as its own raw FileNotFoundError traceback.
285 # One taught line, at the door.
286 print(
287 f"{self.brand.prog}: the working directory no longer exists "
288 f"— cd to a real directory and try again",
289 file=sys.stderr,
290 )
291 return 1
292 from footman import _app
294 code = _app.run(args, brand=self.brand)
295 try:
296 sys.stdout.flush()
297 except BrokenPipeError:
298 # The reader hung up mid-run (`fm … | head`). Flushing here, and
299 # pointing fd 1 at devnull for the interpreter's own shutdown
300 # flush, keeps "Exception ignored while flushing sys.stdout" off
301 # the screen; the code is what a SIGPIPE-default tool reports.
302 import contextlib
304 with contextlib.suppress(Exception):
305 devnull = _os.open(_os.devnull, _os.O_WRONLY)
306 _os.dup2(devnull, sys.stdout.fileno())
307 code = code or 141
308 return code