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
« 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.
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 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)
49if TYPE_CHECKING:
50 from footman._globals import Lane
51 from footman.context import ResultView
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)`."""
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)
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."""
81def _note_contribution(fn: Any, kind: str, item: Any) -> None:
82 """Record on *fn* that decorating it registered *item* under *kind*.
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))
91class GlobalOption:
92 """A plugin's own global option — `--env-file=PATH` beside `--jobs=N`.
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.
100 ENV_FILE = GlobalOption("env-file", Path, help="load this .env file")
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.
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.
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 """
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 )
143 name: str
144 annotation: Any
145 default: Any
146 config: bool | str
147 help: str
148 owner: str
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"
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
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()
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)
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
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.
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 ```
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
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
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
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 )
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
268 return inspect.Parameter(
269 self.name.replace("-", "_"),
270 inspect.Parameter.KEYWORD_ONLY,
271 annotation=self.annotation,
272 default=self.default,
273 )
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}>"
280_UNBOUND = object()
282_USES = "_footman_uses"
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>"
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] = {}
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.
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
305 module = _sys._getframe(1).f_globals.get("__name__", "<unknown>")
306 _CONFIG_SECTIONS[module] = cli_name(name)
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.
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
321 run = _futures.active_session()
322 if run is not None:
323 run.ephemeral.append((group, key, previous))
326def task_uses(fn: Task) -> tuple[GlobalOption, ...]:
327 """The global options *fn* declared it reads — `@task(uses=[OPT])`."""
328 return tuple(getattr(fn, _USES, ()))
331def validate_global_options(options: Sequence[GlobalOption]) -> str | None:
332 """The collision law for plugin globals, applied to the merged tree.
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
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)
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.
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
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()
420def orphan_global_options(root: Group) -> list[str]:
421 """Warnings for globals nothing is wired to read, one per orphan.
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()
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)
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
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.
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.
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.
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"""
493EXPOSE_VALUES = ("always", "global_only", "project_only")
494"""What `expose=` accepts. `global_only` means outside a project only."""
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"
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")
514def validate_lanes(value: Any) -> tuple[Any, ...]:
515 """Lanes are bindings, never strings: each entry must be a `Lane` handle.
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
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
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
548def _validate_rel(value: str | Path) -> str:
549 """A rel suffix: a relative path, appended to the resolved cwd base.
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)
563_CHECKS = "_footman_checks"
564_DEFAULT_GROUP = "_footman_default_group"
565_DEFAULT_FANOUT = "_footman_default_fanout"
568class RegistrationError(ValueError):
569 """A task or group name collided during registration.
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 """
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
586def _checked_expose(value: str) -> str:
587 """*value* if it is one of `EXPOSE_VALUES`, else a taught refusal.
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
600def expose(where: str) -> Callable[[_F], _F]:
601 """Say where a task is offered: `@expose("global_only")`.
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_*`.
607 ```python
608 @task
609 @expose("global_only")
610 def new(name: str): ...
611 ```
612 """
613 checked = _checked_expose(where)
615 def mark(fn: _F) -> _F:
616 setattr(fn, _EXPOSE, checked)
617 return fn
619 return mark
622def cli_name(name: str) -> str:
623 """Normalise a Python identifier to its command-line spelling.
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("_", "-")
635def _isgeneratorfunction(fn: Hook) -> bool:
636 """Whether *fn* is a generator function, through any wrapping."""
637 import inspect
639 return inspect.isgeneratorfunction(inspect.unwrap(fn))
642def _check_hook_arity(kind: str, fn: Hook, wanted: int) -> None:
643 """Refuse a hook whose signature cannot be called, at registration.
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
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 )
673def _empty_body(fn: object) -> bool:
674 """True when *fn*'s body is only a docstring and/or `pass`.
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
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
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 )
712 return all(inert(s) for s in stmts)
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}
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."""
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
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.
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())
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()}
827class _Opted:
828 """A task (or runnable group) reference carrying per-use option overrides.
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 """
839 _opted_base: Task | Group
840 _opted_overrides: dict[str, Any]
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
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)
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
858 return _futures.call(self, args, kwargs)
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
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)
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)
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)
903class _TaskFn:
904 """The object `@task` returns — and the very same object it registers.
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.
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 """
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)
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
935 return _futures.call(self, args, kwargs)
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)
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)))
955 def __repr__(self) -> str:
956 return f"<task {getattr(self, '__name__', '?')}>"
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.
965def task_source(fn: Any) -> str:
966 """The source text of the function behind *fn* (decorators included)."""
967 import inspect
969 return inspect.getsource(inspect.unwrap(fn))
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.
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.
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
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()
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
1002 return inspect.getsourcefile(inspect.unwrap(fn))
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*.
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)
1093_PRE_BIND_HOOKS = "_footman_own_pre_bind"
1094_PRE_TASK_HOOKS = "_footman_own_pre_task"
1095_POST_TASK_HOOKS = "_footman_own_post_task"
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, ()))
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 )
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.
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 """
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
1131 return attach
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)
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()
1154 def _spans() -> list[Any]:
1155 spans = getattr(tls, "spans", None)
1156 if spans is None:
1157 spans = tls.spans = []
1158 return spans
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)
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 )
1186 _pre.__name__ = name
1187 _post.__name__ = name
1188 fn.pre_task(_pre)
1189 fn.post_task(_post)
1190 return gen_fn
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()
1207 def _spans() -> list[list[Any]]:
1208 spans = getattr(tls, "spans", None)
1209 if spans is None:
1210 spans = tls.spans = []
1211 return spans
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])
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
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 )
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
1261 fn.wrap_task = wrap_task_sugar
1262 fn.wrap_bind = wrap_bind_sugar
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])
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 """
1278 __name__: str
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 ...
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 ...
1294 def pre_task(self, hook: Callable[[], None], /) -> Callable[[], None]:
1295 """Before this task's body, innermost — after any plugin's pres."""
1296 ...
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 ...
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 ...
1308 def wrap_task(self, fn: _F, /) -> _F:
1309 """The pre/post pair as one generator: `result = yield` once."""
1310 ...
1312 def wrap_bind(self, fn: _F, /) -> _F:
1313 """The two-yield wrapper: the bind boundary, then the body."""
1314 ...
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.
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."""
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.
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 ...
1406class GroupFactory(Protocol):
1407 """The static shape of the module-level `group` factory (`Group.group`).
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 """
1415 def __call__(
1416 self,
1417 name: str,
1418 help: str = "",
1419 hidden: bool | None = None,
1420 expose: str | None = None,
1421 ) -> Group: ...
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."""
1429 def __call__(self, fn: _F) -> _F: ...
1432class Group:
1433 """A node in the command tree: named tasks and nested sub-groups."""
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
1466 @property
1467 def default_task(self) -> Task | None:
1468 """The default action — what a bare `fm <group>` runs.
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")
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)
1496 def _free_ephemeral_key(self, key: str) -> str:
1497 """`key`, or the next free `key-2`, `key-3` — but only mid-run.
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
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}"
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}")
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]
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]]: ...
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.
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:
1606 ```python
1607 @task(pre=[format, lint, typecheck, test])
1608 def check(): ...
1609 ```
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.
1616 Three different things, worth keeping apart:
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.
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.
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.
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.
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.
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.
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.
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 """
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)
1734 return register(fn) if fn is not None else register
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.
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=…)`.
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
1786 def pre_tasks(self, fn: _F) -> _F:
1787 """Register the once-per-invocation hook, run before anything else.
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`:
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)
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`.)
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.
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
1824 def pre_bind(self, fn: _F) -> _F:
1825 """Register the before-binding hook: `pre_bind(inv, task)`.
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:
1832 @footman.pre_bind
1833 def credentials(inv, task):
1834 task.env["DEPLOY_TOKEN"] = vault.read("deploy")
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
1847 def post_tasks(self, fn: _F) -> _F:
1848 """Register the once-per-invocation closing hook: `post_tasks(inv)`.
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:
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")
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
1875 def wrap_task(self, fn: _F) -> _F:
1876 """Register a one-yield wrapper around the body: sugar over the pair.
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:
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)
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}"
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)
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 )
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
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.
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`:
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)
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}"
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"))
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"))
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 )
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
2036 def pre_task(self, fn: _F) -> _F:
2037 """Register the before-each-task hook: `pre_task(inv, task)`.
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:
2048 @footman.pre_task
2049 def open_span(inv, task):
2050 task.state.span = tracer.start(task.name, dict(task.args))
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.
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
2068 def post_task(self, fn: _F) -> _F:
2069 """Register the after-each-task hook: `post_task(inv, task, result)`.
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.
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.
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
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]]: ...
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.
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).
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.
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.
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 """
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)
2198 return register(fn) if fn is not None else register
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))))
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]`.
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
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
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
2259def reset() -> None:
2260 """Clear the global `root` registry (used by the test-suite, and by
2261 `_discover` between file imports).
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()
2273def _importable(module: str) -> bool:
2274 """True if *module* is importable, via `find_spec`.
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
2283 try:
2284 return importlib.util.find_spec(module) is not None
2285 except Exception:
2286 return False
2289def pre_deps(fn: Task) -> list[Task]:
2290 """The prerequisites declared to run before *fn* (`@task(pre=…)`)."""
2291 return getattr(fn, _PRE, [])
2294def post_deps(fn: Task) -> list[Task]:
2295 """The tasks declared to run after *fn* (`@task(post=…)`)."""
2296 return getattr(fn, _POST, [])
2299def mounted_from(node: Task | Group) -> str | None:
2300 """The plugin identity that mounted *node* in, or None for local code.
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)
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)
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
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
2334def is_infinite(fn: Task) -> bool:
2335 """Whether *fn* runs until stopped: `@task(infinite=True)`."""
2336 return getattr(fn, _INFINITE, False) is True
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.
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)
2352def task_body(fn: Task) -> Task:
2353 """The callable that runs *fn*'s own body — never the machinery.
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
2372def declared_hidden(fn: Task) -> bool | None:
2373 """*fn*'s own answer to "list me?", or `None` when it never said.
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)
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
2390def seal_expose(group: Group, default: str = "project_only") -> None:
2391 """Give every unmarked task in *group* an explicit `expose`.
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.
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 """
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)
2412 walk(group, group.expose)
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
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)
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
2433def task_timeout(fn: Task) -> float | None:
2434 """The `@task(timeout=…)` deadline in seconds, or `None` for no deadline.
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)
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.
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
2454def task_confirm(fn: Task) -> str:
2455 """The `@task(confirm="…")` prompt gating this task, or `""` if none."""
2456 return getattr(fn, _CONFIRM, "")
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)
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)
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
2481Check = Callable[[], str | None]
2482"""One availability gate: the reason it fails, or `None` when it passes."""
2485def _gate(check: Check) -> Callable[[_F], _F]:
2486 """Stack *check* onto a task's availability gates, read live by `availability`.
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."""
2492 def decorate(fn: _F) -> _F:
2493 setattr(fn, _CHECKS, [*getattr(fn, _CHECKS, ()), check])
2494 return fn
2496 return decorate
2499_PRE_RECORD = "_footman_pre_record"
2502def task_lanes(fn: Any) -> tuple[Any, ...]:
2503 """The named lanes this task claims at its boundary, or ()."""
2504 return tuple(getattr(fn, _LANES, ()))
2507def pre_record(hook: Callable[[Any], None]) -> Callable[[_F], _F]:
2508 """Attach a reviewer to a task: `@pre_record(fn)` stacked on the `def`.
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.
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 """
2524 def decorate(fn: _F) -> _F:
2525 setattr(fn, _PRE_RECORD, [*getattr(fn, _PRE_RECORD, ()), hook])
2526 return fn
2528 return decorate
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, ()))
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.
2542 The generic gate the three specialisations build on. A predicate that
2543 raises reads as unavailable, the exception named:
2545 ```python
2546 @task
2547 @requires(lambda: Path("config.toml").exists(), reason="needs config.toml")
2548 def publish(): ...
2549 ```
2550 """
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")
2560 return _gate(check)
2563def requires_dep(*modules: str, reason: str = "") -> Callable[[_F], _F]:
2564 """Gate a task on Python *modules* being importable (`find_spec`, no import).
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 """
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)}"
2576 return _gate(check)
2579def requires_tool(*commands: str, reason: str = "") -> Callable[[_F], _F]:
2580 """Gate a task on command-line tools being on `PATH` (`shutil.which`)."""
2582 def check() -> str | None:
2583 import shutil
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"
2590 return _gate(check)
2593def requires_env(*names: str, reason: str = "") -> Callable[[_F], _F]:
2594 """Gate a task on environment variables being set (`in os.environ`)."""
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)}"
2602 return _gate(check)
2605def availability(fn: Task) -> str | None:
2606 """The reason(s) a task is unavailable here, or `None` if it can run.
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
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
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."""
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,))
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
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)
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.
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)
2681 @property
2682 def pre(self) -> tuple[Task, ...]:
2683 """The prerequisites that run before this task."""
2684 return tuple(pre_deps(self.fn))
2686 @property
2687 def post(self) -> tuple[Task, ...]:
2688 """The tasks that run after this one."""
2689 return tuple(post_deps(self.fn))
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)
2696 # Policy flags (read-only — declared at decoration).
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)
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)
2709 @property
2710 def infinite(self) -> bool:
2711 """Whether the task runs until stopped (`@task(infinite=True)`)."""
2712 return is_infinite(self.fn)
2714 @property
2715 def interactive(self) -> bool:
2716 """Whether the task owns the real terminal (`@task(interactive=True)`)."""
2717 return is_interactive(self.fn)
2719 @property
2720 def hidden(self) -> bool | None:
2721 """The task's own `hidden=` answer, or `None` when it inherits one.
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)
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)
2734 @property
2735 def confirm(self) -> str:
2736 """The `@task(confirm="…")` prompt gating the task, or `""` if none."""
2737 return task_confirm(self.fn)
2739 # Cascade provenance (read-only) — for hooks making decisions by
2740 # where a task came from and what it overrode.
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
2749 return _discover.defining_dir(self.fn)
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
2757 return _discover.shadowed(self.fn)
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
2764 return tuple(_discover.shadow_chain(self.fn))
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
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 )
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 )
2793 def disable(self, reason: str) -> None:
2794 """Mark the task unavailable — listed with *reason*, refused if run."""
2795 _gate(lambda: reason)(self.fn)
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)
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`."""
2813 def __init__(self, root: Group) -> None:
2814 self._root = root
2816 def __iter__(self) -> Iterator[TaskView]:
2817 yield from _task_views(self._root)
2819 def get(self, name: str) -> TaskView | None:
2820 """The task at *name* — its full dotted address — or `None`.
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.
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")
2840 def __getitem__(self, name: str) -> TaskView:
2841 if (view := self.get(name)) is None:
2842 raise KeyError(name)
2843 return view
2845 def __contains__(self, name: object) -> bool:
2846 return isinstance(name, str) and self.get(name) is not None
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))
2858@contextlib.contextmanager
2859def capture() -> Generator[Group]:
2860 """Redirect module-level `@task`/`group` registration into a fresh tree.
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