Coverage for src/footman/registry.py: 98%
335 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""The task registry: the `@task` / `group()` decorator surface.
3Users build their command tree in a tasks file (`tasks.py` by default):
5```python
6from footman import task, group
8@task
9def lint(fix: bool = False):
10 "Run ruff over the project."
12docs = group("docs", help="Documentation")
14@docs.task
15def serve(port: int = 8000):
16 "Serve the docs locally."
17```
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`).
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"""
28from __future__ import annotations
30import contextlib
31import os
32import shutil
33from collections.abc import Callable, Iterator, Sequence
34from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
36Task = Callable[..., Any]
37Finalizer = Callable[["Tasks"], object]
38"""A `@finalize` hook: edits the merged command tree in place at discovery."""
40# A task stays a plain function; its metadata rides as `_footman_*` attributes.
41# These name every key in one place, so the strings appear once and the read
42# accessors below are the one way the rest of the framework touches them.
43_PRE = "_footman_pre"
44_POST = "_footman_post"
45_KEEP_GOING = "_footman_keep_going"
46_ATOMIC = "_footman_atomic"
47_INFINITE = "_footman_infinite"
48_INTERACTIVE = "_footman_interactive"
49_PROGRESS = "_footman_progress"
50_CONFIRM = "_footman_confirm"
51_CHECKS = "_footman_checks"
52_DEFAULT_GROUP = "_footman_default_group"
53_DEFAULT_FANOUT = "_footman_default_fanout"
56class RegistrationError(ValueError):
57 """A task or group name collided during registration.
59 Subclasses `ValueError` so existing `except ValueError` handlers keep
60 working; the app layer matches this type to report a duplicate name as a
61 user error rather than an import failure.
62 """
65def cli_name(name: str) -> str:
66 """Normalise a Python identifier to its command-line spelling.
68 A *trailing* underscore is Python's keyword/name-escape idiom (`sync_` to
69 avoid shadowing `sync`, `import_`, `class_`); it is stripped, so the flag
70 reads `--sync`, not `--sync-`. This is the one place identifiers become CLI
71 tokens for task names, group names, *and* parameter flags — the `tools.*`
72 bridge already strips the same way, and routing every mapping through here
73 keeps the two from drifting apart again.
74 """
75 return name.rstrip("_").replace("_", "-")
78def _empty_body(fn: object) -> bool:
79 """True when *fn*'s body is only a docstring and/or `pass`.
81 This is the signal that a `@group.default` fans out the group's own tasks
82 rather than running a body of its own. Source that can't be read (a C
83 function, a REPL definition) reads as *not* empty — a body we can't see is
84 treated as one we must run.
85 """
86 import ast
87 import inspect
88 import textwrap
90 try:
91 src = textwrap.dedent(inspect.getsource(fn)) # type: ignore[arg-type]
92 mod = ast.parse(src)
93 except (OSError, TypeError, SyntaxError):
94 return False
95 func = mod.body[0] if mod.body else None
96 if not isinstance(func, ast.FunctionDef | ast.AsyncFunctionDef): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 return False
98 stmts = func.body
99 if (
100 stmts
101 and isinstance(stmts[0], ast.Expr)
102 and isinstance(stmts[0].value, ast.Constant)
103 and isinstance(stmts[0].value.value, str)
104 ):
105 stmts = stmts[1:] # drop the docstring
106 return all(isinstance(s, ast.Pass) for s in stmts)
109# The orchestration options `.opts()` can override, mapped to their task
110# attribute. These are *policy* (how a task runs), kept separate from the task's
111# own parameters (the *work*) — which is why they ride in `.opts()` rather than
112# the call, mirroring tools' `.opts()`.
113_OPTS_ATTRS = {
114 "keep_going": _KEEP_GOING,
115 "atomic": _ATOMIC,
116 "interactive": _INTERACTIVE,
117 "progress": _PROGRESS,
118 "confirm": _CONFIRM,
119 "infinite": _INFINITE,
120}
123def _opts_overrides(kwargs: dict[str, Any]) -> dict[str, Any]:
124 """Validate `.opts()` kwargs and map them to their task attributes."""
125 unknown = sorted(set(kwargs) - set(_OPTS_ATTRS))
126 if unknown:
127 valid = ", ".join(sorted(_OPTS_ATTRS))
128 raise TypeError(
129 f".opts() got unknown option(s) {unknown}; valid options are {valid}. "
130 f"A task's own parameters go in the call — `t.opts(atomic=True)(x=1)` — "
131 f"not in .opts()."
132 )
133 for name, value in kwargs.items():
134 # Override values key the DAG's dedup identity, so they must be hashable.
135 # Every real policy value is (bool / str / None); this turns a stray
136 # unhashable one into a clear error here, not a cryptic crash at DAG build.
137 try:
138 hash(value)
139 except TypeError:
140 raise TypeError(
141 f".opts({name}=…) needs a hashable value — options key the run's "
142 f"deduplication — but got an unhashable {type(value).__name__}"
143 ) from None
144 return {_OPTS_ATTRS[k]: v for k, v in kwargs.items()}
147class _Opted:
148 """A task (or runnable group) reference carrying per-use option overrides.
150 `lint.opts(atomic=True)` reads as a task everywhere a bare task does — a
151 `pre=`/`post=` target, a body call — but reports the overridden `_footman_*`
152 options *for this use*, leaving the registered task untouched. It proxies the
153 base transparently: same signature (via `__wrapped__`), same name, same call;
154 only the overridden options differ. This is footman's policy-vs-work split —
155 the options ride beside the call, not inside its argument list — mirroring
156 the `.opts()` on `tools.*`.
157 """
159 _opted_base: Task | Group
160 _opted_overrides: dict[str, Any]
162 def __init__(self, base: Task | Group, overrides: dict[str, Any]) -> None:
163 object.__setattr__(self, "_opted_base", base)
164 object.__setattr__(self, "_opted_overrides", overrides)
165 object.__setattr__(self, "__wrapped__", base) # inspect.signature follows
167 def __getattr__(self, name: str) -> Any:
168 overrides = object.__getattribute__(self, "_opted_overrides")
169 if name in overrides:
170 return overrides[name]
171 return getattr(object.__getattribute__(self, "_opted_base"), name)
173 def __call__(self, *args: Any, **kwargs: Any) -> Any:
174 return object.__getattribute__(self, "_opted_base")(*args, **kwargs)
176 def opts(self, **overrides: Any) -> _Opted:
177 base = object.__getattribute__(self, "_opted_base")
178 merged = dict(object.__getattribute__(self, "_opted_overrides"))
179 merged.update(_opts_overrides(overrides)) # a later .opts() wins
180 return _Opted(base, merged)
182 def _dedup_key(self) -> tuple[int, frozenset[tuple[str, Any]]]:
183 """This override's identity for DAG deduplication: the base task plus its
184 frozen overrides — the same `(id, frozenset)` shape a bare task uses, so
185 an empty `.opts()` collapses onto the bare task and identical overrides
186 share a node (a shared prerequisite still runs once). A different policy
187 is a distinct node, a genuinely different run. Values are hashable by
188 construction — `_opts_overrides` rejects an unhashable one at call time.
189 The proxy's internals stay behind this method, so the scheduler never
190 reaches into them for identity. (`.opts()` never nests — it merges onto
191 the base — so `_opted_base` is always the ultimate task, never `_Opted`.)"""
192 base = object.__getattribute__(self, "_opted_base")
193 overrides = object.__getattribute__(self, "_opted_overrides")
194 return (id(base), frozenset(overrides.items()))
197def _apply_policy(
198 fn: Task,
199 *,
200 pre: Sequence[Task],
201 post: Sequence[Task],
202 progress: bool,
203 infinite: bool,
204 confirm: str,
205 interactive: bool,
206 keep_going: bool | None,
207 atomic: bool,
208) -> None:
209 """Stamp a task's `_footman_*` policy attributes onto *fn*.
211 The single writer of the orchestration markers, shared by `@task` and
212 `@group.default` so the two option surfaces cannot drift apart. Only the
213 non-default markers are set, so `getattr(fn, _…, default)` reads elsewhere
214 stay the source of truth; `pre`/`post` are always written (as lists) so a
215 later mutation edits a list this task owns.
216 """
217 setattr(fn, _PRE, list(pre))
218 setattr(fn, _POST, list(post))
219 if not progress:
220 setattr(fn, _PROGRESS, False)
221 if infinite:
222 setattr(fn, _INFINITE, True)
223 if confirm:
224 setattr(fn, _CONFIRM, confirm)
225 if interactive:
226 setattr(fn, _INTERACTIVE, True)
227 if keep_going is not None:
228 setattr(fn, _KEEP_GOING, keep_going)
229 if atomic:
230 setattr(fn, _ATOMIC, True)
233_P = ParamSpec("_P")
234_R_co = TypeVar("_R_co", covariant=True)
237class TaskFn(Protocol[_P, _R_co]):
238 """The static type of a `@task`-decorated function: callable with the task's
239 *own* signature (parameters and return type forwarded through the `ParamSpec`),
240 plus `.opts()` for per-use option overrides. The `_footman_*` markers ride as
241 dynamic attributes (read through `getattr`), so they need no declaration here.
242 """
244 __name__: str
246 def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ...
247 def opts(self, **overrides: Any) -> _Opted: ...
250class Group:
251 """A node in the command tree: named tasks and nested sub-groups."""
253 def __init__(self, name: str, help: str = "") -> None:
254 self.name = name
255 self.help = help
256 self.tasks: dict[str, Task] = {}
257 self.groups: dict[str, Group] = {}
258 self.default_task: Task | None = None # runs on a bare `fm <group>`
259 self.finalizers: list[Finalizer] = [] # @finalize hooks (root registry only)
261 def _claim(self, key: str) -> None:
262 where = f"group {self.name!r}" if self.name != "root" else "the root"
263 if key in self.tasks:
264 raise RegistrationError(f"{where} already has a task named {key!r}")
265 if key in self.groups:
266 raise RegistrationError(f"{where} already has a group named {key!r}")
268 @overload
269 def task(self, fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: ...
270 @overload
271 def task(
272 self,
273 fn: None = None,
274 *,
275 name: str = "",
276 pre: Sequence[Task] = (),
277 post: Sequence[Task] = (),
278 progress: bool = True,
279 infinite: bool = False,
280 confirm: str = "",
281 interactive: bool = False,
282 keep_going: bool | None = None,
283 atomic: bool = False,
284 ) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]: ...
286 def task(
287 self,
288 fn: Task | None = None,
289 *,
290 name: str = "",
291 pre: Sequence[Task] = (),
292 post: Sequence[Task] = (),
293 progress: bool = True,
294 infinite: bool = False,
295 confirm: str = "",
296 interactive: bool = False,
297 keep_going: bool | None = None,
298 atomic: bool = False,
299 ) -> Task | Callable[[Task], Task]:
300 """Register a function as a task.
302 Usable bare (`@task`) or parameterised (`@task(name="build")`) to
303 override the command name. `pre`/`post` declare dependency tasks (by
304 reference) that run before/after this one — the scheduler runs
305 independent prerequisites in parallel:
307 ```python
308 @task(pre=[format, lint, typecheck, test])
309 def check(): ...
310 ```
312 Availability gating lives in the `@requires` decorators — stack
313 `@requires`, `@requires_dep`, `@requires_tool`, or `@requires_env`
314 above `@task` to list a task as unavailable (with a reason) where it
315 can't run, rather than hide it. To hide a task entirely, use plain
316 Python: `if sys.platform == "darwin": @task ...`
318 `progress=False` marks a task whose duration has no rhyme or
319 reason (a REPL, a watcher, a network fetch): any run containing it
320 never records timing history and never shows a determinate
321 progress bar — the indeterminate pulse still does.
323 `infinite=True` marks a task that runs until *stopped* — a dev
324 server, a follow-mode tail. It implies `progress=False`, and the
325 run swaps the status line for a one-time hint that Ctrl-C is how
326 this ends. Listings and help carry the same note.
328 `confirm="…"` gates the task on a yes/no answer asked *before* the
329 task and its prerequisites run — deny and the task (and its
330 subtree) is skipped; `--yes` auto-answers it. `interactive=True`
331 hands the task the real terminal — no output capture, sole stdio —
332 so its body can prompt or run a REPL; it can't run under `--json`, and
333 because it owns the terminal, a run that contains an interactive task
334 goes fully sequential — that task and everything else, one at a time.
335 """
337 if infinite and not progress: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was never true
338 # Not an error worth raising — infinite already implies it —
339 # but the pair is redundant, and saying so keeps the two
340 # concepts distinct: "never times" vs "never ends".
341 pass
343 def register(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]:
344 key = cli_name(name or fn.__name__)
345 self._claim(key)
346 _apply_policy(
347 fn,
348 pre=pre,
349 post=post,
350 progress=progress,
351 infinite=infinite,
352 confirm=confirm,
353 interactive=interactive,
354 keep_going=keep_going,
355 atomic=atomic,
356 )
357 fn.opts = lambda **o: _Opted(fn, _opts_overrides(o)) # type: ignore[attr-defined]
358 self.tasks[key] = fn
359 return cast("TaskFn[_P, _R_co]", fn)
361 return register(fn) if fn is not None else register
363 def group(self, name: str, help: str = "") -> Group:
364 """Create and register a nested command group, returning it."""
365 key = cli_name(name)
366 self._claim(key)
367 sub = Group(key, help)
368 self.groups[key] = sub
369 return sub
371 def finalize(self, fn: Finalizer) -> Finalizer:
372 """Register a hook that edits the discovered command tree in place.
374 Every `@finalize` function runs once, after the whole `tasks.py` cascade
375 is assembled but before dispatch, handed a `Tasks` view of the merged
376 tree. Its edits are part of the plan, never a runtime surprise: an added
377 `pre` runs and shows in `--dry-run`, a disabled task drops from listings
378 and completion. It is footman's `collection_modifyitems`.
380 Finalizers run in cascade order — root's first, the folder nearest your
381 cwd last, each seeing the previous ones' edits — the same "local overrides
382 global" precedence the task cascade itself uses. Read and edit each task
383 through the `TaskView` surface, never the private `_footman_*` attributes.
385 @footman.finalize
386 def gate_deploys(tasks):
387 audit = tasks["audit"]
388 for t in tasks:
389 if t.name.startswith("deploy"):
390 t.add_pre(audit)
391 """
392 self.finalizers.append(fn)
393 return fn
395 @overload
396 def default(self, fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]: ...
397 @overload
398 def default(
399 self,
400 fn: None = None,
401 *,
402 pre: Sequence[Task] = (),
403 post: Sequence[Task] = (),
404 progress: bool = True,
405 infinite: bool = False,
406 confirm: str = "",
407 interactive: bool = False,
408 keep_going: bool | None = None,
409 atomic: bool = False,
410 ) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]: ...
412 def default(
413 self,
414 fn: Task | None = None,
415 *,
416 pre: Sequence[Task] = (),
417 post: Sequence[Task] = (),
418 progress: bool = True,
419 infinite: bool = False,
420 confirm: str = "",
421 interactive: bool = False,
422 keep_going: bool | None = None,
423 atomic: bool = False,
424 ) -> Task | Callable[[Task], Task]:
425 """Register *fn* as this group's default action — what a bare
426 `fm <group>` runs, and what the group returns when called.
428 Usable bare (`@group.default`) or parameterised
429 (`@group.default(keep_going=True)`). It takes the same orchestration
430 options as `@task` — `pre`/`post`, `progress`, `infinite`, `confirm`,
431 `interactive`, `keep_going`, `atomic` — with no `name` (the group
432 already names it).
434 The function's signature *is* the group's option surface, so it takes
435 flags/options only: a positional parameter is rejected at load time,
436 because a bare word after a group names a child, not a value. Model a
437 positional action as a task, or take free arguments via `--` passthrough.
439 An **empty-body** default fans the group's own tasks out in parallel, so
440 `interactive=True` on one is rejected — there is no single body to own
441 the terminal. Give the default a real body to make it interactive.
442 """
443 where = self.name if self.name != "root" else "the root group"
445 def register(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]:
446 # Lazy: manifest imports registry, so importing it at module load
447 # would cycle. By call time (a tasks file being imported) it resolves.
448 from footman.context import context_param_name
449 from footman.manifest import param_spec, resolved_signature
451 sig = resolved_signature(fn)
452 ctx_name = context_param_name(sig)
453 for param in sig.parameters.values():
454 if param.name == ctx_name:
455 continue
456 if param_spec(param).get("kind") in ("argument", "variadic"):
457 raise RegistrationError(
458 f"{where}'s default {fn.__name__!r} takes a positional "
459 f"parameter ({param.name!r}); a group default takes "
460 f"flags/options only — a bare word after a group names a "
461 f"child. Model a positional action as a task, or take "
462 f"free arguments via `--` passthrough."
463 )
464 fanout = _empty_body(fn)
465 if interactive and fanout:
466 raise RegistrationError(
467 f"{where}'s default {fn.__name__!r} is interactive but has "
468 f"an empty body, so it fans the group's tasks out in "
469 f"parallel — there is no single body to own the terminal. "
470 f"Give it a real body, or drop interactive."
471 )
472 _apply_policy(
473 fn,
474 pre=pre,
475 post=post,
476 progress=progress,
477 infinite=infinite,
478 confirm=confirm,
479 interactive=interactive,
480 keep_going=keep_going,
481 atomic=atomic,
482 )
483 # A back-reference plus the empty-body flag: an empty-body default
484 # fans out the group's own tasks (implicit prerequisites at DAG-build
485 # time); a custom body is the escape hatch and runs as written.
486 setattr(fn, _DEFAULT_GROUP, self)
487 setattr(fn, _DEFAULT_FANOUT, fanout)
488 fn.opts = lambda **o: _Opted(fn, _opts_overrides(o)) # type: ignore[attr-defined]
489 self.default_task = fn
490 return cast("TaskFn[_P, _R_co]", fn)
492 return register(fn) if fn is not None else register
494 def opts(self, **overrides: Any) -> _Opted:
495 """Per-use option overrides for this group's default action, the same
496 `.opts()` a task has — `pre=[lint.opts(keep_going=True)]`. Overrides ride
497 the group's default when it runs (bare, as a `pre=`, or called)."""
498 return _Opted(self, _opts_overrides(overrides))
500 def __call__(self, *args: Any, **kwargs: Any) -> Any:
501 """Run this group's default action — the imperative mirror of a bare
502 `fm <group>` and of `pre=[group]`.
504 A runnable group (one with an `@group.default`) is callable from a task
505 body the way a task is: a `check` task can call `lint(fix=fix)`. It runs
506 the default's action synchronously and in order — a custom body as
507 written, or, for an empty-body default, the group's own tasks, each
508 handed the arguments it declares (partial reach, by name). Like every
509 body call it forwards arguments explicitly and runs to completion before
510 the next statement; prerequisites and parallelism stay the scheduler's
511 job — reach for a real chain, `pre=`, or `parallel()` for those.
512 """
513 if self.default_task is None:
514 raise TypeError(
515 f"group {self.name!r} is not runnable: it has no "
516 f"@{self.name}.default, so there is no action to call. Add a "
517 f"default action, or call a task inside the group directly."
518 )
519 if not fans_out(self.default_task):
520 return self.default_task(*args, **kwargs) # custom body: as written
521 # Empty-body default: fan out the group's own tasks, handing each only
522 # the arguments it declares — the imperative echo of `fm <group>`.
523 # Sequential, like any body call; wrap the call in parallel() to overlap.
524 from footman.manifest import resolved_signature
526 for child in self.tasks.values():
527 accepts = set(resolved_signature(child).parameters)
528 child(**{k: v for k, v in kwargs.items() if k in accepts})
529 return None
532# The implicit root registry populated by the module-level `task`/`group`
533# aliases (re-exported from `footman`). Constructing an explicit `Group` is
534# always an option and keeps tests free of global state.
535root = Group("root")
536task = root.task
537group = root.group
538finalize = root.finalize
541def reset() -> None:
542 """Clear the global `root` registry (used by the test-suite)."""
543 root.tasks.clear()
544 root.groups.clear()
545 root.finalizers.clear()
548def _importable(module: str) -> bool:
549 """True if *module* is importable, via `find_spec`.
551 `find_spec` doesn't import the module itself, but a dotted name imports its
552 parent packages to locate the child — so a parent whose `__init__` raises
553 (any exception, not just ImportError/ValueError) must read as
554 not-importable, never crash `fm --list` with a traceback.
555 """
556 import importlib.util
558 try:
559 return importlib.util.find_spec(module) is not None
560 except Exception:
561 return False
564def pre_tasks(fn: Task) -> list[Task]:
565 """The prerequisites declared to run before *fn* (`@task(pre=…)`)."""
566 return getattr(fn, _PRE, [])
569def post_tasks(fn: Task) -> list[Task]:
570 """The tasks declared to run after *fn* (`@task(post=…)`)."""
571 return getattr(fn, _POST, [])
574def default_group(fn: Task) -> Group | None:
575 """The group *fn* is the `@group.default` action of, or `None`."""
576 return getattr(fn, _DEFAULT_GROUP, None)
579def fans_out(fn: Task) -> bool:
580 """Whether *fn* is an empty-body `@group.default` that fans out its group's
581 own tasks (they become its implicit prerequisites) rather than run a body."""
582 return getattr(fn, _DEFAULT_FANOUT, False) is True
585def wants_progress(fn: Task) -> bool:
586 """Whether *fn* consented to timing: `@task(progress=False)` opts out,
587 and `infinite=True`/`interactive=True` imply it — a duration that never
588 arrives, or one spent waiting on a human, is not history."""
589 if getattr(fn, _INFINITE, False):
590 return False
591 if getattr(fn, _INTERACTIVE, False):
592 return False
593 return getattr(fn, _PROGRESS, True) is not False
596def is_infinite(fn: Task) -> bool:
597 """Whether *fn* runs until stopped: `@task(infinite=True)`."""
598 return getattr(fn, _INFINITE, False) is True
601def is_interactive(fn: Task) -> bool:
602 """Whether *fn* owns the real terminal: `@task(interactive=True)` — no
603 output capture, sole stdio, so its body may prompt or run a REPL."""
604 return getattr(fn, _INTERACTIVE, False) is True
607def keeps_going(fn: Task) -> bool | None:
608 """*fn*'s declared failure policy: `@task(keep_going=True/False)`, or `None`
609 when it left the choice to the command line / the built-in default."""
610 return getattr(fn, _KEEP_GOING, None)
613def is_atomic(fn: Task) -> bool:
614 """Whether *fn*'s subprocesses opt out of fail-fast's kill:
615 `@task(atomic=True)` — they run to completion rather than be cut off."""
616 return getattr(fn, _ATOMIC, False) is True
619def task_confirm(fn: Task) -> str:
620 """The `@task(confirm="…")` prompt gating this task, or `""` if none."""
621 return getattr(fn, _CONFIRM, "")
624Check = Callable[[], str | None]
625"""One availability gate: the reason it fails, or `None` when it passes."""
628def _gate(check: Check) -> Callable[[Task], Task]:
629 """Stack *check* onto a task's availability gates, read live by `availability`."""
631 def decorate(fn: Task) -> Task:
632 setattr(fn, _CHECKS, [*getattr(fn, _CHECKS, ()), check])
633 return fn
635 return decorate
638def requires(
639 predicate: Callable[[], object], *, reason: str = ""
640) -> Callable[[Task], Task]:
641 """Gate a task on a live *predicate* — available only while it is truthy.
643 The generic gate the three specialisations build on. A predicate that
644 raises reads as unavailable, the exception named:
646 ```python
647 @task
648 @requires(lambda: Path("config.toml").exists(), reason="needs config.toml")
649 def publish(): ...
650 ```
651 """
653 def check() -> str | None:
654 try:
655 ok = predicate()
656 except Exception as exc:
657 detail = f"{type(exc).__name__}: {exc}"
658 return f"{reason} ({detail})" if reason else detail
659 return None if ok else (reason or "unavailable here")
661 return _gate(check)
664def requires_dep(*modules: str, reason: str = "") -> Callable[[Task], Task]:
665 """Gate a task on Python *modules* being importable (`find_spec`, no import).
667 Keep the real `import` in the body; this only checks availability, so a
668 missing optional dependency lists as a clean reason, never an import crash.
669 """
671 def check() -> str | None:
672 missing = [m for m in modules if not _importable(m)]
673 if not missing:
674 return None
675 return reason or f"requires {', '.join(missing)}"
677 return _gate(check)
680def requires_tool(*commands: str, reason: str = "") -> Callable[[Task], Task]:
681 """Gate a task on command-line tools being on `PATH` (`shutil.which`)."""
683 def check() -> str | None:
684 missing = [c for c in commands if shutil.which(c) is None]
685 if not missing:
686 return None
687 return reason or f"requires {', '.join(missing)} on PATH"
689 return _gate(check)
692def requires_env(*names: str, reason: str = "") -> Callable[[Task], Task]:
693 """Gate a task on environment variables being set (`in os.environ`)."""
695 def check() -> str | None:
696 missing = [v for v in names if v not in os.environ]
697 if not missing:
698 return None
699 return reason or f"set {', '.join(missing)}"
701 return _gate(check)
704def availability(fn: Task) -> str | None:
705 """The reason(s) a task is unavailable here, or `None` if it can run.
707 Every `@requires` gate on the task is evaluated **live** — never from the
708 cached manifest, so `DOCKER_HOST=… fm up` works the moment the environment
709 does — and **all** failures are collected, each in its own words, so a task
710 gated on both a missing tool and a missing variable says both. A gate whose
711 predicate raises reads as unavailable with the exception named, scoped to
712 that one gate.
713 """
714 reasons = [r for check in getattr(fn, _CHECKS, ()) if (r := check()) is not None]
715 return "; ".join(reasons) if reasons else None
718def _as_fn(t: TaskView | Task) -> Task:
719 """Unwrap a `TaskView` to its function; pass a raw function through."""
720 return t.fn if isinstance(t, TaskView) else t
723class TaskView:
724 """A finalizer's handle on one task: read its wiring, its policy flags, and
725 its cascade provenance (where it was defined, what it overrode), and edit it
726 here — never through the private `_footman_*` attributes."""
728 def __init__(self, fn: Task, name: str, group: Group | None = None) -> None:
729 self.fn = fn
730 """The task function itself — the escape hatch past the view."""
731 self.name = name
732 """The task's command-line name, e.g. `deploy-web`."""
733 self.group = group
734 """The group this task lives in, or `None` for a top-level task — its
735 `.name` is the group's command-line spelling (e.g. `docs`). Use it to
736 disambiguate two tasks that share a leaf name across groups."""
738 @property
739 def pre(self) -> tuple[Task, ...]:
740 """The prerequisites that run before this task."""
741 return tuple(pre_tasks(self.fn))
743 @property
744 def post(self) -> tuple[Task, ...]:
745 """The tasks that run after this one."""
746 return tuple(post_tasks(self.fn))
748 @property
749 def disabled(self) -> str | None:
750 """Why the task is unavailable here, or `None` if it can run."""
751 return availability(self.fn)
753 # Policy flags (read-only — declared at decoration).
755 @property
756 def keep_going(self) -> bool | None:
757 """The task's declared failure policy (`@task(keep_going=…)`), or `None`
758 when it left the choice to the command line / the built-in default."""
759 return keeps_going(self.fn)
761 @property
762 def atomic(self) -> bool:
763 """Whether the task's subprocesses opt out of fail-fast's kill."""
764 return is_atomic(self.fn)
766 @property
767 def infinite(self) -> bool:
768 """Whether the task runs until stopped (`@task(infinite=True)`)."""
769 return is_infinite(self.fn)
771 @property
772 def interactive(self) -> bool:
773 """Whether the task owns the real terminal (`@task(interactive=True)`)."""
774 return is_interactive(self.fn)
776 @property
777 def timed(self) -> bool:
778 """Whether the task records timing history / shows a determinate bar —
779 `@task(progress=False)` (and `infinite`/`interactive`) opt out."""
780 return wants_progress(self.fn)
782 @property
783 def confirm(self) -> str:
784 """The `@task(confirm="…")` prompt gating the task, or `""` if none."""
785 return task_confirm(self.fn)
787 # Cascade provenance (read-only) — for finalizers making decisions by
788 # where a task came from and what it overrode.
790 @property
791 def defining_dir(self) -> str | None:
792 """The folder the task was defined in, or `None` when the cascade did
793 not tag it (a plugin- or `include()`-composed task, not a cascade file).
794 Use it to act on tasks from one subtree of a monorepo."""
795 from footman import discover
797 return discover.defining_dir(self.fn)
799 @property
800 def shadowed(self) -> Task | None:
801 """The task this one overrides — same name, one cascade level up — or
802 `None` if it shadows nothing."""
803 from footman import discover
805 return discover.shadowed(self.fn)
807 @property
808 def shadow_chain(self) -> tuple[Task, ...]:
809 """This task and every task it shadows, nearest (this one) first."""
810 from footman import discover
812 return tuple(discover.shadow_chain(self.fn))
814 @property
815 def source_file(self) -> str | None:
816 """The file the task's function is defined in, or `None` when it can't
817 be located (a built-in or dynamically constructed function)."""
818 import inspect
820 try:
821 return inspect.getsourcefile(self.fn)
822 except TypeError:
823 return None
825 def add_pre(self, *tasks: TaskView | Task) -> None:
826 """Prepend prerequisites (views or functions), skipping any already set."""
827 have = list(pre_tasks(self.fn))
828 setattr(
829 self.fn,
830 _PRE,
831 [*(f for t in tasks if (f := _as_fn(t)) not in have), *have],
832 )
834 def add_post(self, *tasks: TaskView | Task) -> None:
835 """Append post-tasks (views or functions), skipping any already set."""
836 have = list(post_tasks(self.fn))
837 setattr(
838 self.fn,
839 _POST,
840 [*have, *(f for t in tasks if (f := _as_fn(t)) not in have)],
841 )
843 def disable(self, reason: str) -> None:
844 """Mark the task unavailable — listed with *reason*, refused if run."""
845 _gate(lambda: reason)(self.fn)
847 def set_opts(self, **overrides: Any) -> None:
848 """Set orchestration options on the task **permanently, for every use** —
849 the finalize-time counterpart to a per-use `.opts()`. Takes the same
850 options (`keep_going`, `atomic`, `interactive`, `progress`, `confirm`,
851 `infinite`) and rejects a task parameter with the same taught error; the
852 difference is that it edits the registered task rather than a per-use
853 proxy, so a finalizer can set a policy across a whole class of tasks. A
854 command-line `-k`/`--fail-fast` still wins over a set `keep_going`."""
855 for attr, value in _opts_overrides(overrides).items():
856 setattr(self.fn, attr, value)
859class Tasks:
860 """A finalizer's view of the merged command tree: iterate every task, or
861 look one up by its command-line name, each as a `TaskView`."""
863 def __init__(self, root: Group) -> None:
864 self._root = root
866 def __iter__(self) -> Iterator[TaskView]:
867 yield from _task_views(self._root)
869 def get(self, name: str) -> TaskView | None:
870 """The task named *name* (command-line spelling), or `None`."""
871 return next((v for v in self if v.name == name), None)
873 def __getitem__(self, name: str) -> TaskView:
874 if (view := self.get(name)) is None:
875 raise KeyError(name)
876 return view
878 def __contains__(self, name: object) -> bool:
879 return isinstance(name, str) and self.get(name) is not None
882def _task_views(g: Group, owner: Group | None = None) -> Iterator[TaskView]:
883 for name, fn in g.tasks.items():
884 yield TaskView(fn, name, owner)
885 for sub in g.groups.values():
886 yield from _task_views(sub, sub)
889@contextlib.contextmanager
890def capture() -> Iterator[Group]:
891 """Redirect module-level `@task`/`group` registration into a fresh tree.
893 The seam `include()` uses to import a provider module without letting its
894 decorators land in the current registry: `root.tasks`/`root.groups` are
895 swapped for fresh dicts for the duration and the captured tree is yielded.
896 Reentrant — a provider may itself `include()` another provider.
897 """
898 captured = Group("root")
899 saved_tasks, saved_groups = root.tasks, root.groups
900 saved_finalizers = root.finalizers
901 root.tasks, root.groups = captured.tasks, captured.groups
902 root.finalizers = captured.finalizers
903 try:
904 yield captured
905 finally:
906 root.tasks, root.groups = saved_tasks, saved_groups
907 root.finalizers = saved_finalizers