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

974 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +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 functools 

32import os 

33import threading 

34from collections.abc import Callable, Generator, Iterator, Sequence 

35from pathlib import Path 

36from typing import ( 

37 TYPE_CHECKING, 

38 Any, 

39 Final, 

40 ParamSpec, 

41 Protocol, 

42 TypedDict, 

43 TypeVar, 

44 Unpack, 

45 cast, 

46 overload, 

47) 

48 

49if TYPE_CHECKING: 

50 from footman._globals import Lane 

51 from footman.context import ResultView 

52 

53Task = Callable[..., Any] 

54Hook = Callable[..., object] 

55"""A lifecycle hook. The moments differ by arity: `pre_tasks(inv)`, 

56`pre_task(inv, task)`, `post_task(inv, task, result)`.""" 

57 

58# The hook kinds a provider module can contribute alongside (or instead of) 

59# tasks. Each kind is one bucket in `Group.contributions`, and the whole 

60# carriage — `capture()`/`reset()` here, `_fork`/`_pull` in compose, 

61# `load_tree`'s collection in discover — walks the dict generically, so a 

62# future hook kind is one entry here plus its own run semantics. 

63CONTRIBUTION_KINDS: tuple[str, ...] = ( 

64 "pre_tasks", 

65 "pre_bind", 

66 "pre_task", 

67 "post_task", 

68 "post_tasks", 

69 "globals", 

70) 

71 

72_CONTRIBUTED = "_footman_contributed" 

73"""The (kind, item) pairs a hook decorator registered, written on the 

74decorated fn. A module imported outside any proper load has spent its 

75decorators against a registry that is gone, but the decorated functions 

76still sit in the module's namespace — this marker is what lets 

77`compose._reconstruct` rebuild the contributions from there, wrapper 

78objects included.""" 

79 

80 

81def _note_contribution(fn: Any, kind: str, item: Any) -> None: 

82 """Record on *fn* that decorating it registered *item* under *kind*. 

83 

84 The ledger is faithful, not deduplicated: decorating twice registers 

85 twice, and the ledger says so. A callable that refuses attributes 

86 simply never reconstructs.""" 

87 with contextlib.suppress(AttributeError, TypeError): 

88 fn.__dict__.setdefault(_CONTRIBUTED, []).append((kind, item)) 

89 

90 

91class GlobalOption: 

92 """A plugin's own global option — `--env-file=PATH` beside `--jobs=N`. 

93 

94 Constructing one **is** registering it: a module-level singleton in the 

95 provider, stamped with the module that defined it, riding the same 

96 carriage as lifecycle hooks — so it reaches a run only when its owner is 

97 mounted, and an unmounted owner's option is an unknown option, taught. 

98 Long-form only, and the value is `=`-attached like every option's. 

99 

100 ENV_FILE = GlobalOption("env-file", Path, help="load this .env file") 

101 

102 A `bool` annotation makes it a flag; anything else takes a value, coerced 

103 and validated through the same pipeline as a task parameter — `Literal` 

104 choices, `Path` file completion, `Annotated[..., suggest(...)]` all work, 

105 because the manifest describes it with the same machinery. Read it 

106 anywhere in-run as `OPT.value` (frozen after parse); cross-plugin use is 

107 an ordinary import of the singleton. 

108 

109 A mention with no value is legal — `--profile` beside `--profile=out.json` 

110 — and carries presence rather than a value: `.value` is then whatever the 

111 option would have had anyway, and `.given` says someone asked. That is what 

112 lets one declared value cover three outcomes (off, the default file, a named 

113 file) where a second declared value used to be needed. 

114 

115 `config=True` gives the option a project-config rung — the one ladder, 

116 **CLI > `env()` > config > `default(fn)` > declared** — reading the key 

117 named like the option from this provider's own section under the brand 

118 table's reserved `plugins.` child: `[tool.footman.plugins.acme-devkit]` 

119 for the `acme.devkit` entry point (the dot becomes a hyphen, because 

120 TOML's dot is its nesting operator). `config="key"` names the key 

121 instead, for a flag renamed around a collision — flag and key are 

122 different namespaces, and only the flag's is shared. The section is the 

123 entry point's, de-dotted; `footman.config_section("...")` names it 

124 explicitly where there is nothing to derive from (an `include()`d 

125 module) or the derivation reads wrong. 

126 """ 

127 

128 __slots__ = ( 

129 "_frozen", 

130 "_given", 

131 "_mounted", 

132 "_reads", 

133 "_section", 

134 "_value", 

135 "annotation", 

136 "config", 

137 "default", 

138 "help", 

139 "name", 

140 "owner", 

141 ) 

142 

143 name: str 

144 annotation: Any 

145 default: Any 

146 config: bool | str 

147 help: str 

148 owner: str 

149 

150 # Where a config-backed option's key lives: core's own declarations read 

151 # the brand table directly ("root"); a plugin's read its section under 

152 # the reserved `plugins.` child. 

153 _config_scope = "plugins" 

154 

155 def __init__( 

156 self, 

157 name: str, 

158 annotation: Any = bool, 

159 *, 

160 default: Any = None, 

161 config: bool | str = False, 

162 help: str = "", # matches the manifest's vocabulary 

163 ) -> None: 

164 if name.startswith("-"): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 raise RegistrationError( 

166 f"GlobalOption({name!r}): give the bare name — the dashes are " 

167 f"the grammar's (and plugin globals are long-form only)" 

168 ) 

169 self.name = cli_name(name) 

170 self.annotation = annotation 

171 self.default = default if annotation is not bool else bool(default) 

172 self.config = config 

173 self.help = help 

174 # Provenance the mount writes down (the entry-point identity), and 

175 # the config section resolved from it at discovery — the derivation 

176 # `config=` rides on. 

177 self._mounted: str | None = None 

178 self._section: str | None = None 

179 # The DEFINING module, never the importing capture: what a collision 

180 # names, what pairing and provenance key on. 

181 import sys as _sys 

182 

183 frame = _sys._getframe(1) 

184 self.owner = frame.f_globals.get("__name__", "<unknown>") 

185 self._value: Any = _UNBOUND 

186 self._given = False 

187 self._frozen = False 

188 self._reads: set[str] = set() 

189 self._register() 

190 

191 def _register(self) -> None: 

192 # The plugin carriage: constructing IS registering, so the option 

193 # reaches a run exactly when its owner is mounted. Core's own 

194 # declarations (`_split._CoreOption`) override this to stay off it — 

195 # they are not contributions, they are the runner, and the collision 

196 # law reads them from the derived grammar table instead. 

197 root.contributions["globals"].append(self) 

198 

199 @property 

200 def value(self) -> Any: 

201 """The parsed value for this run — the flag's presence, the option's 

202 coerced value, or the default when the line didn't carry it. Frozen 

203 after parse; reading it outside a run is a taught error, because 

204 there is no invocation to have carried it.""" 

205 if not self._frozen: 

206 raise RuntimeError( 

207 f"--{self.name} has no value here — a global option is parsed " 

208 f"from the command line, so read {type(self).__name__}.value " 

209 f"inside a task or lifecycle hook, during a run" 

210 ) 

211 self._mark_read() 

212 return self._value 

213 

214 @property 

215 def given(self) -> bool: 

216 """Whether this option was named on the command line, with or without a 

217 value — the twin of `value`, and the half a value cannot express. 

218 

219 ```python 

220 PROFILE = GlobalOption("profile", Path, default=Path("fm-profile.json")) 

221 if PROFILE.given: # `--profile` writes the default file, 

222 write_trace(PROFILE.value) # no `--profile` writes nothing 

223 ``` 

224 

225 Three outcomes from one declared value: absent, named, named with a 

226 value. `value` answers *what*, `given` answers *whether anyone asked* — 

227 and an `env` fallback fills the first without ever touching the second, 

228 so a caller who wants the environment alone to count can say so, and one 

229 who does not can say that instead. 

230 """ 

231 if not self._frozen: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 raise RuntimeError( 

233 f"--{self.name} has no answer here — a global option is parsed " 

234 f"from the command line, so read {type(self).__name__}.given " 

235 f"inside a task or lifecycle hook, during a run" 

236 ) 

237 self._mark_read() 

238 return self._given 

239 

240 def _mark_read(self) -> None: 

241 # Read-marking for the notes lane: an in-task read is attributed to 

242 # the task; a task that never declared `uses=` gets a taught note 

243 # (once), because help and provenance can only describe what is said. 

244 from footman import context 

245 

246 ctx = context._current.get() 

247 if ctx is None or not ctx.in_task or ctx.fn is None: 

248 return 

249 self._reads.add(ctx.task or "?") 

250 if any(u is self for u in task_uses(ctx.fn)): 

251 return 

252 from footman import _globals 

253 

254 _globals._note( 

255 f"global-read:{self.name}", 

256 f"task {ctx.task or '?'} reads --{self.name} without declaring " 

257 f"it — say @task(uses=[...]) so its help and provenance show it", 

258 ) 

259 

260 def _as_parameter(self) -> Any: 

261 """This option as the synthetic keyword-only parameter the task-side 

262 machinery reasons about — the ONE construction, so the manifest's 

263 description (`_global_spec`) and the binder's absent-option ladder 

264 (`_absent_global`) can never drift apart; both used to hand-build 

265 their own copy.""" 

266 import inspect 

267 

268 return inspect.Parameter( 

269 self.name.replace("-", "_"), 

270 inspect.Parameter.KEYWORD_ONLY, 

271 annotation=self.annotation, 

272 default=self.default, 

273 ) 

274 

275 def __repr__(self) -> str: 

276 state = repr(self._value) if self._frozen else "unbound" 

277 return f"<GlobalOption --{self.name} from {self.owner}: {state}>" 

278 

279 

280_UNBOUND = object() 

281 

282_USES = "_footman_uses" 

283 

284# The mount writes this in place of an identity when one singleton is reached 

285# through two *different* mounts: its derived section would depend on mount 

286# order, so a config-backed option carrying it must name a section instead. 

287_MANY_MOUNTS = "<multiple mounts>" 

288 

289# Module name -> the config section that module's options claim, declared by 

290# `config_section()` beside the options themselves. Keyed the way `owner` is 

291# stamped, so resolution needs no second identity. 

292_CONFIG_SECTIONS: dict[str, str] = {} 

293 

294 

295def config_section(name: str) -> None: 

296 """Name this provider module's config section — the `<name>` of 

297 `[tool.footman.plugins.<name>]` — instead of the entry-point derivation. 

298 

299 Module-level, beside the module's `GlobalOption`s. For an `include()`d 

300 module (no entry point to derive from), or a derivation that reads 

301 wrong or ugly. The name is claimed at discovery, so two providers 

302 naming one section refuse loudly, never resolve by order.""" 

303 import sys as _sys 

304 

305 module = _sys._getframe(1).f_globals.get("__name__", "<unknown>") 

306 _CONFIG_SECTIONS[module] = cli_name(name) 

307 

308 

309def _note_if_ephemeral(group: Group, key: str, previous: Task | None) -> None: 

310 """Record a task that was defined *while a run is in flight*, so the run 

311 can take it back out again. 

312 

313 A `@task` inside a task body is ordinary Python — the decorator runs when 

314 the body does — and the task it makes is genuinely callable. What it must 

315 not do is outlive the run: the manifest was written before any of this 

316 happened, so a task nobody can see in a listing would go on shadowing the 

317 tree for every later run in the process. Registered, used, swept. 

318 """ 

319 from footman import _futures 

320 

321 run = _futures.active_session() 

322 if run is not None: 

323 run.ephemeral.append((group, key, previous)) 

324 

325 

326def task_uses(fn: Task) -> tuple[GlobalOption, ...]: 

327 """The global options *fn* declared it reads — `@task(uses=[OPT])`.""" 

328 return tuple(getattr(fn, _USES, ())) 

329 

330 

331def validate_global_options(options: Sequence[GlobalOption]) -> str | None: 

332 """The collision law for plugin globals, applied to the merged tree. 

333 

334 A name owned by footman itself is refused naming footman; two plugins 

335 claiming one name are refused naming both. A bool option also claims its 

336 `--no-x` spelling, so a literal `no-x` beside a bool `x` is a clash too — 

337 loud at discovery, never order-dependent at parse. The same singleton 

338 reached through two mounts is one option, not a clash. Returns the 

339 teaching message, or `None` when the set is sound.""" 

340 from footman._split import _GLOBAL_KIND 

341 

342 seen: dict[str, tuple[GlobalOption, str]] = {} 

343 for opt in options: 

344 spellings = [f"--{opt.name}"] 

345 if opt.annotation is bool: 

346 spellings.append(f"--no-{opt.name}") 

347 for flag in spellings: 

348 derived = ( 

349 "" 

350 if flag == f"--{opt.name}" 

351 else f" (the off spelling of --{opt.name})" 

352 ) 

353 if flag in _GLOBAL_KIND: 

354 return ( 

355 f"{flag}{derived} (from {opt.owner}) collides with " 

356 f"footman's own global option — plugin globals need " 

357 f"their own names" 

358 ) 

359 other = seen.get(flag) 

360 if other is not None and other[0] is not opt: 

361 return ( 

362 f"{flag} is claimed by both {other[0].owner}{other[1]} " 

363 f"and {opt.owner}{derived} — two plugins, one spelling; " 

364 f"rename one, or mount only one of them" 

365 ) 

366 seen[flag] = (opt, derived) 

367 return _resolve_config_sections(options) 

368 

369 

370def _resolve_config_sections(options: Sequence[GlobalOption]) -> str | None: 

371 """Give every config-backed plugin option its section, or the teaching 

372 message for one that cannot have a single answer. 

373 

374 Explicit `config_section(...)` wins; else the entry-point identity the 

375 mount stamped, de-dotted (`acme.devkit` → `acme-devkit`). Nothing to 

376 derive from, or two mounts disagreeing, is a refusal naming the remedy — 

377 loud at discovery, never resolved by mount order. Two providers claiming 

378 one section refuse naming both, the flag-collision law's habit.""" 

379 claimed: dict[str, GlobalOption] = {} 

380 for opt in options: 

381 if not opt.config or opt._config_scope != "plugins": 

382 continue 

383 section = _CONFIG_SECTIONS.get(opt.owner) 

384 if section is None: 

385 if opt._mounted is None: 

386 return ( 

387 f"--{opt.name} (from {opt.owner}) declares config= but " 

388 f"nothing names its section — no entry point to derive " 

389 f"one from; declare footman.config_section('...') in the " 

390 f"defining module" 

391 ) 

392 if opt._mounted == _MANY_MOUNTS: 

393 return ( 

394 f"--{opt.name} declares config= and is reached through " 

395 f"more than one mount, so its derived section would " 

396 f"depend on mount order — declare " 

397 f"footman.config_section('...') in the defining module" 

398 ) 

399 section = opt._mounted.replace(".", "-") 

400 other = claimed.get(section) 

401 if other is not None and other.owner != opt.owner: 

402 return ( 

403 f"config section 'plugins.{section}' is claimed by both " 

404 f"{other.owner} and {opt.owner} — two providers, one " 

405 f"section; footman.config_section('...') renames either" 

406 ) 

407 claimed[section] = opt 

408 opt._section = section 

409 return None 

410 

411 

412def release_global_options(options: Sequence[GlobalOption]) -> None: 

413 """Unfreeze after the run: an outside-a-run read goes back to teaching.""" 

414 for opt in options: 

415 opt._frozen = False 

416 opt._value = _UNBOUND 

417 opt._reads.clear() 

418 

419 

420def orphan_global_options(root: Group) -> list[str]: 

421 """Warnings for globals nothing is wired to read, one per orphan. 

422 

423 An option is consumed through a lifecycle hook or a declaring task. 

424 When its owner contributes no hook and no task in the tree says 

425 `uses=[...]`, nothing can be seen to read it — a warning, never a 

426 refusal: an undeclared `.value` read still works (and is noted at run 

427 time), and a hook in *another* module may read an imported singleton. 

428 """ 

429 hook_owners = { 

430 getattr(hook, "__module__", None) 

431 for kind in CONTRIBUTION_KINDS 

432 if kind != "globals" 

433 for hook in root.contributions.get(kind, ()) 

434 } 

435 declared: set[int] = set() 

436 

437 def _walk(group: Group) -> None: 

438 for fn in group.tasks.values(): 

439 for opt in task_uses(fn): 

440 declared.add(id(opt)) 

441 for sub in group.groups.values(): 

442 _walk(sub) 

443 

444 _walk(root) 

445 out: list[str] = [] 

446 seen: list[GlobalOption] = [] 

447 for opt in root.contributions.get("globals", ()): 

448 if any(o is opt for o in seen): 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 continue # the same singleton mounted twice is one option 

450 seen.append(opt) 

451 if opt.owner in hook_owners or id(opt) in declared: 

452 continue 

453 out.append( 

454 f"--{opt.name} (from {opt.owner}) has no reader in sight — " 

455 f"{opt.owner} contributes no lifecycle hook, and no task " 

456 f"declares it with @task(uses=[...])" 

457 ) 

458 return out 

459 

460 

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

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

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

464_PRE = "_footman_pre" 

465_POST = "_footman_post" 

466_KEEP_GOING = "_footman_keep_going" 

467_ATOMIC = "_footman_atomic" 

468_TIMEOUT = "_footman_timeout" 

469_RETRIES = "_footman_retries" 

470_INFINITE = "_footman_infinite" 

471_SHARED = "_footman_shared" 

472_EXPOSE = "_footman_expose" 

473"""**Where** a task is offered: everywhere, only in a project, only outside. 

474 

475Most of what a package ships means nothing without a checkout — `deploy` 

476with no project is not a shorter `deploy`, it is a lie that exits 0 — and 

477some of what a runner ships means nothing *with* one: creating a project 

478from inside a project is the same kind of lie the other way round. 

479 

480Three answers, because built-in tasks are part of the cascade and so are 

481reachable from both sides. Whichever side a task does not belong on, it 

482is not listed, not completed, and refused *by name* when asked for — 

483never a "no task named" 404, because the task does exist, it just does 

484not belong here. 

485 

486`None` inherits the enclosing group, exactly as `hidden` does, so one line 

487covers a subtree and a child can still say otherwise. The rungs differ 

488only in their **default**, and each default is that rung's own promise: a 

489package's tasks are `project_only` until one says otherwise (`seal_expose`), 

490while a person's own tasks file rides everywhere. 

491""" 

492 

493EXPOSE_VALUES = ("always", "global_only", "project_only") 

494"""What `expose=` accepts. `global_only` means outside a project only.""" 

495 

496 

497_HIDDEN = "_footman_hidden" 

498_LANES = "_footman_lanes" 

499_INTERACTIVE = "_footman_interactive" 

500_PROGRESS = "_footman_progress" 

501_CONFIRM = "_footman_confirm" 

502_MOUNTED = "_footman_mounted_from" # provenance: the plugin identity a mount stamped 

503_CWD = "_footman_cwd" 

504_REL = "_footman_rel" 

505_SERIAL = "_footman_serial" 

506_EXCLUSIVE = "_footman_exclusive" 

507 

508# The cwd policy tokens — where a task's working directory roots. Anything 

509# else passed as `cwd=` must be an absolute path (a relative one is a taught 

510# error pointing at `rel=`, so base-vs-suffix stays unambiguous). 

511CWD_TOKENS = ("root", "taskfile", "asinvoked", "unmanaged") 

512 

513 

514def validate_lanes(value: Any) -> tuple[Any, ...]: 

515 """Lanes are bindings, never strings: each entry must be a `Lane` handle. 

516 

517 Checked where lanes are declared, so a string dies as a taught error at 

518 the declaration site instead of a raw crash in the arbiter mid-run.""" 

519 from footman._globals import Lane 

520 

521 lanes = tuple(value) 

522 for entry in lanes: 

523 if not isinstance(entry, Lane): 

524 raise TypeError( 

525 f"lanes= takes Lane handles, not {type(entry).__name__} " 

526 f"({entry!r}). Declare the resource once — db = " 

527 f"footman.lane('db', reason='…') — and pass the handle: " 

528 f"lanes=(db,). Handles are what make a typo an undefined " 

529 f"name instead of a second, silently distinct lane." 

530 ) 

531 return lanes 

532 

533 

534def _validate_cwd(value: str | Path) -> str | Path: 

535 """A cwd policy value: one of `CWD_TOKENS`, or an absolute path.""" 

536 if isinstance(value, str) and value in CWD_TOKENS: 

537 return value 

538 path = Path(value) 

539 if not path.is_absolute(): 

540 raise TypeError( 

541 f"cwd={str(value)!r} is relative — cwd takes a policy token " 

542 f"({', '.join(CWD_TOKENS)}) or an absolute path; a relative " 

543 f"suffix goes in rel=…" 

544 ) 

545 return path 

546 

547 

548def _validate_rel(value: str | Path) -> str: 

549 """A rel suffix: a relative path, appended to the resolved cwd base. 

550 

551 Anchored counts as absolute: on Windows a driveless-rooted path 

552 (`/x` — `is_absolute()` False, `anchor` set) would silently replace 

553 the base's whole path portion when joined, the opposite of a suffix.""" 

554 rel_path = Path(value) 

555 if rel_path.is_absolute() or rel_path.anchor: 

556 raise TypeError( 

557 f"rel={str(value)!r} is absolute — rel is a suffix appended to the " 

558 f"resolved cwd base; an absolute directory goes in cwd=…" 

559 ) 

560 return str(value) 

561 

562 

563_CHECKS = "_footman_checks" 

564_DEFAULT_GROUP = "_footman_default_group" 

565_DEFAULT_FANOUT = "_footman_default_fanout" 

566 

567 

568class RegistrationError(ValueError): 

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

570 

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

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

573 user error rather than an import failure. 

574 """ 

575 

576 

577def task_name(fn: Any) -> str: 

578 """The callable's `__name__`. Every task-shaped object carries one (a 

579 function, a `_TaskFn` handle, an `_Opted` proxy), but the plain 

580 `Callable` type does not — reading through `Any` keeps the access legal 

581 under every checker without a suppression.""" 

582 name: str = fn.__name__ 

583 return name 

584 

585 

586def _checked_expose(value: str) -> str: 

587 """*value* if it is one of `EXPOSE_VALUES`, else a taught refusal. 

588 

589 A misspelling here would otherwise read as silence — the task would 

590 inherit its group's answer and appear somewhere its author meant to 

591 keep it out of, which is the failure this axis exists to prevent. 

592 """ 

593 if value not in EXPOSE_VALUES: 

594 raise RegistrationError( 

595 f"expose={value!r} is not one of {', '.join(EXPOSE_VALUES)}" 

596 ) 

597 return value 

598 

599 

600def expose(where: str) -> Callable[[_F], _F]: 

601 """Say where a task is offered: `@expose("global_only")`. 

602 

603 The decorator spelling of `@task(expose=…)`, for authors who prefer the 

604 gates above the signature — it sets the same one answer, so stacking it 

605 twice is a contradiction rather than an addition, unlike `@requires_*`. 

606 

607 ```python 

608 @task 

609 @expose("global_only") 

610 def new(name: str): ... 

611 ``` 

612 """ 

613 checked = _checked_expose(where) 

614 

615 def mark(fn: _F) -> _F: 

616 setattr(fn, _EXPOSE, checked) 

617 return fn 

618 

619 return mark 

620 

621 

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

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

624 

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

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

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

628 tokens for task names, group names, *and* parameter flags. (toolroom's 

629 handles carry their own copy of this rule; parity is held by its 

630 conformance suite against released footman, not by shared code.) 

631 """ 

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

633 

634 

635def _isgeneratorfunction(fn: Hook) -> bool: 

636 """Whether *fn* is a generator function, through any wrapping.""" 

637 import inspect 

638 

639 return inspect.isgeneratorfunction(inspect.unwrap(fn)) 

640 

641 

642def _check_hook_arity(kind: str, fn: Hook, wanted: int) -> None: 

643 """Refuse a hook whose signature cannot be called, at registration. 

644 

645 The lifecycle names its moments, and the moments differ by *arity* — 

646 `pre_tasks(inv)` against a later `post_task(inv, task, result)`. Checked 

647 here so a typo is a taught error naming the hook, not a `TypeError` from 

648 inside the framework at the first task an hour into a run. 

649 """ 

650 import inspect 

651 

652 try: 

653 params = list(inspect.signature(fn).parameters.values()) 

654 except (TypeError, ValueError): 

655 return # not introspectable (a builtin, a C callable): let it try 

656 if any(p.kind is p.VAR_POSITIONAL for p in params): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

657 return 

658 positional = [ 

659 p 

660 for p in params 

661 if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) 

662 and p.default is p.empty 

663 ] 

664 if len(positional) != wanted: 

665 name = getattr(fn, "__name__", repr(fn)) 

666 shape = "inv" if wanted == 1 else ", ".join(["inv", "task", "result"][:wanted]) 

667 raise RegistrationError( 

668 f"@{kind} {name!r} takes {len(positional)} argument(s); {kind} is " 

669 f"handed {wanted}: def {name}({shape})" 

670 ) 

671 

672 

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

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

675 

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

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

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

679 treated as one we must run. 

680 """ 

681 import ast 

682 import textwrap 

683 

684 try: 

685 src = textwrap.dedent(task_source(fn)) 

686 mod = ast.parse(src) 

687 except (OSError, TypeError, SyntaxError): 

688 return False 

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

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

691 return False 

692 stmts = func.body 

693 if ( 

694 stmts 

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

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

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

698 ): 

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

700 

701 # `...` counts as empty exactly like `pass`: it is the stub idiom this 

702 # codebase's own examples teach, and a `@group.default` written 

703 # `def lint_all(): ...` used to read as a real body — the group then 

704 # silently ran nothing, which is the one thing a default must not do. 

705 def inert(stmt: ast.stmt) -> bool: 

706 return isinstance(stmt, ast.Pass) or ( 

707 isinstance(stmt, ast.Expr) 

708 and isinstance(stmt.value, ast.Constant) 

709 and stmt.value.value is ... 

710 ) 

711 

712 return all(inert(s) for s in stmts) 

713 

714 

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

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

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

718# the call, the same policy-beside-the-call split toolroom's handles draw. 

719_OPTS_ATTRS = { 

720 "keep_going": _KEEP_GOING, 

721 "atomic": _ATOMIC, 

722 "timeout": _TIMEOUT, 

723 "retries": _RETRIES, 

724 "interactive": _INTERACTIVE, 

725 "progress": _PROGRESS, 

726 "confirm": _CONFIRM, 

727 "infinite": _INFINITE, 

728 "shared": _SHARED, 

729 "cwd": _CWD, 

730 "rel": _REL, 

731 "serial": _SERIAL, 

732 "exclusive": _EXCLUSIVE, 

733 "lanes": _LANES, 

734} 

735 

736 

737class TaskOpts(TypedDict, total=False): 

738 """The orchestration options `.opts()` and `TaskView.set_opts` accept — 

739 the policy of *how* a task runs, as one closed, typed set. Spelled as a 

740 TypedDict so option names complete in an editor and a wrong name or type 

741 is a static error at the call site; `test_task_opts_matches_opts_attrs` 

742 holds these keys to `_OPTS_ATTRS`. `None` clears a tri-state or a 

743 declared `cwd`/`rel` policy for that use.""" 

744 

745 keep_going: bool | None 

746 atomic: bool 

747 timeout: float | None 

748 retries: int 

749 interactive: bool 

750 progress: bool 

751 confirm: str 

752 infinite: bool 

753 shared: bool | None 

754 cwd: str | Path | None 

755 rel: str | Path | None 

756 lanes: tuple[Lane, ...] 

757 serial: bool 

758 exclusive: bool 

759 

760 

761def work_key( 

762 fn: Task, *, include_shared: bool = False 

763) -> tuple[int, frozenset[tuple[str, Any]]]: 

764 """The one `(id, frozenset)` identity every dedup reads — the futures 

765 memo and the DAG's node key were two spellings of this same shape. 

766 

767 A different policy is a genuinely different invocation, so a bare 

768 reference and an `.opts(atomic=True)` one are two pieces of work and 

769 both run; an empty `.opts()` collapses onto the bare task by 

770 construction. The default *excludes* the `shared` override: it says "do 

771 not reuse an answer", not "this is different work", and folding it in 

772 would put an unshared request in a bucket of its own where no later 

773 request could ever find its result. The DAG's key (*include_shared*) 

774 keeps it in — there, a differently-shared reference is a distinct node. 

775 """ 

776 if isinstance(fn, _Opted): 

777 base = object.__getattribute__(fn, "_opted_base") 

778 overrides = object.__getattribute__(fn, "_opted_overrides") 

779 work = ( 

780 overrides 

781 if include_shared 

782 else {k: v for k, v in overrides.items() if k != _SHARED} 

783 ) 

784 return (id(base), frozenset(work.items())) 

785 return (id(fn), frozenset()) 

786 

787 

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

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

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

791 if unknown: 

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

793 raise TypeError( 

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

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

796 f"not in .opts()." 

797 ) 

798 if kwargs.get("lanes") is not None: 

799 kwargs["lanes"] = validate_lanes(kwargs["lanes"]) 

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

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

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

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

804 try: 

805 hash(value) 

806 except TypeError: 

807 raise TypeError( 

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

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

810 ) from None 

811 # None is "no opinion" — the value a caller computing an override passes 

812 # (`cwd=None if inline else build_dir`), and the way to clear a declared 

813 # one for this use. Readers all `getattr(fn, _CWD, None)`, so a stored None 

814 # already reads as unset; only the validators need to let it through. 

815 if kwargs.get("cwd") is not None: 

816 kwargs["cwd"] = _validate_cwd(kwargs["cwd"]) 

817 if kwargs.get("rel") is not None: 

818 kwargs["rel"] = _validate_rel(kwargs["rel"]) 

819 if kwargs.get("cwd") == "unmanaged" and kwargs.get("rel"): 

820 raise TypeError( 

821 "rel=… needs a managed base and cwd='unmanaged' has none — " 

822 "use cwd='asinvoked' for a pinned launch-directory base" 

823 ) 

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

825 

826 

827class _Opted: 

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

829 

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

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

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

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

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

835 the options ride beside the call, not inside its argument list — the same 

836 split toolroom's handles draw with their `.opts()`. 

837 """ 

838 

839 _opted_base: Task | Group 

840 _opted_overrides: dict[str, Any] 

841 

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

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

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

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

846 

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

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

849 if name in overrides: 

850 return overrides[name] 

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

852 

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

854 # Route as *this reference*, not the bare task: the overrides are part 

855 # of the request (`build.opts(shared=False)()` asks for its own run). 

856 from footman import _futures 

857 

858 return _futures.call(self, args, kwargs) 

859 

860 def _plain_call(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: 

861 """A direct call with no run around it: today's behaviour, kept for the 

862 outside-a-run path where there is no task boundary to apply policy.""" 

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

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

865 if _CWD in overrides or _REL in overrides: 

866 # A direct body-call must honour a cwd/rel override: resolve it 

867 # against the caller's context and install it as `ctx.cwd` around 

868 # the base invocation — a save/restore of the *field*, never a 

869 # process chdir. The other options are scheduler-read and inert 

870 # on a plain call. Lazy import: executor imports registry. 

871 from footman import _executor 

872 from footman.context import current 

873 

874 ctx = current() 

875 saved, saved_unmanaged = ctx.cwd, ctx.cwd_unmanaged 

876 ctx.cwd = None # let the override's ladder re-resolve 

877 ctx.cwd, ctx.cwd_unmanaged = _executor.resolve_cwd(self, ctx) 

878 try: 

879 return base(*args, **kwargs) 

880 finally: 

881 ctx.cwd, ctx.cwd_unmanaged = saved, saved_unmanaged 

882 return base(*args, **kwargs) 

883 

884 def opts(self, **overrides: Unpack[TaskOpts]) -> _Opted: 

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

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

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

888 return _Opted(base, merged) 

889 

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

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

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

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

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

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

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

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

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

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

900 return work_key(self, include_shared=True) 

901 

902 

903class _TaskFn: 

904 """The object `@task` returns — and the very same object it registers. 

905 

906 A task's metadata rides as `_footman_*` attributes, and the framework keys 

907 a great deal on the *identity* of the task object: the DAG's dedup key 

908 (`schedule._dep_key`), the cascade's "am I shadowing myself?" test, the 

909 provenance and defining-directory stamps. So there is exactly **one** 

910 handle per decoration, and it is both registered and returned — a second 

911 handle over the same function would read as a different task. 

912 

913 It is deliberately thin. Attribute reads fall through to the function, so 

914 a marker stamped *before* wrapping (`@requires` written below `@task`) is 

915 still read back; `functools.update_wrapper` gives it the function's name, 

916 docstring, and `__wrapped__`, so `inspect.signature`, `inspect.getdoc` and 

917 `inspect.unwrap` all answer about the function. The two `inspect` readers 

918 that don't follow `__wrapped__` are wrapped once in `task_source` / 

919 `task_source_file` below, so no call site carries the caveat. 

920 """ 

921 

922 def __init__(self, fn: Callable[..., Any]) -> None: 

923 # Copies __name__/__qualname__/__doc__/__module__/__annotations__ and 

924 # the function's __dict__ (markers stamped below @task), and sets 

925 # __wrapped__ — the seam every unwrapping reader follows. 

926 functools.update_wrapper(self, fn) 

927 

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

929 # A call from a task body is a piece of the run: it dedups against the 

930 # DAG, waits on a copy already running, or runs here with a real task 

931 # boundary. Outside a run it is the plain function call it looks like. 

932 # Lazy import: `_futures` reaches back into the _executor. 

933 from footman import _futures 

934 

935 return _futures.call(self, args, kwargs) 

936 

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

938 # Reached only for what neither the instance nor the class carries: 

939 # the function's own dunders (`__code__`, `__globals__`) and any 

940 # attribute set on it after wrapping. Read `__wrapped__` out of the 

941 # instance dict directly — going through `self` would recurse here 

942 # while `__init__` is still running. 

943 try: 

944 fn = object.__getattribute__(self, "__dict__")["__wrapped__"] 

945 except KeyError: 

946 raise AttributeError(name) from None 

947 return getattr(fn, name) 

948 

949 def opts(self, **overrides: Unpack[TaskOpts]) -> _Opted: 

950 """Per-use option overrides — `lint.opts(keep_going=True)`. The base is 

951 this handle, so an opted reference and a bare one agree about which 

952 task they name (the DAG's dedup key reads `id(base)`).""" 

953 return _Opted(self, _opts_overrides(dict(overrides))) 

954 

955 def __repr__(self) -> str: 

956 return f"<task {getattr(self, '__name__', '?')}>" 

957 

958 

959# `inspect`, told about task handles. Everything else in `inspect` follows 

960# `__wrapped__` on its own; these two resolve a *file and line*, which a 

961# handle doesn't have, so they unwrap to the function first. Wrapped here so 

962# reading a task's source stays a one-liner wherever it's needed. 

963 

964 

965def task_source(fn: Any) -> str: 

966 """The source text of the function behind *fn* (decorators included).""" 

967 import inspect 

968 

969 return inspect.getsource(inspect.unwrap(fn)) 

970 

971 

972def task_source_hash(fn: Any) -> str | None: 

973 """A digest of the task's own body, or `None` when its source can't be read. 

974 

975 Normalised through the AST rather than taken over the text, so reformatting 

976 and comments do not move it while a real edit does — `ruff format` sweeping 

977 the repo must not look like every task changed. Decorator lines are part of 

978 it, so a changed `pre=` shows up. 

979 

980 It is a **tripwire, not an identity**: it covers this function's own source 

981 and nothing it calls, so a helper changing underneath leaves it untouched. 

982 Good for "warn me if the body moved and nobody said so"; wrong as a cache 

983 key, which is why nothing here uses it as one. 

984 """ 

985 import ast 

986 import hashlib 

987 import textwrap 

988 

989 try: 

990 tree = ast.parse(textwrap.dedent(task_source(fn))) 

991 except (OSError, TypeError, SyntaxError): 

992 return None 

993 # No attributes: line and column numbers would make whitespace significant. 

994 shape = ast.dump(tree, annotate_fields=True, include_attributes=False) 

995 return hashlib.sha256(shape.encode("utf-8")).hexdigest() 

996 

997 

998def task_source_file(fn: Any) -> str | None: 

999 """The file the function behind *fn* is defined in, if it has one.""" 

1000 import inspect 

1001 

1002 return inspect.getsourcefile(inspect.unwrap(fn)) 

1003 

1004 

1005def _apply_policy( 

1006 fn: Task, 

1007 *, 

1008 pre: Sequence[Task], 

1009 post: Sequence[Task], 

1010 progress: bool, 

1011 infinite: bool, 

1012 shared: bool | None, 

1013 confirm: str, 

1014 interactive: bool, 

1015 keep_going: bool | None, 

1016 atomic: bool, 

1017 timeout: float | None = None, 

1018 retries: int = 0, 

1019 cwd: str | Path = "", 

1020 rel: str | Path = "", 

1021 serial: bool = False, 

1022 exclusive: bool = False, 

1023 hidden: bool | None = None, 

1024 expose: str | None = None, 

1025 lanes: Sequence[Any] = (), 

1026) -> None: 

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

1028 

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

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

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

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

1033 later mutation edits a list this task owns. 

1034 """ 

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

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

1037 if not progress: 

1038 setattr(fn, _PROGRESS, False) 

1039 if infinite: 

1040 setattr(fn, _INFINITE, True) 

1041 if shared is not None: 

1042 setattr(fn, _SHARED, shared) 

1043 if hidden is not None: 

1044 # Tri-state on purpose: unset inherits the enclosing group's answer, 

1045 # so `hidden=False` on a child of a hidden group is a real override 

1046 # rather than indistinguishable from silence. 

1047 setattr(fn, _HIDDEN, hidden) 

1048 if expose is not None: 

1049 # Tri-state for the same reason `hidden` is: unset inherits, so 

1050 # `expose="always"` on a child of a project-scoped group is a real 

1051 # override rather than indistinguishable from silence. 

1052 setattr(fn, _EXPOSE, _checked_expose(expose)) 

1053 if confirm: 

1054 setattr(fn, _CONFIRM, confirm) 

1055 if interactive: 

1056 setattr(fn, _INTERACTIVE, True) 

1057 if keep_going is not None: 

1058 setattr(fn, _KEEP_GOING, keep_going) 

1059 if atomic: 

1060 setattr(fn, _ATOMIC, True) 

1061 if retries: 

1062 if not isinstance(retries, int) or isinstance(retries, bool) or retries < 0: 

1063 raise TypeError( 

1064 f"retries={retries!r} — a whole number of EXTRA attempts after " 

1065 f"the first (retries=2 means up to three runs); omit it for none" 

1066 ) 

1067 setattr(fn, _RETRIES, retries) 

1068 if timeout is not None: 

1069 if timeout <= 0: 

1070 raise TypeError( 

1071 f"timeout={timeout!r} — a deadline must be a positive number of " 

1072 f"seconds; omit it for no deadline" 

1073 ) 

1074 setattr(fn, _TIMEOUT, timeout) 

1075 if cwd == "unmanaged" and rel: 

1076 raise TypeError( 

1077 "rel=… needs a managed base and cwd='unmanaged' has none — " 

1078 "use cwd='asinvoked' for a pinned launch-directory base" 

1079 ) 

1080 if cwd: 

1081 setattr(fn, _CWD, _validate_cwd(cwd)) 

1082 if rel: 

1083 setattr(fn, _REL, _validate_rel(rel)) 

1084 if serial: 

1085 setattr(fn, _SERIAL, True) 

1086 if exclusive: 

1087 setattr(fn, _EXCLUSIVE, True) 

1088 if lanes: 

1089 setattr(fn, _LANES, validate_lanes(lanes)) 

1090 _attach_lifecycle(fn) 

1091 

1092 

1093_PRE_BIND_HOOKS = "_footman_own_pre_bind" 

1094_PRE_TASK_HOOKS = "_footman_own_pre_task" 

1095_POST_TASK_HOOKS = "_footman_own_post_task" 

1096 

1097 

1098def own_hooks(fn: Any, attr: str) -> list[Callable[..., Any]]: 

1099 """A task's own hooks for one moment, in attachment order.""" 

1100 return list(getattr(fn, attr, ())) 

1101 

1102 

1103def has_own_hooks(fn: Any) -> bool: 

1104 """Whether *fn* carries any handle-attached lifecycle hooks — the executor 

1105 fires them whether or not any plugin is registered.""" 

1106 return bool( 

1107 getattr(fn, _PRE_BIND_HOOKS, None) 

1108 or getattr(fn, _PRE_TASK_HOOKS, None) 

1109 or getattr(fn, _POST_TASK_HOOKS, None) 

1110 ) 

1111 

1112 

1113def _attach_lifecycle(fn: Any) -> None: 

1114 """Give a registered task its handle surface: the task's own lifecycle 

1115 moments, attachable where the task lives. 

1116 

1117 `@build.pre_task` runs setup that belongs to build; `@build.pre_record` 

1118 is its reviewer; `@build.post_task` watches its sealed record — code 

1119 local to the task, so a rule about one task never lives in a central 

1120 file that lists everybody (hooks with no task knowledge belong to the 

1121 plugin lane instead). Attachment is permanent — it changes what the 

1122 task does for every requester — and each attacher returns the hook 

1123 unchanged, so the decorators stack and the functions stay callable. 

1124 """ 

1125 

1126 def _attacher(attr: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

1127 def attach(hook: Callable[..., Any]) -> Callable[..., Any]: 

1128 setattr(fn, attr, [*getattr(fn, attr, ()), hook]) 

1129 return hook 

1130 

1131 return attach 

1132 

1133 fn.pre_bind = _attacher(_PRE_BIND_HOOKS) 

1134 fn.pre_task = _attacher(_PRE_TASK_HOOKS) 

1135 fn.post_task = _attacher(_POST_TASK_HOOKS) 

1136 fn.pre_record = _attacher(_PRE_RECORD) 

1137 

1138 def wrap_task_sugar(gen_fn: Callable[..., Any]) -> Callable[..., Any]: 

1139 """The pair as one generator, per task: the pre half, then 

1140 `result = yield` where the body runs (resumed with the sealed, 

1141 read-only record), then the post half. Sugar lowered into the 

1142 task's own `pre_task` + `post_task` at attachment.""" 

1143 name = getattr(gen_fn, "__name__", repr(gen_fn)) 

1144 if not _isgeneratorfunction(gen_fn): 1144 ↛ 1145line 1144 didn't jump to line 1145 because the condition on line 1144 was never true

1145 raise RegistrationError( 

1146 f"@{task_name(fn)}.wrap_task {name!r} must be a generator " 

1147 f"function — write `result = yield` where the body runs" 

1148 ) 

1149 # A stack, not a slot: a task whose body reaches itself again runs 

1150 # the inner execution inline on the same thread, and a single slot 

1151 # would hand the outer span's generator to the inner close. 

1152 tls = threading.local() 

1153 

1154 def _spans() -> list[Any]: 

1155 spans = getattr(tls, "spans", None) 

1156 if spans is None: 

1157 spans = tls.spans = [] 

1158 return spans 

1159 

1160 def _pre() -> None: 

1161 gen = gen_fn() 

1162 try: 

1163 next(gen) 

1164 except StopIteration: 

1165 raise RuntimeError( 

1166 f"@{task_name(fn)}.wrap_task {name!r} returned without " 

1167 f"yielding — exactly one `result = yield` marks where " 

1168 f"the body runs" 

1169 ) from None 

1170 _spans().append(gen) 

1171 

1172 def _post(result: Any) -> None: 

1173 spans = getattr(tls, "spans", None) 

1174 if not spans: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true

1175 return # the anchor never fired: no span open 

1176 gen = spans.pop() 

1177 try: 

1178 gen.send(result) 

1179 except StopIteration: 

1180 return 

1181 raise RuntimeError( 

1182 f"@{task_name(fn)}.wrap_task {name!r} yielded a second time " 

1183 f"— one yield exactly" 

1184 ) 

1185 

1186 _pre.__name__ = name 

1187 _post.__name__ = name 

1188 fn.pre_task(_pre) 

1189 fn.post_task(_post) 

1190 return gen_fn 

1191 

1192 def wrap_bind_sugar(gen_fn: Callable[..., Any]) -> Callable[..., Any]: 

1193 """The two-yield wrapper, per task: enters at the bind boundary, 

1194 yields once before binding and once where the body runs.""" 

1195 name = getattr(gen_fn, "__name__", repr(gen_fn)) 

1196 if not _isgeneratorfunction(gen_fn): 

1197 raise RegistrationError( 

1198 f"@{task_name(fn)}.wrap_bind {name!r} must be a generator " 

1199 f"function — two yields: the bind boundary, then the body" 

1200 ) 

1201 # Same stack discipline as wrap_task, with one more state per span: 

1202 # whether its bind half has been matched by the body half yet, so a 

1203 # hook firing without its own bind (a body call skips binding) never 

1204 # advances an outer execution's open span. 

1205 tls = threading.local() 

1206 

1207 def _spans() -> list[list[Any]]: 

1208 spans = getattr(tls, "spans", None) 

1209 if spans is None: 

1210 spans = tls.spans = [] 

1211 return spans 

1212 

1213 def _bind() -> None: 

1214 gen = gen_fn() 

1215 try: 

1216 next(gen) 

1217 except StopIteration: 

1218 raise RuntimeError( 

1219 f"@{task_name(fn)}.wrap_bind {name!r} returned without " 

1220 f"yielding — two yields exactly" 

1221 ) from None 

1222 _spans().append([gen, False]) 

1223 

1224 def _enter() -> None: 

1225 spans = getattr(tls, "spans", None) 

1226 if not spans or spans[-1][1]: 

1227 return # a body call skips binding: the span never opened 

1228 span = spans[-1] 

1229 span[1] = True 

1230 try: 

1231 span[0].send(None) 

1232 except StopIteration: 

1233 spans.pop() 

1234 raise RuntimeError( 

1235 f"@{task_name(fn)}.wrap_bind {name!r} finished after one " 

1236 f"yield — two yields exactly (bind, then body)" 

1237 ) from None 

1238 

1239 def _post(result: Any) -> None: 

1240 spans = getattr(tls, "spans", None) 

1241 if not spans or not spans[-1][1]: 

1242 return 

1243 gen, _ = spans.pop() 

1244 try: 

1245 gen.send(result) 

1246 except StopIteration: 

1247 return 

1248 raise RuntimeError( 

1249 f"@{task_name(fn)}.wrap_bind {name!r} yielded a third time — " 

1250 f"two yields exactly" 

1251 ) 

1252 

1253 _bind.__name__ = name 

1254 _enter.__name__ = name 

1255 _post.__name__ = name 

1256 fn.pre_bind(_bind) 

1257 fn.pre_task(_enter) 

1258 fn.post_task(_post) 

1259 return gen_fn 

1260 

1261 fn.wrap_task = wrap_task_sugar 

1262 fn.wrap_bind = wrap_bind_sugar 

1263 

1264 

1265_P = ParamSpec("_P") 

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

1267# Identity-decorator variable: a gate returns exactly what it was given. 

1268_F = TypeVar("_F", bound=Callable[..., Any]) 

1269 

1270 

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

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

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

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

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

1276 """ 

1277 

1278 __name__: str 

1279 

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

1281 def opts(self, **overrides: Unpack[TaskOpts]) -> TaskFn[_P, _R_co]: 

1282 """Per-use option overrides; the reference stays callable with the 

1283 task's own signature, so an opted call type-checks like a bare one.""" 

1284 ... 

1285 

1286 # The task's own lifecycle, attachable on the handle: code local to the 

1287 # task it governs. Attachment is permanent (it changes what the task 

1288 # does for every requester); each attacher returns the hook unchanged, 

1289 # so the decorators stack and the functions stay callable. 

1290 def pre_bind(self, hook: Callable[[], None], /) -> Callable[[], None]: 

1291 """Before this task's arguments bind — its own setup moment.""" 

1292 ... 

1293 

1294 def pre_task(self, hook: Callable[[], None], /) -> Callable[[], None]: 

1295 """Before this task's body, innermost — after any plugin's pres.""" 

1296 ... 

1297 

1298 def pre_record( 

1299 self, hook: Callable[[ResultView], None], / 

1300 ) -> Callable[[ResultView], None]: 

1301 """This task's reviewer: the draft, before the record seals.""" 

1302 ... 

1303 

1304 def post_task(self, hook: Callable[[Any], None], /) -> Callable[[Any], None]: 

1305 """Watch this task's sealed record — read-only; veto via `fail()`.""" 

1306 ... 

1307 

1308 def wrap_task(self, fn: _F, /) -> _F: 

1309 """The pre/post pair as one generator: `result = yield` once.""" 

1310 ... 

1311 

1312 def wrap_bind(self, fn: _F, /) -> _F: 

1313 """The two-yield wrapper: the bind boundary, then the body.""" 

1314 ... 

1315 

1316 

1317class TaskDecorator(Protocol): 

1318 """Register a function as a task: bare (`@task`) or parameterised 

1319 (`@task(serial=True)`). The function's name becomes the command 

1320 (underscores as hyphens), its typed parameters become flags and 

1321 positionals, and its docstring's first line becomes the help text. 

1322 

1323 This protocol is the module-level `task`'s static shape — the bound 

1324 `Group.task` of the root registry, whose docstring carries the 

1325 long-form story. `test_registry_aliases_stay_in_sync` holds its 

1326 parameterised form to `Group.task`'s.""" 

1327 

1328 @overload 

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

1330 @overload 

1331 def __call__( 

1332 self, 

1333 fn: None = None, 

1334 *, 

1335 name: str = "", 

1336 pre: Sequence[Task] = (), 

1337 post: Sequence[Task] = (), 

1338 progress: bool = True, 

1339 infinite: bool = False, 

1340 shared: bool | None = None, 

1341 confirm: str = "", 

1342 interactive: bool = False, 

1343 keep_going: bool | None = None, 

1344 atomic: bool = False, 

1345 timeout: float | None = None, 

1346 retries: int = 0, 

1347 cwd: str | Path = "", 

1348 rel: str | Path = "", 

1349 serial: bool = False, 

1350 exclusive: bool = False, 

1351 hidden: bool | None = None, 

1352 expose: str | None = None, 

1353 lanes: Sequence[Lane] = (), 

1354 uses: Sequence[GlobalOption] = (), 

1355 ) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]: 

1356 """Configure and register a task. 

1357 

1358 Args: 

1359 name: Override the command name; the default is the function 

1360 name with underscores as hyphens. 

1361 pre: Tasks that must conclude before this one runs; 

1362 independent prerequisites run in parallel. 

1363 post: Tasks that run after this one concludes. 

1364 progress: `False` for work with no predictable duration (a 

1365 watcher, a network fetch) — no timing history is 

1366 recorded and no determinate bar is shown. 

1367 infinite: The task runs until stopped (a dev server, a 

1368 follow-mode tail); implies `progress=False`, and the run 

1369 notes that Ctrl-C is how this ends. 

1370 shared: `False` runs this task for every request instead of 

1371 sharing one execution per task-and-arguments; it 

1372 propagates down the task's whole subtree. 

1373 confirm: A yes/no question asked before the task and its 

1374 prerequisites run — deny and the subtree is skipped; 

1375 `--yes` auto-answers. 

1376 interactive: The task owns the real terminal — prompts and 

1377 REPLs work, output is uncaptured, the run goes 

1378 sequential, and `--json` refuses it. 

1379 keep_going: Collect failures in this task's subtree instead 

1380 of stopping at the first; `-k` says it for a whole run. 

1381 atomic: Its subprocesses opt out of the fail-fast kill and 

1382 finish on their own. 

1383 cwd: Where the task runs — `"taskfile"` (the defining 

1384 file's directory, the default), `"root"` (the cascade's 

1385 top), `"asinvoked"` (the launch directory), or 

1386 `"unmanaged"` — or an absolute path. 

1387 rel: A relative suffix appended to the resolved `cwd`. 

1388 serial: Owns the process globals — at most one serial task 

1389 at a time, overlapping the parallel pool; a real 

1390 `chdir` is legal inside. 

1391 exclusive: Owns the machine — nothing else in flight while 

1392 it runs. 

1393 hidden: Out of `--list` and help, callable and completable 

1394 as ever; unset inherits the enclosing group's answer. 

1395 expose: Where the task is offered — "always", "global_only" 

1396 (outside a project) or "project_only". Unset inherits the group 

1397 checkout, by name and with a reason. 

1398 lanes: Named resources this task holds while it runs; 

1399 contends only with other claimants of the same lanes. 

1400 uses: Global options this task reads — mounting it puts 

1401 them on the command line. 

1402 """ 

1403 ... 

1404 

1405 

1406class GroupFactory(Protocol): 

1407 """The static shape of the module-level `group` factory (`Group.group`). 

1408 

1409 Every parameter `Group.group` takes belongs here, or the module-level 

1410 spelling type-checks differently from the method one — which is exactly 

1411 what a "static shape of" protocol exists to prevent. 

1412 `test_group_factory_matches_group_group` holds the two together. 

1413 """ 

1414 

1415 def __call__( 

1416 self, 

1417 name: str, 

1418 help: str = "", 

1419 hidden: bool | None = None, 

1420 expose: str | None = None, 

1421 ) -> Group: ... 

1422 

1423 

1424class HookRegistrar(Protocol): 

1425 """The static shape of a module-level hook registrar (`pre_tasks` and 

1426 friends): an identity decorator — it registers the hook and hands the 

1427 same object back.""" 

1428 

1429 def __call__(self, fn: _F) -> _F: ... 

1430 

1431 

1432class Group: 

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

1434 

1435 def __init__( 

1436 self, 

1437 name: str, 

1438 help: str = "", 

1439 hidden: bool | None = None, 

1440 expose: str | None = None, 

1441 ) -> None: 

1442 self.name = name 

1443 self.help = help 

1444 # Tri-state, like a task's: unset inherits the enclosing group's 

1445 # answer, so a hidden subtree needs saying once at its root and a 

1446 # child can still opt back into the listings with `hidden=False`. 

1447 self.hidden = hidden 

1448 # The same tri-state, for the same reason: 

1449 # `group("ci", expose="project_only")` answers for everything under it, 

1450 # and a child can still say otherwise. `None` asks whoever encloses. 

1451 self.expose = expose 

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

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

1454 # Lifecycle contributions, one bucket per hook kind (root registry 

1455 # only). `pre_tasks` hooks are the only kind today. 

1456 # Hook kinds hold callables; the `globals` kind holds 

1457 # `GlobalOption` singletons — one generic carriage, typed loosely. 

1458 self.contributions: dict[str, list[Any]] = { 

1459 kind: [] for kind in CONTRIBUTION_KINDS 

1460 } 

1461 # Provenance: the plugin identity that mounted this group in, or None 

1462 # for a locally-defined one. Collision messages cite it, `--plugins` 

1463 # reports it, and "local silently wins" is decided by it. 

1464 self.mounted_from: str | None = None 

1465 

1466 @property 

1467 def default_task(self) -> Task | None: 

1468 """The default action — what a bare `fm <group>` runs. 

1469 

1470 Derived, never stored: the default *is* the child task named 

1471 `default` (`@group.default` ↔ `fm lint.default`), so default-ness 

1472 has one spelling, survives forks and grafts by construction, and 

1473 there is no pointer to desync. 

1474 """ 

1475 return self.tasks.get("default") 

1476 

1477 def _stamp_default(self, fn: Task, interactive: bool) -> None: 

1478 """The default-action validations and markers, one code path for 

1479 every way a task can come to be named `default` — the decorator, 

1480 `@task(name="default")`, or a mount landing one here.""" 

1481 fanout = _empty_body(fn) 

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

1483 if interactive and fanout: 

1484 raise RegistrationError( 

1485 f"{where}'s default {task_name(fn)!r} is interactive but has " 

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

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

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

1489 ) 

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

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

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

1493 setattr(fn, _DEFAULT_GROUP, self) 

1494 setattr(fn, _DEFAULT_FANOUT, fanout) 

1495 

1496 def _free_ephemeral_key(self, key: str) -> str: 

1497 """`key`, or the next free `key-2`, `key-3` — but only mid-run. 

1498 

1499 A duplicate name written in a tasks file is a mistake, and stays a 

1500 taught error. One made *while a run is in flight* is not: the task is 

1501 ad-hoc, born to be used and swept, and the name is incidental — a 

1502 `lambda` in a loop is `<lambda>` every time, and `task(rmtree)` twice 

1503 in one app is two honest pieces of work. Numbering them keeps both 

1504 callable, and keeps them distinct in the report. 

1505 """ 

1506 from footman import _futures 

1507 

1508 if _futures.active_session() is None: 

1509 return key 

1510 if key not in self.tasks and key not in self.groups: 

1511 return key 

1512 n = 2 

1513 while f"{key}-{n}" in self.tasks or f"{key}-{n}" in self.groups: 

1514 n += 1 

1515 return f"{key}-{n}" 

1516 

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

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

1519 # `.` is the address separator (`fm docs.serve`), so a name containing 

1520 # one would alias into fake nesting or become unreachable; whitespace 

1521 # can never survive shell word-splitting. Refuse both at load time. 

1522 if "." in key or any(c.isspace() for c in key): 

1523 raise RegistrationError( 

1524 f"{where}: {key!r} is not a legal name — '.' is the address " 

1525 f"separator and spaces cannot be addressed; use '_' or '-' " 

1526 f"inside a name, or nest a group instead" 

1527 ) 

1528 if key in self.tasks: 

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

1530 if key in self.groups: 

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

1532 

1533 def _shadow_pulled(self, key: str) -> None: 

1534 """Make way for a *local* definition of *key*: a mounted entry yields 

1535 silently, whatever the file order — the cascade's "user names shadow 

1536 plugins" principle, carried by provenance instead of ordering. 

1537 Local-vs-local stays loud in `_claim`.""" 

1538 existing_task = self.tasks.get(key) 

1539 if existing_task is not None and mounted_from(existing_task) is not None: 

1540 del self.tasks[key] 

1541 existing_group = self.groups.get(key) 

1542 if existing_group is not None and existing_group.mounted_from is not None: 1542 ↛ 1543line 1542 didn't jump to line 1543 because the condition on line 1542 was never true

1543 del self.groups[key] 

1544 

1545 @overload 

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

1547 @overload 

1548 def task( 

1549 self, 

1550 fn: None = None, 

1551 *, 

1552 name: str = "", 

1553 pre: Sequence[Task] = (), 

1554 post: Sequence[Task] = (), 

1555 progress: bool = True, 

1556 infinite: bool = False, 

1557 shared: bool | None = None, 

1558 confirm: str = "", 

1559 interactive: bool = False, 

1560 keep_going: bool | None = None, 

1561 atomic: bool = False, 

1562 timeout: float | None = None, 

1563 retries: int = 0, 

1564 cwd: str | Path = "", 

1565 rel: str | Path = "", 

1566 serial: bool = False, 

1567 exclusive: bool = False, 

1568 hidden: bool | None = None, 

1569 expose: str | None = None, 

1570 lanes: Sequence[Lane] = (), 

1571 uses: Sequence[GlobalOption] = (), 

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

1573 

1574 def task( 

1575 self, 

1576 fn: Task | None = None, 

1577 *, 

1578 name: str = "", 

1579 pre: Sequence[Task] = (), 

1580 post: Sequence[Task] = (), 

1581 progress: bool = True, 

1582 infinite: bool = False, 

1583 shared: bool | None = None, 

1584 confirm: str = "", 

1585 interactive: bool = False, 

1586 keep_going: bool | None = None, 

1587 atomic: bool = False, 

1588 timeout: float | None = None, 

1589 retries: int = 0, 

1590 cwd: str | Path = "", 

1591 rel: str | Path = "", 

1592 serial: bool = False, 

1593 exclusive: bool = False, 

1594 hidden: bool | None = None, 

1595 expose: str | None = None, 

1596 lanes: Sequence[Lane] = (), 

1597 uses: Sequence[GlobalOption] = (), 

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

1599 """Register a function as a task. 

1600 

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

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

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

1604 independent prerequisites in parallel: 

1605 

1606 ```python 

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

1608 def check(): ... 

1609 ``` 

1610 

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

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

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

1614 can't run, rather than hide it. 

1615 

1616 Three different things, worth keeping apart: 

1617 

1618 * `hidden=True` — **out of the listings, callable as ever.** The 

1619 task drops out of `--list`, `--tree` and group help (`--all` 

1620 brings it back), while `fm <name>` runs it exactly as before, 

1621 <kbd>Tab</kbd> completes it, and the did-you-mean index knows it: 

1622 a listing is prose about the project, completion is help with a 

1623 name you are already typing. For the tasks a machine calls and a 

1624 human never types: a CI entry point, a release step another task 

1625 drives. It is presentation only — prerequisites still run it, a 

1626 group's empty-body fan-out still includes it, and `--json` reports 

1627 it *marked* rather than missing, because a machine is who calls it. 

1628 Unset inherits the enclosing group's answer, so 

1629 `group("internal", hidden=True)` hides a whole subtree and a 

1630 child can still say `hidden=False`. 

1631 * `@requires…` — listed *with a reason* it can't run here. 

1632 * `if sys.platform == "darwin": @task ...` — plain Python, and the 

1633 task does not **exist**: nothing to list, nothing to call, no 

1634 address at all. Reach for it when the task is meaningless on this 

1635 machine, not when it is merely uninteresting to type. 

1636 

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

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

1639 never records timing history and never shows a determinate 

1640 progress bar — the indeterminate pulse still does. 

1641 

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

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

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

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

1646 

1647 `shared=False` says this task is **never shared**: every request for 

1648 it runs. A run normally performs one execution per task and arguments, 

1649 whoever asks — `pre=[build]` then `build()` in a body is one build — 

1650 which is wrong for work whose whole point is to happen again, like a 

1651 notification or a timestamp. One rule covers every spelling, so nobody 

1652 has to remember whether a task was reached by declaration or by call: 

1653 two dependents each get their own run, just as two calls do. 

1654 

1655 Sharing is a property of the **request**, resolved by a ladder: this 

1656 reference's own `.opts(shared=…)`, then the task's declaration, then 

1657 whatever asked for it, then shared. It propagates *down* — an unshared 

1658 request asks unshared for everything it needs, or the promise would be 

1659 a half-truth — which is worth knowing before reaching for it, because 

1660 one `shared=False` unshares that task's whole subtree. A step that 

1661 genuinely is reusable pins itself with `shared=True`, which beats an 

1662 inherited answer. 

1663 

1664 `.opts(shared=False)` asks for one unshared run without changing the 

1665 task, on a call or on a declared edge alike. Such a run gets its own 

1666 value but never rewrites what the run already remembers: the first 

1667 result stands, so later shared requests stay stable. 

1668 

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

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

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

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

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

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

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

1676 

1677 `cwd=` roots the task's working directory: a policy token (`"root"` — 

1678 the highest cascade file's directory, `"taskfile"` — the file the task 

1679 was defined in (the default), `"asinvoked"` — a pinned snapshot of the 

1680 launch directory, `"unmanaged"` — footman stays out entirely) or an 

1681 absolute path. `rel=` appends a relative suffix to the resolved base. 

1682 `ctx.cwd` and every `run()`/tools subprocess follow it. 

1683 """ 

1684 

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

1686 key = cli_name(name or task_name(fn)) 

1687 key = self._free_ephemeral_key(key) 

1688 self._shadow_pulled(key) 

1689 self._claim(key) 

1690 task = _TaskFn(fn) # one handle: registered *and* returned 

1691 if key == "default": 

1692 # The name *is* the mechanism: any task named `default` is its 

1693 # group's default action — `@group.default` is sugar for this. 

1694 # One validation path, so there are no second-class defaults. 

1695 self._stamp_default(task, interactive) 

1696 _apply_policy( 

1697 task, 

1698 pre=pre, 

1699 post=post, 

1700 progress=progress, 

1701 infinite=infinite, 

1702 shared=shared, 

1703 confirm=confirm, 

1704 interactive=interactive, 

1705 keep_going=keep_going, 

1706 atomic=atomic, 

1707 timeout=timeout, 

1708 retries=retries, 

1709 cwd=cwd, 

1710 rel=rel, 

1711 serial=serial, 

1712 exclusive=exclusive, 

1713 hidden=hidden, 

1714 expose=expose, 

1715 lanes=lanes, 

1716 ) 

1717 if uses: 

1718 for used in uses: 

1719 if not isinstance(used, GlobalOption): 

1720 raise RegistrationError( 

1721 f"@task(uses=...) on {key!r} takes GlobalOption " 

1722 f"singletons — got {type(used).__name__}" 

1723 ) 

1724 setattr(task, _USES, tuple(uses)) 

1725 if cli_name(task.__name__) != key: 

1726 # Numbered by `_free_ephemeral_key`: report it under the name 

1727 # it actually answers to, not the one it collided on. 

1728 object.__setattr__(task, "__name__", key) 

1729 previous = self.tasks.get(key) 

1730 self.tasks[key] = task 

1731 _note_if_ephemeral(self, key, previous) 

1732 return cast("TaskFn[_P, _R_co]", task) 

1733 

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

1735 

1736 def group( 

1737 self, 

1738 name: str, 

1739 help: str = "", 

1740 hidden: bool | None = None, 

1741 expose: str | None = None, 

1742 ) -> Group: 

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

1744 

1745 `hidden=True` keeps the whole subtree out of the human listings 

1746 (`--list`, `--tree`, help — `--all` shows them) while leaving every 

1747 address in it callable and completable — see `@task(hidden=…)`. 

1748 

1749 `expose="project_only"` says the whole subtree belongs to a project: 

1750 outside one it is not listed, not completed, and refused by name. 

1751 `"global_only"` says the opposite, `"always"` says both. A child may 

1752 still answer for itself — the same tri-state `hidden` has. 

1753 """ 

1754 key = cli_name(name) 

1755 if key == "default": 

1756 # `default` is a meaningful *task* name — the group's default 

1757 # action. A group-typed default is incoherent (a bare `fm lint` 

1758 # resolving to another bare group — turtles), so the name is 

1759 # refused for groups at load time. 

1760 raise RegistrationError( 

1761 f"a group cannot be named 'default': the name means \"this " 

1762 f"group's default action\" and belongs to a task — declare " 

1763 f"@{self.name}.default, or name a task 'default'" 

1764 ) 

1765 adopted = self.groups.get(key) 

1766 if adopted is not None and adopted.mounted_from is not None: 

1767 # A local definition over a *mounted* group adopts it rather than 

1768 # evicting it: claiming the name means adding to it — exactly 

1769 # what mounting after the definition produces. Local leaves still 

1770 # shadow mounted ones (task-level `_shadow_pulled`), so definition 

1771 # order stops mattering: either way the union, local wins per 

1772 # leaf, and only the listing order follows the file. 

1773 if help: 

1774 adopted.help = help 

1775 if hidden is not None: 1775 ↛ 1776line 1775 didn't jump to line 1776 because the condition on line 1775 was never true

1776 adopted.hidden = hidden 

1777 if expose is not None: 1777 ↛ 1778line 1777 didn't jump to line 1778 because the condition on line 1777 was never true

1778 adopted.expose = expose 

1779 return adopted 

1780 self._shadow_pulled(key) 

1781 self._claim(key) 

1782 sub = Group(key, help, hidden, expose) 

1783 self.groups[key] = sub 

1784 return sub 

1785 

1786 def pre_tasks(self, fn: _F) -> _F: 

1787 """Register the once-per-invocation hook, run before anything else. 

1788 

1789 It happens after the whole `tasks.py` cascade is assembled and *before* 

1790 availability gates, the manifest, and any task — the one single-threaded 

1791 moment where the invocation is still editable. It is handed the 

1792 `Invocation`: 

1793 

1794 @footman.pre_tasks 

1795 def gate_deploys(inv): 

1796 audit = inv.tasks["audit"] 

1797 for t in inv.tasks: 

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

1799 t.add_pre(audit) 

1800 

1801 Its edits are part of the plan, never a runtime surprise: an added `pre` 

1802 runs and shows in `--dry-run`, a disabled task drops out of listings and 

1803 completion. Setting `os.environ` here is ordinary code — it is pre-DAG 

1804 and single-threaded, so everything downstream sees it, including 

1805 `requires_env` gates and `env()` parameter fallbacks. (Per-*task* 

1806 environment is a different lane: `ctx.env`.) 

1807 

1808 Hooks run in cascade order — root's first, the folder nearest your cwd 

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

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

1811 through `TaskView`, never the private `_footman_*` attributes. 

1812 

1813 It also runs in the detached child that rebuilds the completion 

1814 manifest, so a gate that depends on what a hook sets is baked into 

1815 completion too. That is why it must be **quick and quiet**, and why 

1816 tree edits derive from files, config and environment — never from 

1817 `inv.cli`, which the child does not have. 

1818 """ 

1819 _check_hook_arity("pre_tasks", fn, 1) 

1820 self.contributions["pre_tasks"].append(fn) 

1821 _note_contribution(fn, "pre_tasks", fn) 

1822 return fn 

1823 

1824 def pre_bind(self, fn: _F) -> _F: 

1825 """Register the before-binding hook: `pre_bind(inv, task)`. 

1826 

1827 The earliest per-task moment: it fires before the task's parameters 

1828 are bound, so what it writes into `task.env` reaches `env()` 

1829 fallbacks, coercion, and `check(fn)` validators — the one moment a 

1830 plugin can influence what the body will be handed: 

1831 

1832 @footman.pre_bind 

1833 def credentials(inv, task): 

1834 task.env["DEPLOY_TOKEN"] = vault.read("deploy") 

1835 

1836 Because nothing is bound yet, `task.args` is not readable here — 

1837 read the values in `pre_task`, the post-bind moment. Binding happens 

1838 once per *request*: a request that then joins work the run already 

1839 performed still bound first, so `pre_bind` may fire for a request 

1840 whose row ends up `shared`. 

1841 """ 

1842 _check_hook_arity("pre_bind", fn, 2) 

1843 self.contributions["pre_bind"].append(fn) 

1844 _note_contribution(fn, "pre_bind", fn) 

1845 return fn 

1846 

1847 def post_tasks(self, fn: _F) -> _F: 

1848 """Register the once-per-invocation closing hook: `post_tasks(inv)`. 

1849 

1850 The run report's moment, on the main thread, after every task has 

1851 concluded and before the summary or the `--json` envelope prints — 

1852 so a rewrite a hook makes through a result view is what gets 

1853 reported. The invocation now carries the whole story: 

1854 

1855 @footman.post_tasks 

1856 def digest(inv): 

1857 failed = [r for r in inv.results if not r.ok] 

1858 slack.post(f"{len(failed)} failed of {len(inv.results)}, " 

1859 f"{inv.total_ms:.0f} ms") 

1860 

1861 `inv.results` is every row, in the order the work was created, as 

1862 result views — 

1863 executions, `shared` rows, refusals, and `skipped` nodes 

1864 (`inv.skipped` is that subset; a `post_task`-only reporter never 

1865 sees what never ran, which is why this moment exists). Under 

1866 `--json` anything a hook prints goes to stderr — the envelope owns 

1867 stdout. Hooks run in cascade order; a raising hook is named and 

1868 fails the invocation rather than passing silently. 

1869 """ 

1870 _check_hook_arity("post_tasks", fn, 1) 

1871 self.contributions["post_tasks"].append(fn) 

1872 _note_contribution(fn, "post_tasks", fn) 

1873 return fn 

1874 

1875 def wrap_task(self, fn: _F) -> _F: 

1876 """Register a one-yield wrapper around the body: sugar over the pair. 

1877 

1878 Write the pre half, `result = yield`, then the post half — locals 

1879 carry your state, and a `try/finally` around the yield closes even 

1880 when the task fails: 

1881 

1882 @footman.wrap_task 

1883 def span(inv, task): 

1884 s = tracer.start(task.name, dict(task.args)) 

1885 result = yield 

1886 s.end(ok=result.ok) 

1887 

1888 It enters at the `pre_task` moment and is resumed with the 

1889 `ResultView` at `post_task` — one engine, both spellings, so the 

1890 rules are the pair's rules: per request (a request satisfied by an 

1891 execution the run already performed is resumed with its `shared` 

1892 row), unwound in reverse plugin order, a raising half failing the 

1893 task, named. The one thing it never sees is a task that failed to 

1894 **bind** — its anchor moment never fires; observe the bind boundary 

1895 with `@wrap_bind`, which enters there. 

1896 """ 

1897 name = getattr(fn, "__name__", repr(fn)) 

1898 if not _isgeneratorfunction(fn): 

1899 raise RegistrationError( 

1900 f"@wrap_task {name!r} must be a generator function — write " 

1901 f"`result = yield` where the body runs (a plain pair of " 

1902 f"functions is @pre_task + @post_task)" 

1903 ) 

1904 _check_hook_arity("wrap_task", fn, 2) 

1905 key = f"__wrap_task_{name}" 

1906 

1907 @functools.wraps(fn) 

1908 def _pre(inv: object, task: Any) -> None: 

1909 gen: Any = fn(inv, task) 

1910 try: 

1911 next(gen) # the pre half, up to the yield 

1912 except StopIteration: 

1913 raise RuntimeError( 

1914 f"@wrap_task {name!r} returned without yielding — exactly " 

1915 f"one `result = yield` marks where the body runs" 

1916 ) from None 

1917 setattr(task.state, key, gen) 

1918 

1919 @functools.wraps(fn) 

1920 def _post(inv: object, task: Any, result: Any) -> None: 

1921 gen = getattr(task.state, key, None) 

1922 if gen is None: 

1923 return # the anchor never fired (a bind failure): no span open 

1924 try: 

1925 gen.send(result) # the post half, to the end 

1926 except StopIteration: 

1927 return 

1928 raise RuntimeError( 

1929 f"@wrap_task {name!r} yielded a second time — one yield " 

1930 f"exactly; @wrap_bind is the two-yield wrapper that enters at " 

1931 f"the bind boundary" 

1932 ) 

1933 

1934 self.contributions["pre_task"].append(_pre) 

1935 self.contributions["post_task"].append(_post) 

1936 _note_contribution(fn, "pre_task", _pre) 

1937 _note_contribution(fn, "post_task", _post) 

1938 return fn 

1939 

1940 def wrap_bind(self, fn: _F) -> _F: 

1941 """Register a two-yield wrapper spanning bind, body and all: sugar 

1942 over `pre_bind` + `pre_task` + `post_task`, one generator per request. 

1943 

1944 The first yield sits at the bind boundary and receives the bound 

1945 arguments; the second sits around the body and receives the 

1946 `ResultView`: 

1947 

1948 @footman.wrap_bind 

1949 def audit(inv, task): 

1950 started = clock() 

1951 try: 

1952 bound = yield # after binding: the real values 

1953 result = yield # after the body: the outcome 

1954 finally: 

1955 log(task.name, clock() - started) 

1956 

1957 A failed **bind** arrives as the failure raised at the first yield, 

1958 so a `try/finally` (or `except`) around it closes the span even 

1959 then — the one span `wrap_task` cannot close. Everything else is the 

1960 pair's rules, per request. 

1961 """ 

1962 name = getattr(fn, "__name__", repr(fn)) 

1963 if not _isgeneratorfunction(fn): 

1964 raise RegistrationError( 

1965 f"@wrap_bind {name!r} must be a generator function — write " 

1966 f"`bound = yield` then `result = yield` (a plain trio of " 

1967 f"functions is @pre_bind + @pre_task + @post_task)" 

1968 ) 

1969 _check_hook_arity("wrap_bind", fn, 2) 

1970 key = f"__wrap_bind_{name}" 

1971 

1972 @functools.wraps(fn) 

1973 def _bind_pre(inv: object, task: Any) -> None: 

1974 gen: Any = fn(inv, task) 

1975 try: 

1976 next(gen) # up to the bind boundary 

1977 except StopIteration: 

1978 raise RuntimeError( 

1979 f"@wrap_bind {name!r} returned without yielding — it " 

1980 f"takes two: `bound = yield`, then `result = yield`" 

1981 ) from None 

1982 setattr(task.state, key, (gen, "binding")) 

1983 

1984 @functools.wraps(fn) 

1985 def _pre(inv: object, task: Any) -> None: 

1986 pair = getattr(task.state, key, None) 

1987 if pair is None: 1987 ↛ 1988line 1987 didn't jump to line 1988 because the condition on line 1987 was never true

1988 return 

1989 gen, _ = pair 

1990 try: 

1991 gen.send(task.args) # binding done: hand over the values 

1992 except StopIteration: 

1993 raise RuntimeError( 

1994 f"@wrap_bind {name!r} finished after one yield — it " 

1995 f"takes two: `bound = yield`, then `result = yield`" 

1996 ) from None 

1997 setattr(task.state, key, (gen, "running")) 

1998 

1999 @functools.wraps(fn) 

2000 def _post(inv: object, task: Any, result: Any) -> None: 

2001 pair = getattr(task.state, key, None) 

2002 if pair is None: 2002 ↛ 2003line 2002 didn't jump to line 2003 because the condition on line 2002 was never true

2003 return 

2004 gen, phase = pair 

2005 try: 

2006 if phase == "binding": 

2007 # Binding never completed: the failure arrives at the 

2008 # first yield, so the author's try/finally closes the 

2009 # span. Whatever propagates back out is the same failure 

2010 # the task already reported. 

2011 error = result.error 

2012 if error is None: 2012 ↛ 2013line 2012 didn't jump to line 2013 because the condition on line 2012 was never true

2013 error = RuntimeError("binding never completed") 

2014 gen.throw(error) 

2015 else: 

2016 gen.send(result) # the closing half, to the end 

2017 except StopIteration: 

2018 return 

2019 except BaseException: 

2020 if phase == "binding": 2020 ↛ 2022line 2020 didn't jump to line 2022 because the condition on line 2020 was always true

2021 return # the thrown failure resurfacing: span closed 

2022 raise 

2023 raise RuntimeError( 

2024 f"@wrap_bind {name!r} yielded again after the result — two " 

2025 f"yields exactly" 

2026 ) 

2027 

2028 self.contributions["pre_bind"].append(_bind_pre) 

2029 self.contributions["pre_task"].append(_pre) 

2030 self.contributions["post_task"].append(_post) 

2031 _note_contribution(fn, "pre_bind", _bind_pre) 

2032 _note_contribution(fn, "pre_task", _pre) 

2033 _note_contribution(fn, "post_task", _post) 

2034 return fn 

2035 

2036 def pre_task(self, fn: _F) -> _F: 

2037 """Register the before-each-task hook: `pre_task(inv, task)`. 

2038 

2039 Runs on the task's worker thread — in parallel across tasks, for 

2040 every *request*: a chain segment, a prerequisite, a fan-out member, a 

2041 body call. Only the body is shared: a request satisfied by an 

2042 execution the run already performed still gets the pair, opened here 

2043 and closed by `post_task` with its `shared` row — so pairing never 

2044 depends on sharing, and only a reporter that cares reads 

2045 `result.state`. It fires after binding, so `task.args` holds the 

2046 real values the body would receive: 

2047 

2048 @footman.pre_task 

2049 def open_span(inv, task): 

2050 task.state.span = tracer.start(task.name, dict(task.args)) 

2051 

2052 `inv` is the frozen invocation — read it freely, it cannot change under 

2053 you. Per-plugin scratch goes on `task.state` (private to your plugin, 

2054 one namespace per execution, delivered back to your `post_task`); 

2055 per-task environment goes in `task.env` (the task's own overlay — 

2056 never `os.environ`, which is shared with every parallel sibling). 

2057 A raising hook fails the task like a failed prerequisite, named. 

2058 

2059 The return value is **reserved**: a future power for a pre that 

2060 supplies the task's result and skips the body. Today a returned value 

2061 is noted and ignored — state belongs on `task.state`. 

2062 """ 

2063 _check_hook_arity("pre_task", fn, 2) 

2064 self.contributions["pre_task"].append(fn) 

2065 _note_contribution(fn, "pre_task", fn) 

2066 return fn 

2067 

2068 def post_task(self, fn: _F) -> _F: 

2069 """Register the after-each-task hook: `post_task(inv, task, result)`. 

2070 

2071 The task-finished event: once a request's ladder opened, it fires 

2072 when the request concludes — an execution whatever its outcome, a 

2073 bind refusal, or a request satisfied by an execution the run already 

2074 performed (`result.state == "shared"`). It fires whether or not your 

2075 plugin registered a pre, and however any pre fared. Posts unwind in 

2076 reverse plugin order, so the first plugin in speaks last. 

2077 

2078 `result` reads everything (`ok`, `code`, `returned`, `error`, 

2079 `duration`, `output`, `steps`) and writes one thing: `set_returned`, 

2080 which rewrites the *reported* value — the summary and the `--json` 

2081 envelope — never what a dependent or a body caller received. A raising 

2082 hook fails an otherwise-green task, named: a reporter that crashed 

2083 must not pass silently. 

2084 

2085 @footman.post_task 

2086 def close_span(inv, task, result): 

2087 task.state.span.end(ok=result.ok) 

2088 """ 

2089 _check_hook_arity("post_task", fn, 3) 

2090 self.contributions["post_task"].append(fn) 

2091 _note_contribution(fn, "post_task", fn) 

2092 return fn 

2093 

2094 @overload 

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

2096 @overload 

2097 def default( 

2098 self, 

2099 fn: None = None, 

2100 *, 

2101 pre: Sequence[Task] = (), 

2102 post: Sequence[Task] = (), 

2103 progress: bool = True, 

2104 infinite: bool = False, 

2105 shared: bool | None = None, 

2106 confirm: str = "", 

2107 interactive: bool = False, 

2108 keep_going: bool | None = None, 

2109 atomic: bool = False, 

2110 timeout: float | None = None, 

2111 retries: int = 0, 

2112 cwd: str | Path = "", 

2113 rel: str | Path = "", 

2114 serial: bool = False, 

2115 exclusive: bool = False, 

2116 hidden: bool | None = None, 

2117 expose: str | None = None, 

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

2119 

2120 def default( 

2121 self, 

2122 fn: Task | None = None, 

2123 *, 

2124 pre: Sequence[Task] = (), 

2125 post: Sequence[Task] = (), 

2126 progress: bool = True, 

2127 infinite: bool = False, 

2128 shared: bool | None = None, 

2129 confirm: str = "", 

2130 interactive: bool = False, 

2131 keep_going: bool | None = None, 

2132 atomic: bool = False, 

2133 timeout: float | None = None, 

2134 retries: int = 0, 

2135 cwd: str | Path = "", 

2136 rel: str | Path = "", 

2137 serial: bool = False, 

2138 exclusive: bool = False, 

2139 hidden: bool | None = None, 

2140 expose: str | None = None, 

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

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

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

2144 

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

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

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

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

2149 already names it). 

2150 

2151 The function's signature *is* the group's whole CLI surface, 

2152 positionals included: `fm lint src/` hands `src/` to the default the 

2153 way any task takes an argument. A nested task always keeps its own 

2154 dotted address (`fm lint.python`), so a bare word after the group is 

2155 unambiguously the default's value — when a value happens to equal a 

2156 child's name, the run carries a one-line stderr note pointing at the 

2157 dotted spelling. 

2158 

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

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

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

2162 

2163 The default registers as the child task named **`default`** — a 

2164 fixed, well-known name, not the function's own (which stays private, 

2165 so renaming it is free). The decorator you wrote is the address you 

2166 type: `@lint.default` ↔ `fm lint.default`; bare `fm lint` stays the 

2167 idiomatic spelling, the way `GET /` serves `/index.html`. The name is 

2168 the mechanism — `@group.default` is sugar for a task named `default`. 

2169 """ 

2170 

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

2172 self._claim("default") 

2173 task = _TaskFn(fn) # one handle: registered *and* returned 

2174 self._stamp_default(task, interactive) 

2175 _apply_policy( 

2176 task, 

2177 pre=pre, 

2178 post=post, 

2179 progress=progress, 

2180 infinite=infinite, 

2181 shared=shared, 

2182 confirm=confirm, 

2183 interactive=interactive, 

2184 keep_going=keep_going, 

2185 atomic=atomic, 

2186 timeout=timeout, 

2187 retries=retries, 

2188 cwd=cwd, 

2189 rel=rel, 

2190 serial=serial, 

2191 exclusive=exclusive, 

2192 hidden=hidden, 

2193 expose=expose, 

2194 ) 

2195 self.tasks["default"] = task 

2196 return cast("TaskFn[_P, _R_co]", task) 

2197 

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

2199 

2200 def opts(self, **overrides: Unpack[TaskOpts]) -> TaskFn[..., Any]: 

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

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

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

2204 static type is a task reference with an untracked signature — `Group` 

2205 doesn't carry its default's parameters the way `TaskFn` carries a 

2206 task's.""" 

2207 return cast("TaskFn[..., Any]", _Opted(self, _opts_overrides(dict(overrides)))) 

2208 

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

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

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

2212 

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

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

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

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

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

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

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

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

2221 """ 

2222 if self.default_task is None: 

2223 raise TypeError( 

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

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

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

2227 ) 

2228 if not fans_out(self.default_task): 

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

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

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

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

2233 # The `default` child is the fan-out itself: excluded from its own set. 

2234 from footman._manifest import resolved_signature 

2235 

2236 for name, child in self.tasks.items(): 

2237 if name == "default": 

2238 continue 

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

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

2241 return None 

2242 

2243 

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

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

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

2247root: Final[Group] = Group("root") 

2248task: Final[TaskDecorator] = root.task 

2249group: Final[GroupFactory] = root.group 

2250pre_tasks: Final[HookRegistrar] = root.pre_tasks 

2251post_tasks: Final[HookRegistrar] = root.post_tasks 

2252pre_bind: Final[HookRegistrar] = root.pre_bind 

2253pre_task: Final[HookRegistrar] = root.pre_task 

2254post_task: Final[HookRegistrar] = root.post_task 

2255wrap_task: Final[HookRegistrar] = root.wrap_task 

2256wrap_bind: Final[HookRegistrar] = root.wrap_bind 

2257 

2258 

2259def reset() -> None: 

2260 """Clear the global `root` registry (used by the test-suite, and by 

2261 `_discover` between file imports). 

2262 

2263 `_CONFIG_SECTIONS` deliberately survives: it is keyed by module name — 

2264 a re-import overwrites its own entry — and the cascade resets between 

2265 files, so clearing here would erase one file's declaration while the 

2266 next imports.""" 

2267 root.tasks.clear() 

2268 root.groups.clear() 

2269 for bucket in root.contributions.values(): 

2270 bucket.clear() 

2271 

2272 

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

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

2275 

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

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

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

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

2280 """ 

2281 import importlib.util 

2282 

2283 try: 

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

2285 except Exception: 

2286 return False 

2287 

2288 

2289def pre_deps(fn: Task) -> list[Task]: 

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

2291 return getattr(fn, _PRE, []) 

2292 

2293 

2294def post_deps(fn: Task) -> list[Task]: 

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

2296 return getattr(fn, _POST, []) 

2297 

2298 

2299def mounted_from(node: Task | Group) -> str | None: 

2300 """The plugin identity that mounted *node* in, or None for local code. 

2301 

2302 Groups carry it as a real field (each graft gets fresh Group objects); 

2303 task functions carry it as a marker attribute — fns are shared between 

2304 forks on purpose, so the stamp is the *identity*, which is the same 

2305 everywhere the same provider's fn lands. 

2306 """ 

2307 if isinstance(node, Group): 2307 ↛ 2308line 2307 didn't jump to line 2308 because the condition on line 2307 was never true

2308 return node.mounted_from 

2309 return getattr(node, _MOUNTED, None) 

2310 

2311 

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

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

2314 return getattr(fn, _DEFAULT_GROUP, None) 

2315 

2316 

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

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

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

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

2321 

2322 

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

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

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

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

2327 if getattr(fn, _INFINITE, False): 

2328 return False 

2329 if getattr(fn, _INTERACTIVE, False): 

2330 return False 

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

2332 

2333 

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

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

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

2337 

2338 

2339def sharing(fn: Task) -> bool | None: 

2340 """*fn*'s declared sharing policy: `@task(shared=True/False)`, or `None` 

2341 when it left the choice to whoever asks for it. 

2342 

2343 Sharing is a property of a *request*, resolved by a ladder — the request's 

2344 own `.opts(shared=…)`, then the task's declaration, then whatever it was 

2345 requested by (it propagates down a dependency subtree), then shared. This 

2346 reader is the declaration rung; `schedule` resolves the rest, exactly as it 

2347 does for `keep_going`. 

2348 """ 

2349 return getattr(fn, _SHARED, None) 

2350 

2351 

2352def task_body(fn: Task) -> Task: 

2353 """The callable that runs *fn*'s own body — never the machinery. 

2354 

2355 A task is a handle whose `__call__` *is* the body-call machinery, so the 

2356 executor asks for this instead: the author's function, reached through the 

2357 handle and through any `.opts()` reference around it (whose policy the 

2358 executor has already applied at the task boundary). Calling the handle here 

2359 would route the scheduler's own invocation back into the memo it is meant 

2360 to be filling. 

2361 """ 

2362 if isinstance(fn, _Opted): 

2363 base = object.__getattribute__(fn, "_opted_base") 

2364 if isinstance(base, Group): 2364 ↛ 2365line 2364 didn't jump to line 2365 because the condition on line 2364 was never true

2365 base = base.default_task 

2366 return task_body(cast("Task", base)) 

2367 if isinstance(fn, _TaskFn): 

2368 return cast("Task", fn.__wrapped__) 

2369 return fn 

2370 

2371 

2372def declared_hidden(fn: Task) -> bool | None: 

2373 """*fn*'s own answer to "list me?", or `None` when it never said. 

2374 

2375 `None` is the whole point: the manifest resolves it against the enclosing 

2376 group, so hiding a subtree is one declaration at its root and a child can 

2377 still say `hidden=False` to come back. 

2378 """ 

2379 value = getattr(fn, _HIDDEN, None) 

2380 return value if value is None else bool(value) 

2381 

2382 

2383def declared_expose(fn: Task) -> str | None: 

2384 """*fn*'s own answer to "where am I offered?", or `None` when it never 

2385 said — so the enclosing group answers for it, exactly as `hidden` does.""" 

2386 value = getattr(fn, _EXPOSE, None) 

2387 return value if isinstance(value, str) else None 

2388 

2389 

2390def seal_expose(group: Group, default: str = "project_only") -> None: 

2391 """Give every unmarked task in *group* an explicit `expose`. 

2392 

2393 Called on a rung the moment it is assembled, because that is the only 

2394 place the answer is known: a package's tasks are `project_only` until 

2395 one says otherwise, while a person's own tasks file rides everywhere. 

2396 

2397 It has to happen *before* the rungs merge, not after. They are overlaid 

2398 into one tree, and once merged nothing distinguishes "the package 

2399 shipped this" from "the person wrote this" by position — while their 

2400 defaults differ. Sealing here means everything downstream reads one 

2401 resolved value. 

2402 """ 

2403 

2404 def walk(node: Group, inherited: str | None) -> None: 

2405 mine = node.expose if node.expose is not None else inherited 

2406 for fn in node.tasks.values(): 

2407 if declared_expose(fn) is None: 

2408 setattr(fn, _EXPOSE, default if mine is None else mine) 

2409 for sub in node.groups.values(): 

2410 walk(sub, mine) 

2411 

2412 walk(group, group.expose) 

2413 

2414 

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

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

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

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

2419 

2420 

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

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

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

2424 return getattr(fn, _KEEP_GOING, None) 

2425 

2426 

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

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

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

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

2431 

2432 

2433def task_timeout(fn: Task) -> float | None: 

2434 """The `@task(timeout=…)` deadline in seconds, or `None` for no deadline. 

2435 

2436 A duration here, an instant on the context: the scheduler turns one into 

2437 the other when the body starts, because everything downstream asks how 

2438 long is left rather than how long was allowed.""" 

2439 value = getattr(fn, _TIMEOUT, None) 

2440 return None if value is None else float(value) 

2441 

2442 

2443def task_retries(fn: Task) -> int: 

2444 """Extra attempts after the first — `@task(retries=2)` means up to three 

2445 runs. `0` (the default) means the first attempt is the only one. 

2446 

2447 A count of *retries*, never of attempts: "retry twice" is what an author 

2448 says out loud, and reading it as a total would silently halve every 

2449 declaration.""" 

2450 value = getattr(fn, _RETRIES, 0) 

2451 return value if isinstance(value, int) else 0 

2452 

2453 

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

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

2456 return getattr(fn, _CONFIRM, "") 

2457 

2458 

2459def task_cwd(fn: Task) -> str | Path | None: 

2460 """The task's declared cwd policy — a token from `CWD_TOKENS` or an 

2461 absolute `Path` — or `None` when undeclared (the config ladder decides).""" 

2462 return getattr(fn, _CWD, None) 

2463 

2464 

2465def task_rel(fn: Task) -> str | None: 

2466 """The task's declared rel suffix (appended to the resolved cwd base).""" 

2467 return getattr(fn, _REL, None) 

2468 

2469 

2470def task_lane(fn: Task) -> str | None: 

2471 """The task's declared arbiter lane: `"exclusive"` (runs with nothing 

2472 else in flight), `"serial"` (owns the process globals, one at a time, 

2473 overlapping the parallel pool), or `None` (the parallel regime).""" 

2474 if getattr(fn, _EXCLUSIVE, False): 

2475 return "exclusive" 

2476 if getattr(fn, _SERIAL, False): 

2477 return "serial" 

2478 return None 

2479 

2480 

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

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

2483 

2484 

2485def _gate(check: Check) -> Callable[[_F], _F]: 

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

2487 

2488 The decorator is identity in types — a gate marks the function and hands 

2489 the same object back, so whatever it wraps (a plain function below 

2490 `@task`, or a `TaskFn` when stacked above it) keeps its static type.""" 

2491 

2492 def decorate(fn: _F) -> _F: 

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

2494 return fn 

2495 

2496 return decorate 

2497 

2498 

2499_PRE_RECORD = "_footman_pre_record" 

2500 

2501 

2502def task_lanes(fn: Any) -> tuple[Any, ...]: 

2503 """The named lanes this task claims at its boundary, or ().""" 

2504 return tuple(getattr(fn, _LANES, ())) 

2505 

2506 

2507def pre_record(hook: Callable[[Any], None]) -> Callable[[_F], _F]: 

2508 """Attach a reviewer to a task: `@pre_record(fn)` stacked on the `def`. 

2509 

2510 The reviewer receives the task's record while it is still a draft — after 

2511 the body concluded, before the record is sealed, observed, or reported. 

2512 It reads the draft (a `ResultView`) and may set `title` and `code`; the 

2513 row's verdict follows what the review leaves behind, and the record's 

2514 audit names the reviewer and what it did. 

2515 

2516 Reviewers run from the inside out: the hook written closest to the 

2517 function sees the draft first, and each one above it sees what the 

2518 previous reviewers left. Stacks above or below `@task` alike — the 

2519 decorated function (and a `TaskFn` when stacked above the lifter) keeps 

2520 its static type. A reviewer that raises fails the task with its own 

2521 error: a broken reviewer is a broken gate. 

2522 """ 

2523 

2524 def decorate(fn: _F) -> _F: 

2525 setattr(fn, _PRE_RECORD, [*getattr(fn, _PRE_RECORD, ()), hook]) 

2526 return fn 

2527 

2528 return decorate 

2529 

2530 

2531def task_reviewers(fn: Any) -> list[Callable[[Any], None]]: 

2532 """The reviewers attached to *fn*, in execution order (inside-out: 

2533 decorators apply bottom-up, so the nearest attachment appended first).""" 

2534 return list(getattr(fn, _PRE_RECORD, ())) 

2535 

2536 

2537def requires( 

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

2539) -> Callable[[_F], _F]: 

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

2541 

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

2543 raises reads as unavailable, the exception named: 

2544 

2545 ```python 

2546 @task 

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

2548 def publish(): ... 

2549 ``` 

2550 """ 

2551 

2552 def check() -> str | None: 

2553 try: 

2554 ok = predicate() 

2555 except Exception as exc: 

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

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

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

2559 

2560 return _gate(check) 

2561 

2562 

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

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

2565 

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

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

2568 """ 

2569 

2570 def check() -> str | None: 

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

2572 if not missing: 

2573 return None 

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

2575 

2576 return _gate(check) 

2577 

2578 

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

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

2581 

2582 def check() -> str | None: 

2583 import shutil 

2584 

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

2586 if not missing: 

2587 return None 

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

2589 

2590 return _gate(check) 

2591 

2592 

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

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

2595 

2596 def check() -> str | None: 

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

2598 if not missing: 

2599 return None 

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

2601 

2602 return _gate(check) 

2603 

2604 

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

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

2607 

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

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

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

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

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

2613 that one gate. 

2614 """ 

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

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

2617 

2618 

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

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

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

2622 

2623 

2624class TaskView: 

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

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

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

2628 

2629 def __init__( 

2630 self, 

2631 fn: Task, 

2632 name: str, 

2633 group: Group | None = None, 

2634 *, 

2635 path: tuple[str, ...] | None = None, 

2636 ) -> None: 

2637 self.fn = fn 

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

2639 self.name = name 

2640 """The task's leaf name, e.g. `build`. The whole spelling is 

2641 `address` — two tasks in different groups share this one.""" 

2642 self.group = group 

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

2644 `.name` is the group's command-line spelling (e.g. `docs`).""" 

2645 # A group has no parent pointer, deliberately: `include()`/`plugin()` 

2646 # can mount one group object in more than one place, so an address 

2647 # belongs to the *view* the walk yields, not to the group or the 

2648 # function. The walk passes it; the fallback is for a hand-built view 

2649 # (a test), where the immediate group is all there is to go on. 

2650 self._path = path or ((group.name, name) if group else (name,)) 

2651 

2652 @property 

2653 def path(self) -> tuple[str, ...]: 

2654 """Every segment of the task's address, leaf last — `("forge", "dev", 

2655 "up")`. `address` is the same thing as one string.""" 

2656 return self._path 

2657 

2658 @property 

2659 def address(self) -> str: 

2660 """The task's full dotted address, exactly as the command line spells 

2661 it: `forge.dev.up`. Unique across the tree — a name is a task or a 

2662 group at each level, never both — so this is the key to hold a task 

2663 by, and what `Tasks.get` looks up.""" 

2664 return ".".join(self._path) 

2665 

2666 @property 

2667 def mounted_from(self) -> str | None: 

2668 """Which provider this task came from — the `footman.tasks` 

2669 entry-point identity for `plugin()`, the module name for 

2670 `include()` — or `None` when the tasks file defines it itself, 

2671 which is the honest answer: the project owns it. 

2672 

2673 Language-neutral by construction: it names a provider, not a file, 

2674 so it keeps answering for a task that is one day not a Python 

2675 function. A group carries the same field only when it was grafted 

2676 whole; the moment a mount composes into a group that already 

2677 exists, the group is shared and answers `None` while every task 

2678 under it still answers exactly. Read ownership here.""" 

2679 return mounted_from(self.fn) 

2680 

2681 @property 

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

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

2684 return tuple(pre_deps(self.fn)) 

2685 

2686 @property 

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

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

2689 return tuple(post_deps(self.fn)) 

2690 

2691 @property 

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

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

2694 return availability(self.fn) 

2695 

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

2697 

2698 @property 

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

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

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

2702 return keeps_going(self.fn) 

2703 

2704 @property 

2705 def atomic(self) -> bool: 

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

2707 return is_atomic(self.fn) 

2708 

2709 @property 

2710 def infinite(self) -> bool: 

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

2712 return is_infinite(self.fn) 

2713 

2714 @property 

2715 def interactive(self) -> bool: 

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

2717 return is_interactive(self.fn) 

2718 

2719 @property 

2720 def hidden(self) -> bool | None: 

2721 """The task's own `hidden=` answer, or `None` when it inherits one. 

2722 

2723 Read the *declaration*, not the resolved answer: a hook that 

2724 hides a group wants to know whether this task overrode it. 

2725 """ 

2726 return declared_hidden(self.fn) 

2727 

2728 @property 

2729 def timed(self) -> bool: 

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

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

2732 return wants_progress(self.fn) 

2733 

2734 @property 

2735 def confirm(self) -> str: 

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

2737 return task_confirm(self.fn) 

2738 

2739 # Cascade provenance (read-only) — for hooks making decisions by 

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

2741 

2742 @property 

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

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

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

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

2747 from footman import _discover 

2748 

2749 return _discover.defining_dir(self.fn) 

2750 

2751 @property 

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

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

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

2755 from footman import _discover 

2756 

2757 return _discover.shadowed(self.fn) 

2758 

2759 @property 

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

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

2762 from footman import _discover 

2763 

2764 return tuple(_discover.shadow_chain(self.fn)) 

2765 

2766 @property 

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

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

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

2770 try: 

2771 return task_source_file(self.fn) 

2772 except TypeError: 

2773 return None 

2774 

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

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

2777 have = list(pre_deps(self.fn)) 

2778 setattr( 

2779 self.fn, 

2780 _PRE, 

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

2782 ) 

2783 

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

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

2786 have = list(post_deps(self.fn)) 

2787 setattr( 

2788 self.fn, 

2789 _POST, 

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

2791 ) 

2792 

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

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

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

2796 

2797 def set_opts(self, **overrides: Unpack[TaskOpts]) -> None: 

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

2799 the discovery-time counterpart to a per-use `.opts()`. Takes the same 

2800 options (`TaskOpts`, the whole `.opts()` set) and rejects a task 

2801 parameter with the same taught error; the difference is that it edits 

2802 the registered task rather than a per-use proxy, so a hook can set a 

2803 policy across a whole class of tasks. A command-line `-k`/`--fail-fast` 

2804 still wins over a set `keep_going`.""" 

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

2806 setattr(self.fn, attr, value) 

2807 

2808 

2809class Tasks: 

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

2811 look one up by its address, each as a `TaskView`.""" 

2812 

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

2814 self._root = root 

2815 

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

2817 yield from _task_views(self._root) 

2818 

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

2820 """The task at *name* — its full dotted address — or `None`. 

2821 

2822 Addresses are unique by construction (a name is a task or a group 

2823 at each level, never both), so this either finds one task or finds 

2824 none: there is nothing here to be ambiguous about. A bare leaf is 

2825 not an address — `get("build")` finds a top-level `build`, never 

2826 `docs.build`. To search by leaf, iterate: two tasks may answer to 

2827 one, and picking between them is the caller's to make, not 

2828 footman's to guess. 

2829 

2830 A runnable group answers whatever its default answers, so 

2831 `get("lint")` is `get("lint.default")` — the bare group name is 

2832 that action's other spelling on the command line, and lookup would 

2833 otherwise be the one place that isn't true. 

2834 """ 

2835 by_address = {v.address: v for v in self} 

2836 if (view := by_address.get(name)) is not None: 

2837 return view 

2838 return by_address.get(f"{name}.default") 

2839 

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

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

2842 raise KeyError(name) 

2843 return view 

2844 

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

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

2847 

2848 

2849def _task_views( 

2850 g: Group, owner: Group | None = None, prefix: tuple[str, ...] = () 

2851) -> Iterator[TaskView]: 

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

2853 yield TaskView(fn, name, owner, path=(*prefix, name)) 

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

2855 yield from _task_views(sub, sub, (*prefix, sub.name)) 

2856 

2857 

2858@contextlib.contextmanager 

2859def capture() -> Generator[Group]: 

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

2861 

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

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

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

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

2866 """ 

2867 captured = Group("root") 

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

2869 saved_contributions = root.contributions 

2870 root.tasks, root.groups = captured.tasks, captured.groups 

2871 root.contributions = captured.contributions 

2872 try: 

2873 yield captured 

2874 finally: 

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

2876 root.contributions = saved_contributions