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

1"""Public application entry: build a custom-branded CLI on top of footman. 

2 

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: 

7 

8```python 

9# acme/cli.py 

10from footman import App 

11 

12app = App(name="Acme", prog="acme", version="1.4.0") 

13 

14def main() -> None: 

15 raise SystemExit(app.run()) 

16``` 

17 

18```toml 

19# your pyproject.toml 

20[project.scripts] 

21acme = "acme.cli:main" 

22``` 

23 

24Tasks are discovered exactly as they are for `fm`: the `tasks.py` cascade 

25from the repo root down to the current directory. 

26 

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

31 

32from __future__ import annotations 

33 

34import sys 

35from collections.abc import Sequence 

36from dataclasses import dataclass 

37from pathlib import Path 

38 

39from footman import __version__ 

40 

41 

42def _prefix_from(prog: str) -> str: 

43 """An environment-variable prefix from the command name. 

44 

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. 

49 

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 

64 

65 

66@dataclass(frozen=True) 

67class Brand: 

68 """The names a CLI shows the user, and where it keeps its things. 

69 

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

74 

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. 

80 

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: 

84 

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

90 

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. 

97 

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

102 

103 Task authors never see any of this: they call `footman.cache_dir()` and 

104 `footman.data_dir()` and get a directory that exists. 

105 

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. 

110 

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. 

117 

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

131 

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

152 

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) 

157 

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 

163 

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

167 

168 def config_file(self) -> str: 

169 """This brand's standalone config filename — `acme.toml`.""" 

170 return f"{self.config_stem}.toml" 

171 

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 

178 

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 ) 

190 

191 

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

198 

199 

200class App: 

201 """A branded footman CLI — call `run` from your console-script entry. 

202 

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

210 

211 brand: Brand 

212 

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 ) 

252 

253 def run(self, argv: list[str] | None = None) -> int: 

254 """Resolve and run the CLI, returning the process exit code. 

255 

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 

265 

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 

274 

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 

293 

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 

303 

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