Coverage for src/footman/__init__.py: 100%

64 statements  

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

1"""footman — a task runner with typed commands and instant completion. 

2 

3Typed function signatures become real flags and positionals, modules become 

4nested command groups, and shell completion answers from a cached manifest 

5without importing your code. Building that manifest does import it, in a 

6detached subprocess: the first <kbd>Tab</kbd> in a fresh directory, and the 

7background rebuild once the cache goes stale. 

8 

9The console-script entry lives here and is deliberately thin: completion must 

10dispatch to the stdlib-only hot path before importing the framework or the 

11user's tasks, so `main` checks `--complete` first and everything else is 

12imported lazily. A bare `import footman` pays for nothing but this module. 

13""" 

14 

15from __future__ import annotations 

16 

17# The literal-False spelling both checkers honour without importing `typing` 

18# (~1.4 ms) — the completion dispatch runs through this module on every TAB 

19# press, and a bare `import footman` should pay for nothing it doesn't use. 

20TYPE_CHECKING = False 

21if TYPE_CHECKING: 

22 # Give type-checkers the real types for the lazily re-exported names below; 

23 # at runtime these are served by `__getattr__` without importing registry 

24 # on a bare `import footman` (the completion hot path). 

25 from footman import docstrings as docstrings 

26 from footman import markdown as markdown 

27 from footman._fetch import FetchError as FetchError 

28 from footman._fetch import fetch as fetch 

29 from footman._globals import Lane as Lane 

30 from footman._globals import console_lane as console_lane 

31 from footman._globals import cwd_lane as cwd_lane 

32 from footman._globals import make_lane as lane 

33 from footman._step import step as step 

34 from footman.app import App as App 

35 from footman.app import Brand as Brand 

36 from footman.compose import include as include 

37 from footman.compose import plugin as plugin 

38 from footman.context import Argv as Argv 

39 from footman.context import AuditEntry as AuditEntry 

40 from footman.context import Context as Context 

41 from footman.context import Failed as Failed 

42 from footman.context import Result as Result 

43 from footman.context import ResultView as ResultView 

44 from footman.context import RunFailed as RunFailed 

45 from footman.context import Section as Section 

46 from footman.context import Stream as Stream 

47 from footman.context import TimedOut as TimedOut 

48 from footman.context import attended as attended 

49 from footman.context import cache_dir as cache_dir 

50 from footman.context import chdir as chdir 

51 from footman.context import colored as colored 

52 from footman.context import config_dir as config_dir 

53 from footman.context import config_file as config_file 

54 from footman.context import confirm as confirm 

55 from footman.context import cwd as cwd 

56 from footman.context import data_dir as data_dir 

57 from footman.context import dist as dist 

58 from footman.context import fail as fail 

59 from footman.context import given as given 

60 from footman.context import inherited as inherited 

61 from footman.context import mark as mark 

62 from footman.context import parallel as parallel 

63 from footman.context import passthrough as passthrough 

64 from footman.context import prog as prog 

65 from footman.context import progress as progress 

66 from footman.context import project_root as project_root 

67 from footman.context import prompt as prompt 

68 from footman.context import run as run 

69 from footman.context import section as section 

70 from footman.context import select as select 

71 from footman.context import stream as stream 

72 from footman.context import track as track 

73 from footman.context import tty as tty 

74 from footman.context import use_context as use_context 

75 from footman.context import user_tasks_file as user_tasks_file 

76 from footman.invocation import Invocation as Invocation 

77 from footman.params import Arg as Arg 

78 from footman.params import Exists as Exists 

79 from footman.params import Forward as Forward 

80 from footman.params import Hidden as Hidden 

81 from footman.params import IsDir as IsDir 

82 from footman.params import IsFile as IsFile 

83 from footman.params import Many as Many 

84 from footman.params import NoSplit as NoSplit 

85 from footman.params import Secret as Secret 

86 from footman.params import Stdin as Stdin 

87 from footman.params import Stdout as Stdout 

88 from footman.params import ask as ask 

89 from footman.params import between as between 

90 from footman.params import check as check 

91 from footman.params import default as default 

92 from footman.params import doc as doc 

93 from footman.params import env as env 

94 from footman.params import exists as exists 

95 from footman.params import forward as forward 

96 from footman.params import hidden as hidden 

97 from footman.params import isdir as isdir 

98 from footman.params import isfile as isfile 

99 from footman.params import matching as matching 

100 from footman.params import nosplit as nosplit 

101 from footman.params import stdin as stdin 

102 from footman.params import stdout as stdout 

103 from footman.params import suggest as suggest 

104 from footman.registry import GlobalOption as GlobalOption 

105 from footman.registry import Group as Group 

106 from footman.registry import Tasks as Tasks 

107 from footman.registry import TaskView as TaskView 

108 from footman.registry import capture as capture 

109 from footman.registry import config_section as config_section 

110 from footman.registry import expose as expose 

111 from footman.registry import group as group 

112 from footman.registry import post_task as post_task 

113 from footman.registry import post_tasks as post_tasks 

114 from footman.registry import pre_bind as pre_bind 

115 from footman.registry import pre_record as pre_record 

116 from footman.registry import pre_task as pre_task 

117 from footman.registry import pre_tasks as pre_tasks 

118 from footman.registry import requires as requires 

119 from footman.registry import requires_dep as requires_dep 

120 from footman.registry import requires_env as requires_env 

121 from footman.registry import requires_tool as requires_tool 

122 from footman.registry import task as task 

123 from footman.registry import wrap_bind as wrap_bind 

124 from footman.registry import wrap_task as wrap_task 

125 from footman.testing import Runner as Runner 

126 from footman.testing import recording as recording 

127 

128__version__ = "0.52.1" 

129 

130BUILTIN = ("footman.new", "footman.self") 

131"""Stock footman's built-in task providers — what a project-less `fm` offers. 

132 

133Named here, beside `main()`, because BOTH doors need it: the `App` the 

134execution path builds, and the `--complete` dispatch, which configures 

135`_paths` with it before the hot path decides whether this directory has a 

136global tree. A branded CLI passes its own through `App(builtin=…)`. 

137""" 

138__all__ = [ 

139 "App", 

140 "Arg", 

141 "Argv", 

142 "AuditEntry", 

143 "Brand", 

144 "Context", 

145 "Exists", 

146 "Failed", 

147 "FetchError", 

148 "Forward", 

149 "GlobalOption", 

150 "Group", 

151 "Hidden", 

152 "Invocation", 

153 "IsDir", 

154 "IsFile", 

155 "Lane", 

156 "Many", 

157 "NoSplit", 

158 "Result", 

159 "ResultView", 

160 "RunFailed", 

161 "Runner", 

162 "Secret", 

163 "Section", 

164 "Stdin", 

165 "Stdout", 

166 "Stream", 

167 "TaskView", 

168 "Tasks", 

169 "TimedOut", 

170 "__version__", 

171 "ask", 

172 "attended", 

173 "between", 

174 "cache_dir", 

175 "capture", 

176 "chdir", 

177 "check", 

178 "colored", 

179 "config_dir", 

180 "config_file", 

181 "config_section", 

182 "confirm", 

183 "console_lane", 

184 "cwd", 

185 "cwd_lane", 

186 "data_dir", 

187 "default", 

188 "dist", 

189 "doc", 

190 "docstrings", 

191 "env", 

192 "exists", 

193 "expose", 

194 "fail", 

195 "fetch", 

196 "forward", 

197 "given", 

198 "group", 

199 "hidden", 

200 "include", 

201 "inherited", 

202 "isdir", 

203 "isfile", 

204 "lane", 

205 "main", 

206 "mark", 

207 "markdown", 

208 "matching", 

209 "nosplit", 

210 "parallel", 

211 "passthrough", 

212 "plugin", 

213 "post_task", 

214 "post_tasks", 

215 "pre_bind", 

216 "pre_record", 

217 "pre_task", 

218 "pre_tasks", 

219 "prog", 

220 "progress", 

221 "project_root", 

222 "prompt", 

223 "recording", 

224 "requires", 

225 "requires_dep", 

226 "requires_env", 

227 "requires_tool", 

228 "run", 

229 "section", 

230 "select", 

231 "stdin", 

232 "stdout", 

233 "step", 

234 "stream", 

235 "suggest", 

236 "task", 

237 "track", 

238 "tty", 

239 "use_context", 

240 "user_tasks_file", 

241 "wrap_bind", 

242 "wrap_task", 

243] 

244 

245 

246def main(tasks_file: str | None = None) -> None: 

247 """Console-script entry for `footman` and `fm`. 

248 

249 `tasks_file` makes a tasks file its own command. Ending a file with 

250 

251 if __name__ == "__main__": 

252 footman.main(__file__) 

253 

254 turns it into a runnable script — `./deploy.py build` — reading its own 

255 tasks whatever the directory, which is what pairs with a PEP 723 

256 header and a `#!/usr/bin/env -S uv run --script` shebang. An explicit 

257 `-f` on the command line still wins. 

258 """ 

259 import sys 

260 

261 argv = sys.argv[1:] 

262 if argv and argv[0] == "--complete": 

263 # Still first: the hot path answers before anything else is decided, 

264 # and `footman.app` (with its dataclass machinery) stays off a TAB 

265 # press entirely. `_paths`' module defaults *are* stock footman's 

266 # locations — except the built-ins, which live on the `App` below 

267 # and no default can know. Telling `_paths` about them is two 

268 # attribute writes, and without it the hot path cannot see that a 

269 # project-less directory has a global tree at all: the fallback is 

270 # skipped, the refresh child writes nothing, and every TAB in a 

271 # directory like $HOME pays the full cold bound for empty output. 

272 from footman import _paths 

273 from footman._complete import complete_cli 

274 

275 # `brand_version` too, not just the built-ins: the global-mode 

276 # manifest is keyed by (prog, version, builtins), and the execution 

277 # path configures the real version — leaving the default "" here 

278 # would give completion and execution two different global caches. 

279 _paths.configure(builtin=BUILTIN, brand_version=__version__) 

280 raise SystemExit(complete_cli(argv[1:])) 

281 # Past the hot path, so the TAB press above pays nothing for it (and 

282 # `_complete` writes bytes anyway): everything from here on prints text, 

283 # and a locale-encoded stdout defaults to errors='strict'. A task name, a 

284 # docstring, `--tree`'s own branch glyphs or the em dash in a header is 

285 # then unencodable on an ascii or cp1252 console, and a listing dies 

286 # half-written with a raw UnicodeEncodeError. Degrade those glyphs to '?' 

287 # instead. This is the same reconfigure `context.routing()` installs 

288 # around a run — hoisted here so the listings, which never start one, are 

289 # covered too. 

290 import contextlib 

291 

292 for stream in (sys.stdout, sys.stderr): 

293 with contextlib.suppress(Exception): 

294 # getattr, not hasattr-then-call: hasattr narrowing is not 

295 # portable across checkers, the getattr is. 

296 reconfigure = getattr(stream, "reconfigure", None) 

297 if reconfigure is not None: 

298 reconfigure(errors="replace") 

299 if tasks_file is not None and not any( 

300 a.startswith(("-f=", "--tasks-file=")) for a in argv 

301 ): 

302 argv = [f"--tasks-file={tasks_file}", *argv] 

303 # Past the completion hot path, this process exists to run one command and 

304 # exit, so it should not spend its startup collecting garbage it has not 

305 # made yet. `_globals` turns the collector back on — with everything 

306 # loaded by then frozen — at the last moment before task bodies run. 

307 from footman import _globals 

308 from footman.app import App 

309 

310 _globals.defer_gc() 

311 

312 # `dist` names the distribution this console script ships in — what a 

313 # project's lockfile pins, and what a tasks file carrying its own 

314 # dependencies must declare. A branded CLI passes its own. 

315 # 

316 # The stock pins — `FOOTMAN_*` variables, `footman.toml` config, the 

317 # long words a two-letter command cannot derive — are `App`'s own: any 

318 # `App` that keeps the `fm` command gets them, this one included. 

319 # `fm new` in an empty directory: footman declares its own built-in, 

320 # the same way any branded CLI would. 

321 raise SystemExit(App(dist="footman", builtin=BUILTIN).run(argv)) 

322 

323 

324def __getattr__(name: str) -> object: 

325 # Lazy re-export: `from footman import task, group` works without paying the 

326 # registry import on a bare `import footman` (the completion hot path). 

327 if name in ( 

328 "task", 

329 "group", 

330 "Group", 

331 "capture", 

332 "config_section", 

333 "GlobalOption", 

334 "pre_tasks", 

335 "pre_record", 

336 "pre_bind", 

337 "pre_task", 

338 "post_task", 

339 "post_tasks", 

340 "wrap_task", 

341 "wrap_bind", 

342 "Tasks", 

343 "TaskView", 

344 "expose", 

345 "requires", 

346 "requires_dep", 

347 "requires_env", 

348 "requires_tool", 

349 ): 

350 from footman import registry 

351 

352 return getattr(registry, name) 

353 if name == "step": 

354 from footman import _step 

355 

356 return _step.step 

357 if name in ("Lane", "cwd_lane", "console_lane", "lane"): 

358 from footman import _globals 

359 

360 return _globals.make_lane if name == "lane" else getattr(_globals, name) 

361 if name in ("Runner", "recording"): 

362 from footman import testing 

363 

364 return getattr(testing, name) 

365 if name == "docstrings": 

366 import footman.docstrings 

367 

368 return footman.docstrings 

369 if name == "markdown": 

370 import footman.markdown 

371 

372 return footman.markdown 

373 if name in ("fetch", "FetchError"): 

374 from footman import _fetch 

375 

376 return getattr(_fetch, name) 

377 if name in ("include", "plugin"): 

378 from footman import compose 

379 

380 return getattr(compose, name) 

381 if name in ( 

382 "Arg", 

383 "suggest", 

384 "Many", 

385 "nosplit", 

386 "exists", 

387 "isfile", 

388 "isdir", 

389 "matching", 

390 "between", 

391 "env", 

392 "check", 

393 "default", 

394 "doc", 

395 "ask", 

396 "forward", 

397 "Forward", 

398 "hidden", 

399 "Hidden", 

400 "NoSplit", 

401 "Secret", 

402 "stdin", 

403 "Stdin", 

404 "stdout", 

405 "Stdout", 

406 "Exists", 

407 "IsFile", 

408 "IsDir", 

409 ): 

410 from footman import params 

411 

412 return getattr(params, name) 

413 if name in ( 

414 "run", 

415 "parallel", 

416 "Context", 

417 "Argv", 

418 "Result", 

419 "ResultView", 

420 "AuditEntry", 

421 "inherited", 

422 "given", 

423 "passthrough", 

424 "progress", 

425 "prompt", 

426 "chdir", 

427 "confirm", 

428 "cache_dir", 

429 "attended", 

430 "tty", 

431 "colored", 

432 "cwd", 

433 "data_dir", 

434 "config_dir", 

435 "config_file", 

436 "prog", 

437 "dist", 

438 "user_tasks_file", 

439 "project_root", 

440 "select", 

441 "track", 

442 "RunFailed", 

443 "Failed", 

444 "TimedOut", 

445 "fail", 

446 "use_context", 

447 "section", 

448 "stream", 

449 "mark", 

450 "Section", 

451 "Stream", 

452 ): 

453 from footman import context 

454 

455 return getattr(context, name) 

456 if name in ("App", "Brand"): 

457 from footman import app 

458 

459 return getattr(app, name) 

460 if name == "Invocation": 

461 from footman import invocation 

462 

463 return invocation.Invocation 

464 raise AttributeError(f"module 'footman' has no attribute {name!r}")