Coverage for src/footman/registry.py: 98%

335 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""The task registry: the `@task` / `group()` decorator surface. 

2 

3Users build their command tree in a tasks file (`tasks.py` by default): 

4 

5```python 

6from footman import task, group 

7 

8@task 

9def lint(fix: bool = False): 

10 "Run ruff over the project." 

11 

12docs = group("docs", help="Documentation") 

13 

14@docs.task 

15def serve(port: int = 8000): 

16 "Serve the docs locally." 

17``` 

18 

19A module of functions becomes a flat set of commands; each `group` opens 

20a nested command group. Command names are the function/group name with 

21underscores turned into hyphens (`add_word` -> `add-word`). 

22 

23This module holds only the tree structure. Turning it into the manifest (which 

24pays the cost of `inspect`) lives in `footman.manifest`, and the 

25completion hot path never imports either. 

26""" 

27 

28from __future__ import annotations 

29 

30import contextlib 

31import os 

32import shutil 

33from collections.abc import Callable, Iterator, Sequence 

34from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload 

35 

36Task = Callable[..., Any] 

37Finalizer = Callable[["Tasks"], object] 

38"""A `@finalize` hook: edits the merged command tree in place at discovery.""" 

39 

40# A task stays a plain function; its metadata rides as `_footman_*` attributes. 

41# These name every key in one place, so the strings appear once and the read 

42# accessors below are the one way the rest of the framework touches them. 

43_PRE = "_footman_pre" 

44_POST = "_footman_post" 

45_KEEP_GOING = "_footman_keep_going" 

46_ATOMIC = "_footman_atomic" 

47_INFINITE = "_footman_infinite" 

48_INTERACTIVE = "_footman_interactive" 

49_PROGRESS = "_footman_progress" 

50_CONFIRM = "_footman_confirm" 

51_CHECKS = "_footman_checks" 

52_DEFAULT_GROUP = "_footman_default_group" 

53_DEFAULT_FANOUT = "_footman_default_fanout" 

54 

55 

56class RegistrationError(ValueError): 

57 """A task or group name collided during registration. 

58 

59 Subclasses `ValueError` so existing `except ValueError` handlers keep 

60 working; the app layer matches this type to report a duplicate name as a 

61 user error rather than an import failure. 

62 """ 

63 

64 

65def cli_name(name: str) -> str: 

66 """Normalise a Python identifier to its command-line spelling. 

67 

68 A *trailing* underscore is Python's keyword/name-escape idiom (`sync_` to 

69 avoid shadowing `sync`, `import_`, `class_`); it is stripped, so the flag 

70 reads `--sync`, not `--sync-`. This is the one place identifiers become CLI 

71 tokens for task names, group names, *and* parameter flags — the `tools.*` 

72 bridge already strips the same way, and routing every mapping through here 

73 keeps the two from drifting apart again. 

74 """ 

75 return name.rstrip("_").replace("_", "-") 

76 

77 

78def _empty_body(fn: object) -> bool: 

79 """True when *fn*'s body is only a docstring and/or `pass`. 

80 

81 This is the signal that a `@group.default` fans out the group's own tasks 

82 rather than running a body of its own. Source that can't be read (a C 

83 function, a REPL definition) reads as *not* empty — a body we can't see is 

84 treated as one we must run. 

85 """ 

86 import ast 

87 import inspect 

88 import textwrap 

89 

90 try: 

91 src = textwrap.dedent(inspect.getsource(fn)) # type: ignore[arg-type] 

92 mod = ast.parse(src) 

93 except (OSError, TypeError, SyntaxError): 

94 return False 

95 func = mod.body[0] if mod.body else None 

96 if not isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true

97 return False 

98 stmts = func.body 

99 if ( 

100 stmts 

101 and isinstance(stmts[0], ast.Expr) 

102 and isinstance(stmts[0].value, ast.Constant) 

103 and isinstance(stmts[0].value.value, str) 

104 ): 

105 stmts = stmts[1:] # drop the docstring 

106 return all(isinstance(s, ast.Pass) for s in stmts) 

107 

108 

109# The orchestration options `.opts()` can override, mapped to their task 

110# attribute. These are *policy* (how a task runs), kept separate from the task's 

111# own parameters (the *work*) — which is why they ride in `.opts()` rather than 

112# the call, mirroring tools' `.opts()`. 

113_OPTS_ATTRS = { 

114 "keep_going": _KEEP_GOING, 

115 "atomic": _ATOMIC, 

116 "interactive": _INTERACTIVE, 

117 "progress": _PROGRESS, 

118 "confirm": _CONFIRM, 

119 "infinite": _INFINITE, 

120} 

121 

122 

123def _opts_overrides(kwargs: dict[str, Any]) -> dict[str, Any]: 

124 """Validate `.opts()` kwargs and map them to their task attributes.""" 

125 unknown = sorted(set(kwargs) - set(_OPTS_ATTRS)) 

126 if unknown: 

127 valid = ", ".join(sorted(_OPTS_ATTRS)) 

128 raise TypeError( 

129 f".opts() got unknown option(s) {unknown}; valid options are {valid}. " 

130 f"A task's own parameters go in the call — `t.opts(atomic=True)(x=1)` — " 

131 f"not in .opts()." 

132 ) 

133 for name, value in kwargs.items(): 

134 # Override values key the DAG's dedup identity, so they must be hashable. 

135 # Every real policy value is (bool / str / None); this turns a stray 

136 # unhashable one into a clear error here, not a cryptic crash at DAG build. 

137 try: 

138 hash(value) 

139 except TypeError: 

140 raise TypeError( 

141 f".opts({name}=…) needs a hashable value — options key the run's " 

142 f"deduplication — but got an unhashable {type(value).__name__}" 

143 ) from None 

144 return {_OPTS_ATTRS[k]: v for k, v in kwargs.items()} 

145 

146 

147class _Opted: 

148 """A task (or runnable group) reference carrying per-use option overrides. 

149 

150 `lint.opts(atomic=True)` reads as a task everywhere a bare task does — a 

151 `pre=`/`post=` target, a body call — but reports the overridden `_footman_*` 

152 options *for this use*, leaving the registered task untouched. It proxies the 

153 base transparently: same signature (via `__wrapped__`), same name, same call; 

154 only the overridden options differ. This is footman's policy-vs-work split — 

155 the options ride beside the call, not inside its argument list — mirroring 

156 the `.opts()` on `tools.*`. 

157 """ 

158 

159 _opted_base: Task | Group 

160 _opted_overrides: dict[str, Any] 

161 

162 def __init__(self, base: Task | Group, overrides: dict[str, Any]) -> None: 

163 object.__setattr__(self, "_opted_base", base) 

164 object.__setattr__(self, "_opted_overrides", overrides) 

165 object.__setattr__(self, "__wrapped__", base) # inspect.signature follows 

166 

167 def __getattr__(self, name: str) -> Any: 

168 overrides = object.__getattribute__(self, "_opted_overrides") 

169 if name in overrides: 

170 return overrides[name] 

171 return getattr(object.__getattribute__(self, "_opted_base"), name) 

172 

173 def __call__(self, *args: Any, **kwargs: Any) -> Any: 

174 return object.__getattribute__(self, "_opted_base")(*args, **kwargs) 

175 

176 def opts(self, **overrides: Any) -> _Opted: 

177 base = object.__getattribute__(self, "_opted_base") 

178 merged = dict(object.__getattribute__(self, "_opted_overrides")) 

179 merged.update(_opts_overrides(overrides)) # a later .opts() wins 

180 return _Opted(base, merged) 

181 

182 def _dedup_key(self) -> tuple[int, frozenset[tuple[str, Any]]]: 

183 """This override's identity for DAG deduplication: the base task plus its 

184 frozen overrides — the same `(id, frozenset)` shape a bare task uses, so 

185 an empty `.opts()` collapses onto the bare task and identical overrides 

186 share a node (a shared prerequisite still runs once). A different policy 

187 is a distinct node, a genuinely different run. Values are hashable by 

188 construction — `_opts_overrides` rejects an unhashable one at call time. 

189 The proxy's internals stay behind this method, so the scheduler never 

190 reaches into them for identity. (`.opts()` never nests — it merges onto 

191 the base — so `_opted_base` is always the ultimate task, never `_Opted`.)""" 

192 base = object.__getattribute__(self, "_opted_base") 

193 overrides = object.__getattribute__(self, "_opted_overrides") 

194 return (id(base), frozenset(overrides.items())) 

195 

196 

197def _apply_policy( 

198 fn: Task, 

199 *, 

200 pre: Sequence[Task], 

201 post: Sequence[Task], 

202 progress: bool, 

203 infinite: bool, 

204 confirm: str, 

205 interactive: bool, 

206 keep_going: bool | None, 

207 atomic: bool, 

208) -> None: 

209 """Stamp a task's `_footman_*` policy attributes onto *fn*. 

210 

211 The single writer of the orchestration markers, shared by `@task` and 

212 `@group.default` so the two option surfaces cannot drift apart. Only the 

213 non-default markers are set, so `getattr(fn, _…, default)` reads elsewhere 

214 stay the source of truth; `pre`/`post` are always written (as lists) so a 

215 later mutation edits a list this task owns. 

216 """ 

217 setattr(fn, _PRE, list(pre)) 

218 setattr(fn, _POST, list(post)) 

219 if not progress: 

220 setattr(fn, _PROGRESS, False) 

221 if infinite: 

222 setattr(fn, _INFINITE, True) 

223 if confirm: 

224 setattr(fn, _CONFIRM, confirm) 

225 if interactive: 

226 setattr(fn, _INTERACTIVE, True) 

227 if keep_going is not None: 

228 setattr(fn, _KEEP_GOING, keep_going) 

229 if atomic: 

230 setattr(fn, _ATOMIC, True) 

231 

232 

233_P = ParamSpec("_P") 

234_R_co = TypeVar("_R_co", covariant=True) 

235 

236 

237class TaskFn(Protocol[_P, _R_co]): 

238 """The static type of a `@task`-decorated function: callable with the task's 

239 *own* signature (parameters and return type forwarded through the `ParamSpec`), 

240 plus `.opts()` for per-use option overrides. The `_footman_*` markers ride as 

241 dynamic attributes (read through `getattr`), so they need no declaration here. 

242 """ 

243 

244 __name__: str 

245 

246 def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... 

247 def opts(self, **overrides: Any) -> _Opted: ... 

248 

249 

250class Group: 

251 """A node in the command tree: named tasks and nested sub-groups.""" 

252 

253 def __init__(self, name: str, help: str = "") -> None: 

254 self.name = name 

255 self.help = help 

256 self.tasks: dict[str, Task] = {} 

257 self.groups: dict[str, Group] = {} 

258 self.default_task: Task | None = None # runs on a bare `fm <group>` 

259 self.finalizers: list[Finalizer] = [] # @finalize hooks (root registry only) 

260 

261 def _claim(self, key: str) -> None: 

262 where = f"group {self.name!r}" if self.name != "root" else "the root" 

263 if key in self.tasks: 

264 raise RegistrationError(f"{where} already has a task named {key!r}") 

265 if key in self.groups: 

266 raise RegistrationError(f"{where} already has a group named {key!r}") 

267 

268 @overload 

269 def task(self, fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: ... 

270 @overload 

271 def task( 

272 self, 

273 fn: None = None, 

274 *, 

275 name: str = "", 

276 pre: Sequence[Task] = (), 

277 post: Sequence[Task] = (), 

278 progress: bool = True, 

279 infinite: bool = False, 

280 confirm: str = "", 

281 interactive: bool = False, 

282 keep_going: bool | None = None, 

283 atomic: bool = False, 

284 ) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]: ... 

285 

286 def task( 

287 self, 

288 fn: Task | None = None, 

289 *, 

290 name: str = "", 

291 pre: Sequence[Task] = (), 

292 post: Sequence[Task] = (), 

293 progress: bool = True, 

294 infinite: bool = False, 

295 confirm: str = "", 

296 interactive: bool = False, 

297 keep_going: bool | None = None, 

298 atomic: bool = False, 

299 ) -> Task | Callable[[Task], Task]: 

300 """Register a function as a task. 

301 

302 Usable bare (`@task`) or parameterised (`@task(name="build")`) to 

303 override the command name. `pre`/`post` declare dependency tasks (by 

304 reference) that run before/after this one — the scheduler runs 

305 independent prerequisites in parallel: 

306 

307 ```python 

308 @task(pre=[format, lint, typecheck, test]) 

309 def check(): ... 

310 ``` 

311 

312 Availability gating lives in the `@requires` decorators — stack 

313 `@requires`, `@requires_dep`, `@requires_tool`, or `@requires_env` 

314 above `@task` to list a task as unavailable (with a reason) where it 

315 can't run, rather than hide it. To hide a task entirely, use plain 

316 Python: `if sys.platform == "darwin": @task ...` 

317 

318 `progress=False` marks a task whose duration has no rhyme or 

319 reason (a REPL, a watcher, a network fetch): any run containing it 

320 never records timing history and never shows a determinate 

321 progress bar — the indeterminate pulse still does. 

322 

323 `infinite=True` marks a task that runs until *stopped* — a dev 

324 server, a follow-mode tail. It implies `progress=False`, and the 

325 run swaps the status line for a one-time hint that Ctrl-C is how 

326 this ends. Listings and help carry the same note. 

327 

328 `confirm="…"` gates the task on a yes/no answer asked *before* the 

329 task and its prerequisites run — deny and the task (and its 

330 subtree) is skipped; `--yes` auto-answers it. `interactive=True` 

331 hands the task the real terminal — no output capture, sole stdio — 

332 so its body can prompt or run a REPL; it can't run under `--json`, and 

333 because it owns the terminal, a run that contains an interactive task 

334 goes fully sequential — that task and everything else, one at a time. 

335 """ 

336 

337 if infinite and not progress: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was never true

338 # Not an error worth raising — infinite already implies it — 

339 # but the pair is redundant, and saying so keeps the two 

340 # concepts distinct: "never times" vs "never ends". 

341 pass 

342 

343 def register(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: 

344 key = cli_name(name or fn.__name__) 

345 self._claim(key) 

346 _apply_policy( 

347 fn, 

348 pre=pre, 

349 post=post, 

350 progress=progress, 

351 infinite=infinite, 

352 confirm=confirm, 

353 interactive=interactive, 

354 keep_going=keep_going, 

355 atomic=atomic, 

356 ) 

357 fn.opts = lambda **o: _Opted(fn, _opts_overrides(o)) # type: ignore[attr-defined] 

358 self.tasks[key] = fn 

359 return cast("TaskFn[_P, _R_co]", fn) 

360 

361 return register(fn) if fn is not None else register 

362 

363 def group(self, name: str, help: str = "") -> Group: 

364 """Create and register a nested command group, returning it.""" 

365 key = cli_name(name) 

366 self._claim(key) 

367 sub = Group(key, help) 

368 self.groups[key] = sub 

369 return sub 

370 

371 def finalize(self, fn: Finalizer) -> Finalizer: 

372 """Register a hook that edits the discovered command tree in place. 

373 

374 Every `@finalize` function runs once, after the whole `tasks.py` cascade 

375 is assembled but before dispatch, handed a `Tasks` view of the merged 

376 tree. Its edits are part of the plan, never a runtime surprise: an added 

377 `pre` runs and shows in `--dry-run`, a disabled task drops from listings 

378 and completion. It is footman's `collection_modifyitems`. 

379 

380 Finalizers run in cascade order — root's first, the folder nearest your 

381 cwd last, each seeing the previous ones' edits — the same "local overrides 

382 global" precedence the task cascade itself uses. Read and edit each task 

383 through the `TaskView` surface, never the private `_footman_*` attributes. 

384 

385 @footman.finalize 

386 def gate_deploys(tasks): 

387 audit = tasks["audit"] 

388 for t in tasks: 

389 if t.name.startswith("deploy"): 

390 t.add_pre(audit) 

391 """ 

392 self.finalizers.append(fn) 

393 return fn 

394 

395 @overload 

396 def default(self, fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: ... 

397 @overload 

398 def default( 

399 self, 

400 fn: None = None, 

401 *, 

402 pre: Sequence[Task] = (), 

403 post: Sequence[Task] = (), 

404 progress: bool = True, 

405 infinite: bool = False, 

406 confirm: str = "", 

407 interactive: bool = False, 

408 keep_going: bool | None = None, 

409 atomic: bool = False, 

410 ) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]: ... 

411 

412 def default( 

413 self, 

414 fn: Task | None = None, 

415 *, 

416 pre: Sequence[Task] = (), 

417 post: Sequence[Task] = (), 

418 progress: bool = True, 

419 infinite: bool = False, 

420 confirm: str = "", 

421 interactive: bool = False, 

422 keep_going: bool | None = None, 

423 atomic: bool = False, 

424 ) -> Task | Callable[[Task], Task]: 

425 """Register *fn* as this group's default action — what a bare 

426 `fm <group>` runs, and what the group returns when called. 

427 

428 Usable bare (`@group.default`) or parameterised 

429 (`@group.default(keep_going=True)`). It takes the same orchestration 

430 options as `@task` — `pre`/`post`, `progress`, `infinite`, `confirm`, 

431 `interactive`, `keep_going`, `atomic` — with no `name` (the group 

432 already names it). 

433 

434 The function's signature *is* the group's option surface, so it takes 

435 flags/options only: a positional parameter is rejected at load time, 

436 because a bare word after a group names a child, not a value. Model a 

437 positional action as a task, or take free arguments via `--` passthrough. 

438 

439 An **empty-body** default fans the group's own tasks out in parallel, so 

440 `interactive=True` on one is rejected — there is no single body to own 

441 the terminal. Give the default a real body to make it interactive. 

442 """ 

443 where = self.name if self.name != "root" else "the root group" 

444 

445 def register(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: 

446 # Lazy: manifest imports registry, so importing it at module load 

447 # would cycle. By call time (a tasks file being imported) it resolves. 

448 from footman.context import context_param_name 

449 from footman.manifest import param_spec, resolved_signature 

450 

451 sig = resolved_signature(fn) 

452 ctx_name = context_param_name(sig) 

453 for param in sig.parameters.values(): 

454 if param.name == ctx_name: 

455 continue 

456 if param_spec(param).get("kind") in ("argument", "variadic"): 

457 raise RegistrationError( 

458 f"{where}'s default {fn.__name__!r} takes a positional " 

459 f"parameter ({param.name!r}); a group default takes " 

460 f"flags/options only — a bare word after a group names a " 

461 f"child. Model a positional action as a task, or take " 

462 f"free arguments via `--` passthrough." 

463 ) 

464 fanout = _empty_body(fn) 

465 if interactive and fanout: 

466 raise RegistrationError( 

467 f"{where}'s default {fn.__name__!r} is interactive but has " 

468 f"an empty body, so it fans the group's tasks out in " 

469 f"parallel — there is no single body to own the terminal. " 

470 f"Give it a real body, or drop interactive." 

471 ) 

472 _apply_policy( 

473 fn, 

474 pre=pre, 

475 post=post, 

476 progress=progress, 

477 infinite=infinite, 

478 confirm=confirm, 

479 interactive=interactive, 

480 keep_going=keep_going, 

481 atomic=atomic, 

482 ) 

483 # A back-reference plus the empty-body flag: an empty-body default 

484 # fans out the group's own tasks (implicit prerequisites at DAG-build 

485 # time); a custom body is the escape hatch and runs as written. 

486 setattr(fn, _DEFAULT_GROUP, self) 

487 setattr(fn, _DEFAULT_FANOUT, fanout) 

488 fn.opts = lambda **o: _Opted(fn, _opts_overrides(o)) # type: ignore[attr-defined] 

489 self.default_task = fn 

490 return cast("TaskFn[_P, _R_co]", fn) 

491 

492 return register(fn) if fn is not None else register 

493 

494 def opts(self, **overrides: Any) -> _Opted: 

495 """Per-use option overrides for this group's default action, the same 

496 `.opts()` a task has — `pre=[lint.opts(keep_going=True)]`. Overrides ride 

497 the group's default when it runs (bare, as a `pre=`, or called).""" 

498 return _Opted(self, _opts_overrides(overrides)) 

499 

500 def __call__(self, *args: Any, **kwargs: Any) -> Any: 

501 """Run this group's default action — the imperative mirror of a bare 

502 `fm <group>` and of `pre=[group]`. 

503 

504 A runnable group (one with an `@group.default`) is callable from a task 

505 body the way a task is: a `check` task can call `lint(fix=fix)`. It runs 

506 the default's action synchronously and in order — a custom body as 

507 written, or, for an empty-body default, the group's own tasks, each 

508 handed the arguments it declares (partial reach, by name). Like every 

509 body call it forwards arguments explicitly and runs to completion before 

510 the next statement; prerequisites and parallelism stay the scheduler's 

511 job — reach for a real chain, `pre=`, or `parallel()` for those. 

512 """ 

513 if self.default_task is None: 

514 raise TypeError( 

515 f"group {self.name!r} is not runnable: it has no " 

516 f"@{self.name}.default, so there is no action to call. Add a " 

517 f"default action, or call a task inside the group directly." 

518 ) 

519 if not fans_out(self.default_task): 

520 return self.default_task(*args, **kwargs) # custom body: as written 

521 # Empty-body default: fan out the group's own tasks, handing each only 

522 # the arguments it declares — the imperative echo of `fm <group>`. 

523 # Sequential, like any body call; wrap the call in parallel() to overlap. 

524 from footman.manifest import resolved_signature 

525 

526 for child in self.tasks.values(): 

527 accepts = set(resolved_signature(child).parameters) 

528 child(**{k: v for k, v in kwargs.items() if k in accepts}) 

529 return None 

530 

531 

532# The implicit root registry populated by the module-level `task`/`group` 

533# aliases (re-exported from `footman`). Constructing an explicit `Group` is 

534# always an option and keeps tests free of global state. 

535root = Group("root") 

536task = root.task 

537group = root.group 

538finalize = root.finalize 

539 

540 

541def reset() -> None: 

542 """Clear the global `root` registry (used by the test-suite).""" 

543 root.tasks.clear() 

544 root.groups.clear() 

545 root.finalizers.clear() 

546 

547 

548def _importable(module: str) -> bool: 

549 """True if *module* is importable, via `find_spec`. 

550 

551 `find_spec` doesn't import the module itself, but a dotted name imports its 

552 parent packages to locate the child — so a parent whose `__init__` raises 

553 (any exception, not just ImportError/ValueError) must read as 

554 not-importable, never crash `fm --list` with a traceback. 

555 """ 

556 import importlib.util 

557 

558 try: 

559 return importlib.util.find_spec(module) is not None 

560 except Exception: 

561 return False 

562 

563 

564def pre_tasks(fn: Task) -> list[Task]: 

565 """The prerequisites declared to run before *fn* (`@task(pre=…)`).""" 

566 return getattr(fn, _PRE, []) 

567 

568 

569def post_tasks(fn: Task) -> list[Task]: 

570 """The tasks declared to run after *fn* (`@task(post=…)`).""" 

571 return getattr(fn, _POST, []) 

572 

573 

574def default_group(fn: Task) -> Group | None: 

575 """The group *fn* is the `@group.default` action of, or `None`.""" 

576 return getattr(fn, _DEFAULT_GROUP, None) 

577 

578 

579def fans_out(fn: Task) -> bool: 

580 """Whether *fn* is an empty-body `@group.default` that fans out its group's 

581 own tasks (they become its implicit prerequisites) rather than run a body.""" 

582 return getattr(fn, _DEFAULT_FANOUT, False) is True 

583 

584 

585def wants_progress(fn: Task) -> bool: 

586 """Whether *fn* consented to timing: `@task(progress=False)` opts out, 

587 and `infinite=True`/`interactive=True` imply it — a duration that never 

588 arrives, or one spent waiting on a human, is not history.""" 

589 if getattr(fn, _INFINITE, False): 

590 return False 

591 if getattr(fn, _INTERACTIVE, False): 

592 return False 

593 return getattr(fn, _PROGRESS, True) is not False 

594 

595 

596def is_infinite(fn: Task) -> bool: 

597 """Whether *fn* runs until stopped: `@task(infinite=True)`.""" 

598 return getattr(fn, _INFINITE, False) is True 

599 

600 

601def is_interactive(fn: Task) -> bool: 

602 """Whether *fn* owns the real terminal: `@task(interactive=True)` — no 

603 output capture, sole stdio, so its body may prompt or run a REPL.""" 

604 return getattr(fn, _INTERACTIVE, False) is True 

605 

606 

607def keeps_going(fn: Task) -> bool | None: 

608 """*fn*'s declared failure policy: `@task(keep_going=True/False)`, or `None` 

609 when it left the choice to the command line / the built-in default.""" 

610 return getattr(fn, _KEEP_GOING, None) 

611 

612 

613def is_atomic(fn: Task) -> bool: 

614 """Whether *fn*'s subprocesses opt out of fail-fast's kill: 

615 `@task(atomic=True)` — they run to completion rather than be cut off.""" 

616 return getattr(fn, _ATOMIC, False) is True 

617 

618 

619def task_confirm(fn: Task) -> str: 

620 """The `@task(confirm="…")` prompt gating this task, or `""` if none.""" 

621 return getattr(fn, _CONFIRM, "") 

622 

623 

624Check = Callable[[], str | None] 

625"""One availability gate: the reason it fails, or `None` when it passes.""" 

626 

627 

628def _gate(check: Check) -> Callable[[Task], Task]: 

629 """Stack *check* onto a task's availability gates, read live by `availability`.""" 

630 

631 def decorate(fn: Task) -> Task: 

632 setattr(fn, _CHECKS, [*getattr(fn, _CHECKS, ()), check]) 

633 return fn 

634 

635 return decorate 

636 

637 

638def requires( 

639 predicate: Callable[[], object], *, reason: str = "" 

640) -> Callable[[Task], Task]: 

641 """Gate a task on a live *predicate* — available only while it is truthy. 

642 

643 The generic gate the three specialisations build on. A predicate that 

644 raises reads as unavailable, the exception named: 

645 

646 ```python 

647 @task 

648 @requires(lambda: Path("config.toml").exists(), reason="needs config.toml") 

649 def publish(): ... 

650 ``` 

651 """ 

652 

653 def check() -> str | None: 

654 try: 

655 ok = predicate() 

656 except Exception as exc: 

657 detail = f"{type(exc).__name__}: {exc}" 

658 return f"{reason} ({detail})" if reason else detail 

659 return None if ok else (reason or "unavailable here") 

660 

661 return _gate(check) 

662 

663 

664def requires_dep(*modules: str, reason: str = "") -> Callable[[Task], Task]: 

665 """Gate a task on Python *modules* being importable (`find_spec`, no import). 

666 

667 Keep the real `import` in the body; this only checks availability, so a 

668 missing optional dependency lists as a clean reason, never an import crash. 

669 """ 

670 

671 def check() -> str | None: 

672 missing = [m for m in modules if not _importable(m)] 

673 if not missing: 

674 return None 

675 return reason or f"requires {', '.join(missing)}" 

676 

677 return _gate(check) 

678 

679 

680def requires_tool(*commands: str, reason: str = "") -> Callable[[Task], Task]: 

681 """Gate a task on command-line tools being on `PATH` (`shutil.which`).""" 

682 

683 def check() -> str | None: 

684 missing = [c for c in commands if shutil.which(c) is None] 

685 if not missing: 

686 return None 

687 return reason or f"requires {', '.join(missing)} on PATH" 

688 

689 return _gate(check) 

690 

691 

692def requires_env(*names: str, reason: str = "") -> Callable[[Task], Task]: 

693 """Gate a task on environment variables being set (`in os.environ`).""" 

694 

695 def check() -> str | None: 

696 missing = [v for v in names if v not in os.environ] 

697 if not missing: 

698 return None 

699 return reason or f"set {', '.join(missing)}" 

700 

701 return _gate(check) 

702 

703 

704def availability(fn: Task) -> str | None: 

705 """The reason(s) a task is unavailable here, or `None` if it can run. 

706 

707 Every `@requires` gate on the task is evaluated **live** — never from the 

708 cached manifest, so `DOCKER_HOST=… fm up` works the moment the environment 

709 does — and **all** failures are collected, each in its own words, so a task 

710 gated on both a missing tool and a missing variable says both. A gate whose 

711 predicate raises reads as unavailable with the exception named, scoped to 

712 that one gate. 

713 """ 

714 reasons = [r for check in getattr(fn, _CHECKS, ()) if (r := check()) is not None] 

715 return "; ".join(reasons) if reasons else None 

716 

717 

718def _as_fn(t: TaskView | Task) -> Task: 

719 """Unwrap a `TaskView` to its function; pass a raw function through.""" 

720 return t.fn if isinstance(t, TaskView) else t 

721 

722 

723class TaskView: 

724 """A finalizer's handle on one task: read its wiring, its policy flags, and 

725 its cascade provenance (where it was defined, what it overrode), and edit it 

726 here — never through the private `_footman_*` attributes.""" 

727 

728 def __init__(self, fn: Task, name: str, group: Group | None = None) -> None: 

729 self.fn = fn 

730 """The task function itself — the escape hatch past the view.""" 

731 self.name = name 

732 """The task's command-line name, e.g. `deploy-web`.""" 

733 self.group = group 

734 """The group this task lives in, or `None` for a top-level task — its 

735 `.name` is the group's command-line spelling (e.g. `docs`). Use it to 

736 disambiguate two tasks that share a leaf name across groups.""" 

737 

738 @property 

739 def pre(self) -> tuple[Task, ...]: 

740 """The prerequisites that run before this task.""" 

741 return tuple(pre_tasks(self.fn)) 

742 

743 @property 

744 def post(self) -> tuple[Task, ...]: 

745 """The tasks that run after this one.""" 

746 return tuple(post_tasks(self.fn)) 

747 

748 @property 

749 def disabled(self) -> str | None: 

750 """Why the task is unavailable here, or `None` if it can run.""" 

751 return availability(self.fn) 

752 

753 # Policy flags (read-only — declared at decoration). 

754 

755 @property 

756 def keep_going(self) -> bool | None: 

757 """The task's declared failure policy (`@task(keep_going=…)`), or `None` 

758 when it left the choice to the command line / the built-in default.""" 

759 return keeps_going(self.fn) 

760 

761 @property 

762 def atomic(self) -> bool: 

763 """Whether the task's subprocesses opt out of fail-fast's kill.""" 

764 return is_atomic(self.fn) 

765 

766 @property 

767 def infinite(self) -> bool: 

768 """Whether the task runs until stopped (`@task(infinite=True)`).""" 

769 return is_infinite(self.fn) 

770 

771 @property 

772 def interactive(self) -> bool: 

773 """Whether the task owns the real terminal (`@task(interactive=True)`).""" 

774 return is_interactive(self.fn) 

775 

776 @property 

777 def timed(self) -> bool: 

778 """Whether the task records timing history / shows a determinate bar — 

779 `@task(progress=False)` (and `infinite`/`interactive`) opt out.""" 

780 return wants_progress(self.fn) 

781 

782 @property 

783 def confirm(self) -> str: 

784 """The `@task(confirm="…")` prompt gating the task, or `""` if none.""" 

785 return task_confirm(self.fn) 

786 

787 # Cascade provenance (read-only) — for finalizers making decisions by 

788 # where a task came from and what it overrode. 

789 

790 @property 

791 def defining_dir(self) -> str | None: 

792 """The folder the task was defined in, or `None` when the cascade did 

793 not tag it (a plugin- or `include()`-composed task, not a cascade file). 

794 Use it to act on tasks from one subtree of a monorepo.""" 

795 from footman import discover 

796 

797 return discover.defining_dir(self.fn) 

798 

799 @property 

800 def shadowed(self) -> Task | None: 

801 """The task this one overrides — same name, one cascade level up — or 

802 `None` if it shadows nothing.""" 

803 from footman import discover 

804 

805 return discover.shadowed(self.fn) 

806 

807 @property 

808 def shadow_chain(self) -> tuple[Task, ...]: 

809 """This task and every task it shadows, nearest (this one) first.""" 

810 from footman import discover 

811 

812 return tuple(discover.shadow_chain(self.fn)) 

813 

814 @property 

815 def source_file(self) -> str | None: 

816 """The file the task's function is defined in, or `None` when it can't 

817 be located (a built-in or dynamically constructed function).""" 

818 import inspect 

819 

820 try: 

821 return inspect.getsourcefile(self.fn) 

822 except TypeError: 

823 return None 

824 

825 def add_pre(self, *tasks: TaskView | Task) -> None: 

826 """Prepend prerequisites (views or functions), skipping any already set.""" 

827 have = list(pre_tasks(self.fn)) 

828 setattr( 

829 self.fn, 

830 _PRE, 

831 [*(f for t in tasks if (f := _as_fn(t)) not in have), *have], 

832 ) 

833 

834 def add_post(self, *tasks: TaskView | Task) -> None: 

835 """Append post-tasks (views or functions), skipping any already set.""" 

836 have = list(post_tasks(self.fn)) 

837 setattr( 

838 self.fn, 

839 _POST, 

840 [*have, *(f for t in tasks if (f := _as_fn(t)) not in have)], 

841 ) 

842 

843 def disable(self, reason: str) -> None: 

844 """Mark the task unavailable — listed with *reason*, refused if run.""" 

845 _gate(lambda: reason)(self.fn) 

846 

847 def set_opts(self, **overrides: Any) -> None: 

848 """Set orchestration options on the task **permanently, for every use** — 

849 the finalize-time counterpart to a per-use `.opts()`. Takes the same 

850 options (`keep_going`, `atomic`, `interactive`, `progress`, `confirm`, 

851 `infinite`) and rejects a task parameter with the same taught error; the 

852 difference is that it edits the registered task rather than a per-use 

853 proxy, so a finalizer can set a policy across a whole class of tasks. A 

854 command-line `-k`/`--fail-fast` still wins over a set `keep_going`.""" 

855 for attr, value in _opts_overrides(overrides).items(): 

856 setattr(self.fn, attr, value) 

857 

858 

859class Tasks: 

860 """A finalizer's view of the merged command tree: iterate every task, or 

861 look one up by its command-line name, each as a `TaskView`.""" 

862 

863 def __init__(self, root: Group) -> None: 

864 self._root = root 

865 

866 def __iter__(self) -> Iterator[TaskView]: 

867 yield from _task_views(self._root) 

868 

869 def get(self, name: str) -> TaskView | None: 

870 """The task named *name* (command-line spelling), or `None`.""" 

871 return next((v for v in self if v.name == name), None) 

872 

873 def __getitem__(self, name: str) -> TaskView: 

874 if (view := self.get(name)) is None: 

875 raise KeyError(name) 

876 return view 

877 

878 def __contains__(self, name: object) -> bool: 

879 return isinstance(name, str) and self.get(name) is not None 

880 

881 

882def _task_views(g: Group, owner: Group | None = None) -> Iterator[TaskView]: 

883 for name, fn in g.tasks.items(): 

884 yield TaskView(fn, name, owner) 

885 for sub in g.groups.values(): 

886 yield from _task_views(sub, sub) 

887 

888 

889@contextlib.contextmanager 

890def capture() -> Iterator[Group]: 

891 """Redirect module-level `@task`/`group` registration into a fresh tree. 

892 

893 The seam `include()` uses to import a provider module without letting its 

894 decorators land in the current registry: `root.tasks`/`root.groups` are 

895 swapped for fresh dicts for the duration and the captured tree is yielded. 

896 Reentrant — a provider may itself `include()` another provider. 

897 """ 

898 captured = Group("root") 

899 saved_tasks, saved_groups = root.tasks, root.groups 

900 saved_finalizers = root.finalizers 

901 root.tasks, root.groups = captured.tasks, captured.groups 

902 root.finalizers = captured.finalizers 

903 try: 

904 yield captured 

905 finally: 

906 root.tasks, root.groups = saved_tasks, saved_groups 

907 root.finalizers = saved_finalizers