Coverage for src/footman/compose.py: 92%
342 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"""Compose the task surface: two typed verbs over one engine.
3- `plugin("acme.devkit.lint")` mounts from an installed package's
4 **`footman.tasks` entry point** — the console-script of task trees: a
5 stable public identity for a Group the package offers, enumerable,
6 inert until mounted. The longest installed entry-point name is the
7 identity; the rest of the string walks the advertised tree.
8- `include("mytasks.lint")` mounts from an **importable module** — the same
9 grammar over your own reach: file-splitting, monorepo-local sharing.
10 The longest importable prefix is imported (under a registry capture, so
11 the provider's decorators can't leak); the rest walks the captured tree.
13The type tag lives in the verb: no string is ever resolved against both
14registries, so there is no precedence and no silent re-pointing when a new
15package lands. The model is Python imports — `plugin("acme.devkit.lint")`
16is `from acme_devkit import lint` for task trees; mounting a whole container
17is the `import *`, safe here because local definitions silently win and
18imported-vs-imported clashes are loud.
20A mounted node lands under its **own name** (identity never becomes an
21address); `into=` — a dotted address, auto-vivified — is the consumer's
22placement. Everything after resolution (walk, land, filter, merge) is one
23shared engine, and every imported node carries its provider identity as
24provenance: collision messages cite it, `fm --plugins` reports it.
26Everything resolves at import/manifest-build time; the completion hot path
27is untouched. `importlib.metadata` is stdlib — footman stays zero-dependency.
28"""
30from __future__ import annotations
32import importlib
33import sys
34from types import ModuleType
35from typing import Any
37from footman import registry
38from footman.registry import Group, RegistrationError, Task, mounted_from
40ENTRY_POINT_GROUP = "footman.tasks"
42# One import (and capture) per provider module per process: every cascade
43# file that includes the same module gets the same tree, whatever the import
44# order — the `sys.modules` cache can't half-register a provider.
45_module_trees: dict[str, Group] = {}
48def _declares_tasks(module: ModuleType) -> bool:
49 """Whether *module* holds anything whose registration cannot be rebuilt.
51 A `Group` or a `@task`-stamped function is the shape `_reconstruct`
52 refuses to speak for, because a task's registration IS the tree
53 structure and a namespace cannot say where it was mounted. Everything
54 else a provider declares — options, hooks — carries its own receipt and
55 rebuilds.
56 """
57 return any(
58 isinstance(value, Group) or hasattr(value, registry._PRE)
59 for value in vars(module).values()
60 )
63def _tree_of_module(module: ModuleType, *, allow_empty: bool = False) -> Group:
64 """The task tree of an already-imported module: the memo, a rebuild, an
65 empty answer, or a taught no.
67 A module imported *outside* `include()` already ran its decorators
68 against whatever registry was live, and re-executing it would double
69 every side effect. A contributions-only module (hooks and options, no
70 tasks) can still be rebuilt from its own namespace; a module that
71 defined tasks cannot, and the answer is guidance, not guesswork.
73 The last two rungs are the same question the freshly-imported path asks
74 a few lines below — *does this module hold anything footman can use?* —
75 and they exist because it must be asked here too. An already-imported
76 module that declares NOTHING was not spent: there was nothing to
77 capture. Refusing it blamed an import for a tree that was always empty,
78 and named the wrong module while doing it, because the empty one is
79 usually a parent package `include()` is merely walking through
80 (`include("devkit.tasks")` walks `devkit`) rather than the one the
81 caller wrote.
82 """
83 name = module.__name__
84 if name in _module_trees: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 return _module_trees[name]
86 if (rebuilt := _reconstruct(module)) is not None:
87 _module_trees[name] = rebuilt
88 return rebuilt
89 if _declares_tasks(module):
90 # Genuinely spent: there IS something here, and the import that would
91 # have captured it happened somewhere this call cannot reach.
92 raise RegistrationError(
93 f"include({name!r}): the module was already imported outside "
94 f"include(), so its tasks were never captured — call include() "
95 f"before anything else imports it, or have the module expose an "
96 f"explicit Group and pass that instead"
97 )
98 if allow_empty:
99 return Group("root") # an intermediate package with nothing in it
100 return _adopt_explicit_group(module)
103def _reconstruct(module: ModuleType) -> Group | None:
104 """Rebuild an already-imported, contributions-only module's tree from
105 its own namespace — or `None` for a module this cannot speak for.
107 The import that should have fired inside a proper load's capture is
108 spent, and the capture it actually fired in is gone (it happened: a
109 bare `import footman.env_files` anywhere in the process left the scan
110 unable to see `--env-file`, an order-dependent flake in the suite and a
111 dead end for any later mount). But the declarations survive as
112 module-level names, each carrying its own receipt: a `GlobalOption` is
113 stamped with its defining module (`owner`), and a hook decorator writes
114 the (kind, item) pairs it registered on the decorated fn
115 (`registry._CONTRIBUTED`) — the exact objects, wrappers included.
116 Module-level names are as far as this can see, which is the documented
117 provider convention. Tasks and groups don't reconstruct — their
118 registration is the tree structure itself — so any sign of either (a
119 `Group`, a `@task`-stamped fn) keeps the caller's taught refusal.
120 """
121 name = module.__name__
122 tree = Group("root")
123 seen: set[int] = set()
124 for value in vars(module).values():
125 if isinstance(value, Group) or hasattr(value, registry._PRE):
126 return None
127 if id(value) in seen:
128 continue # one declaration, however many names it goes by
129 seen.add(id(value))
130 if isinstance(value, registry.GlobalOption):
131 if value.owner == name: 131 ↛ 124line 131 didn't jump to line 124 because the condition on line 131 was always true
132 tree.contributions["globals"].append(value)
133 elif getattr(value, "__module__", None) == name:
134 for kind, item in getattr(value, registry._CONTRIBUTED, ()):
135 tree.contributions[kind].append(item)
136 if not any(tree.contributions.values()):
137 return None
138 if (module.__doc__ or "").strip(): 138 ↛ 140line 138 didn't jump to line 140 because the condition on line 138 was always true
139 tree.help = (module.__doc__ or "").strip().splitlines()[0]
140 return tree
143def _adopt_explicit_group(module: ModuleType) -> Group:
144 """A never-registering provider's single module-level `Group`, if any."""
145 groups = [v for v in vars(module).values() if isinstance(v, Group)]
146 if len(groups) == 1:
147 return groups[0]
148 detail = "no module-level Group" if not groups else f"{len(groups)} Groups"
149 raise RegistrationError(
150 f"include({module.__name__!r}): the module registered no tasks and "
151 f"has {detail} to adopt — define tasks with @task/group(), or expose "
152 f"exactly one Group"
153 )
156def _import_source(dotted: str, *, allow_empty: bool = False) -> Group:
157 """Import *dotted* under `capture()` and memoise its captured tree.
159 *allow_empty* tolerates a module that registers nothing and offers no
160 explicit Group — an intermediate package on the way to a longer prefix
161 (`include("pkg.tasks")` walks through `pkg`) is allowed to be empty; a
162 terminal one keeps the taught refusal.
163 """
164 if dotted in _module_trees:
165 return _module_trees[dotted]
167 if dotted in sys.modules:
168 return _tree_of_module(sys.modules[dotted], allow_empty=allow_empty)
169 with registry.capture() as captured:
170 try:
171 module = importlib.import_module(dotted)
172 except ImportError as exc:
173 # A missing module (a typo, a module not on the path, a missing
174 # dependency) otherwise surfaces as "failed to import <tasks.py>",
175 # blaming the file and never naming the include() call that broke.
176 # Name it, and the reason — the same taught shape plugin() gives. A
177 # RegistrationError from the provider's own body isn't an
178 # ImportError, so it propagates already-taught, untouched.
179 raise RegistrationError(
180 f"include({dotted!r}): failed to import ({type(exc).__name__}: {exc})"
181 ) from exc
182 if captured.tasks or captured.groups or any(captured.contributions.values()):
183 # Tasks, groups, or lifecycle contributions alone — a hooks-only
184 # provider (a `@pre_tasks` module) is a valid mount with no tree.
185 tree = captured
186 elif allow_empty:
187 tree = captured # an empty intermediate package: fine, walk on
188 else:
189 # Nothing registered at module level: the provider keeps an explicit
190 # Group instead (the entry-point convention) — unambiguous only
191 # because the capture came back empty.
192 tree = _adopt_explicit_group(module)
193 if not tree.help and (module.__doc__ or "").strip():
194 # A container describes itself through its module docstring — the
195 # first line becomes the root's help, so `--plugins` and group help
196 # have words even for a bare bundle of groups.
197 tree.help = (module.__doc__ or "").strip().splitlines()[0]
198 _module_trees[dotted] = tree
199 return tree
202def _fork(tree: Group) -> Group:
203 """A structural copy of *tree*: fresh Group objects and dicts, shared fns.
205 A memoised provider tree grafted into a project is later mutated by the
206 cascade overlay/tag in place — without a fork, one project's tasks (and
207 DEFINING_DIR stamps) leak into the shared `_module_trees` memo and thus into
208 every later in-process invocation (F38). The task callables stay shared on
209 purpose: DEFINING_DIR is re-stamped on each load, so sharing them is safe
210 and keeps `recording()`/identity checks meaningful.
211 """
212 fork = Group(tree.name, tree.help, tree.hidden, tree.expose)
213 fork.tasks.update(tree.tasks) # share fns, but into a fresh dict
214 for name, sub in tree.groups.items():
215 fork.groups[name] = _fork(sub) # recurse: fresh subgroup objects
216 # A faithful copy carries *every* Group field, not only tasks/groups: a
217 # provider's lifecycle contributions (`@pre_tasks` hooks today) ride
218 # along, provenance survives, and a runnable group keeps its
219 # `@group.default` for free — the default *is* the child task named
220 # `default`, so the tasks-dict copy above already carried it.
221 # `test_compose`'s field census fails the moment a new Group field is
222 # added but not copied here.
223 fork.contributions = {k: list(b) for k, b in tree.contributions.items()}
224 fork.mounted_from = tree.mounted_from
225 return fork
228def _stamp(node: Group, identity: str) -> None:
229 """Record *identity* as provenance on every node of a mounted tree.
231 Groups carry it as a field (each mount grafts fresh Group objects); task
232 fns carry the marker attribute — they are shared between forks, and the
233 identity is the same everywhere the same provider's fn lands.
234 """
235 node.mounted_from = identity
236 for fn in node.tasks.values():
237 setattr(fn, registry._MOUNTED, identity)
238 for sub in node.groups.values():
239 _stamp(sub, identity)
242def _walk_subpath(
243 tree: Group, segments: list[str], *, verb: str, source: str
244) -> Group | Task:
245 """Walk the remainder of a source string inside a provider's tree."""
246 node: Group = tree
247 for pos, seg in enumerate(segments):
248 last = pos == len(segments) - 1
249 if seg in node.groups:
250 node = node.groups[seg]
251 continue
252 if seg in node.tasks and last:
253 return node.tasks[seg]
254 bad = ".".join(segments[: pos + 1])
255 known = ", ".join(sorted([*node.groups, *node.tasks])) or "nothing"
256 raise RegistrationError(
257 f"{verb}({source!r}): no task or group at {bad!r} in the "
258 f"provider's tree (has: {known})"
259 )
260 return node
263def _load_entry_point(name: str) -> Group:
264 """Load the installed `footman.tasks` entry point *name* to its Group.
266 **The only place that may call `ep.load()`.** A module imports once per
267 process, so its `@task` decorators and `GlobalOption` constructions fire
268 inside exactly one `registry.capture()` — whoever called `load()` first.
269 Every later `load()` re-resolves the cached module, runs no body, and
270 captures nothing, which is why `_module_trees` memoises the tree the one
271 real import produced. A second call site spends that import on itself and
272 leaves the next caller holding a plugin with no tasks and no options: it
273 happened, from a scan built to *describe* a plugin, and cost four tests
274 that only failed when the scan happened to run first.
276 Raises `RegistrationError` with a taught message for the failure shapes
277 that matter: claimed by two distributions, an import-time crash (a
278 missing optional dep must not dump a traceback on every `--help`), or an
279 entry point that resolves to something that isn't a Group or a module
280 of tasks.
281 """
282 from importlib.metadata import entry_points
284 matches = [ep for ep in entry_points(group=ENTRY_POINT_GROUP) if ep.name == name]
285 if len(matches) > 1:
286 dists = ", ".join(str(ep.dist) for ep in matches)
287 raise RegistrationError(
288 f"plugin {name!r}: claimed by more than one distribution ({dists})"
289 )
290 try:
291 with registry.capture() as captured:
292 loaded = matches[0].load()
293 except RegistrationError:
294 raise # already a taught message; don't re-wrap
295 except Exception as exc:
296 raise RegistrationError(
297 f"plugin {name!r}: failed to import ({type(exc).__name__}: {exc})"
298 ) from exc
299 if isinstance(loaded, Group):
300 return loaded
301 if isinstance(loaded, ModuleType):
302 module_name = loaded.__name__
303 if captured.tasks or captured.groups or any(captured.contributions.values()):
304 if not captured.help and (loaded.__doc__ or "").strip():
305 # A container describes itself through its module docstring.
306 captured.help = (loaded.__doc__ or "").strip().splitlines()[0]
307 # Memoise under the module name so re-resolving in the same
308 # process (or a later include of the same module) reuses the tree.
309 _module_trees[module_name] = captured
310 return captured
311 # Registered nothing at module level. Reuse a memoised tree if a prior
312 # resolve captured one (the entry point re-`load()`s the cached module,
313 # so decorators no longer fire and `captured` comes back empty); rebuild
314 # from the module's namespace when a bare import spent the one real
315 # import before any proper load could capture it; otherwise adopt the
316 # module's single explicit Group.
317 if module_name in _module_trees:
318 return _module_trees[module_name]
319 if (rebuilt := _reconstruct(loaded)) is not None:
320 _module_trees[module_name] = rebuilt
321 return rebuilt
322 tree = _adopt_explicit_group(loaded)
323 _module_trees[module_name] = tree
324 return tree
325 raise RegistrationError(
326 f"plugin {name!r}: entry point must resolve to a footman Group "
327 f"(or a module of tasks), got {type(loaded).__name__}"
328 )
331def _resolve_plugin(source: str) -> tuple[str, Group | Task]:
332 """Resolve *source* against the installed `footman.tasks` entry points.
334 The longest installed entry-point name that prefixes *source* is the
335 identity; the remainder walks the advertised tree. When a *shorter*
336 installed prefix would also resolve fully, both readings are named on
337 stderr — a new package must never silently re-point an existing mount.
338 """
339 from importlib.metadata import entry_points
341 installed = {ep.name: ep for ep in entry_points(group=ENTRY_POINT_GROUP)}
342 segments = source.split(".")
343 prefixes = [
344 ".".join(segments[:n])
345 for n in range(len(segments), 0, -1)
346 if ".".join(segments[:n]) in installed
347 ]
348 if not prefixes:
349 names = ", ".join(sorted(installed)) or "none"
350 raise RegistrationError(
351 f"plugin({source!r}): no {ENTRY_POINT_GROUP!r} entry point "
352 f"matches (installed: {names})"
353 )
354 identity = prefixes[0]
355 rest = segments[len(identity.split(".")) :]
356 tree = _load_entry_point(identity)
357 node = _walk_subpath(tree, rest, verb="plugin", source=source)
358 for shorter in prefixes[1:]: 358 ↛ 360line 358 didn't jump to line 360 because the loop on line 358 never started
359 # Both viable? Name the two readings; the longest wins, on purpose.
360 alt_rest = segments[len(shorter.split(".")) :]
361 try:
362 _walk_subpath(
363 _load_entry_point(shorter), alt_rest, verb="plugin", source=source
364 )
365 except RegistrationError:
366 continue
367 print(
368 f"note: plugin({source!r}) resolves via the entry point "
369 f"{identity!r}; the shorter {shorter!r} would also resolve — "
370 f"the longest prefix wins",
371 file=sys.stderr,
372 )
373 break
374 return identity, node
377def _resolve_module(source: str) -> tuple[str, Group | Task]:
378 """Resolve *source* against importable modules: the longest importable
379 prefix is imported under capture; the remainder walks the captured tree.
381 Probing runs shortest-first on purpose: `find_spec("a.b")` imports `a`
382 to locate `b`, so testing the long prefix first would import the parent
383 *outside* capture and poison the fallback. Importing each prefix under
384 capture as we walk keeps every side effect caught, and the deepest
385 module still wins.
386 """
387 segments = source.split(".")
388 for n in range(len(segments), 0, -1):
389 prefix = ".".join(segments[:n])
390 if prefix in _module_trees: # already captured this process: reuse
391 try:
392 node = _walk_subpath(
393 _module_trees[prefix], segments[n:], verb="include", source=source
394 )
395 except RegistrationError:
396 # A shallower memo can be a package the last include() walked
397 # *through* (`include("pkg.alpha")` memoises `pkg`'s empty
398 # capture), and the next submodule of that package is not in
399 # it. When the remainder still names an importable module, the
400 # memo simply isn't the answer — fall through to the import
401 # walk, which reuses the memo for the parent and imports the
402 # child. An exact-prefix miss is a real typo: keep its words.
403 if n < len(segments) and registry._importable(
404 ".".join(segments[: n + 1])
405 ):
406 break
407 raise
408 return prefix, node
409 if not (segments[0] in sys.modules or registry._importable(segments[0])):
410 raise RegistrationError(
411 f"include({source!r}): no importable module matches — "
412 f"{segments[0]!r} is not importable"
413 )
414 best = 1
415 _import_source(segments[0], allow_empty=len(segments) > 1)
416 for n in range(2, len(segments) + 1):
417 prefix = ".".join(segments[:n])
418 if not registry._importable(prefix):
419 break
420 _import_source(prefix, allow_empty=n < len(segments))
421 best = n
422 prefix = ".".join(segments[:best])
423 tree = _module_trees.get(prefix)
424 if tree is None: # pre-imported outside include(): the taught refusal 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true
425 tree = _tree_of_module(sys.modules[prefix])
426 node = _walk_subpath(tree, segments[best:], verb="include", source=source)
427 return prefix, node
430def _vivify_into(into: str | Group | None, verb: str) -> Group:
431 """The consumer-side landing group: root, a Group, or a dotted address
432 (created on demand — placement is always the consumer's)."""
433 if into is None:
434 return registry.root
435 if isinstance(into, Group):
436 return into
437 node = registry.root
438 walked: list[str] = []
439 for seg in into.split("."):
440 walked.append(seg)
441 addr = ".".join(walked)
442 if seg == "default" or not seg or any(c.isspace() for c in seg):
443 # `into=` names a *group* to graft into; `default` is task-typed
444 # by definition, and empty/whitespace segments are not addresses.
445 # For a provider's default, mount it by address instead:
446 # plugin("acme.linters.default", into="lint").
447 raise RegistrationError(
448 f"{verb}(into={into!r}): {addr!r} cannot name a group — "
449 f"'default' is a task; to adopt a provider's default, mount "
450 f'it directly: {verb}("….default", into="<group>")'
451 if seg == "default"
452 else f"{verb}(into={into!r}): {addr!r} is not a legal group address"
453 )
454 if seg in node.tasks:
455 raise RegistrationError(
456 f"{verb}(into={into!r}): {addr!r} is a task — into= "
457 f"names a group to graft into"
458 )
459 sub = node.groups.get(seg)
460 if sub is None:
461 sub = Group(seg)
462 node.groups[seg] = sub
463 node = sub
464 return node
467def _merge_group(dst: Group, src: Group, *, override: bool, at: str) -> None:
468 """The one recursive leaf merge: two mounts into one subtree compose all
469 the way down; only a same-address leaf conflicts.
471 Local-vs-imported: the local leaf silently wins, whatever the order.
472 Imported-vs-imported (a task-vs-task or type clash): loud unless
473 `override=True` — every mount is authored, so a clash is a bug with a
474 one-line fix (`exclude=`/`into=`), and loud beats silently running the
475 wrong task.
476 """
478 def clash(name: str, theirs: Task | Group) -> None:
479 addr = f"{at}.{name}" if at else name
480 raise RegistrationError(
481 f"{addr!r} claimed by both {mounted_from(theirs)!r} and "
482 f"{src.mounted_from!r} — exclude= one side, retarget with into=, "
483 f"or pass override=True to take the later mount's"
484 )
486 for name, fn in src.tasks.items():
487 existing_t = dst.tasks.get(name)
488 existing_g = dst.groups.get(name)
489 if existing_t is not None:
490 if mounted_from(existing_t) is None: # local silently wins
491 continue
492 if not override:
493 clash(name, existing_t)
494 dst.tasks[name] = fn
495 elif existing_g is not None: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true
496 if existing_g.mounted_from is None: # local group beats a mounted task
497 continue
498 if not override:
499 clash(name, existing_g)
500 del dst.groups[name]
501 dst.tasks[name] = fn
502 else:
503 dst.tasks[name] = fn
504 for name, sub in src.groups.items():
505 existing_t = dst.tasks.get(name)
506 existing_g = dst.groups.get(name)
507 if existing_t is not None: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true
508 if mounted_from(existing_t) is None:
509 continue
510 if not override:
511 clash(name, existing_t)
512 del dst.tasks[name]
513 dst.groups[name] = sub
514 elif existing_g is not None:
515 # Group-vs-group is composition, never a clash: recurse. This is
516 # what lets two mounts (or a mount and a local group) share one
517 # namespace all the way down.
518 _merge_group(
519 existing_g, sub, override=override, at=f"{at}.{name}" if at else name
520 )
521 else:
522 dst.groups[name] = sub
525def _validate_filter(node: Group, address: str, verb: str) -> None:
526 """Resolve one filter address segment-wise; every miss is taught."""
527 current = node
528 walked: list[str] = []
529 for pos, seg in enumerate(address.split(".")):
530 last = pos == len(address.split(".")) - 1
531 walked.append(seg)
532 parent = ".".join(walked[:-1]) or "the mounted node"
533 if seg in current.groups:
534 current = current.groups[seg]
535 continue
536 if seg in current.tasks:
537 if last:
538 return
539 raise RegistrationError(
540 f"{verb}(): {'.'.join(walked)!r} is a task, not a group — "
541 f"nothing lives beneath it"
542 )
543 known = ", ".join(sorted([*current.groups, *current.tasks])) or "nothing"
544 raise RegistrationError(
545 f"{verb}(): no task or group at {'.'.join(walked)!r} "
546 f"({parent} has: {known})"
547 )
550_KEEP = object() # sentinel: keep this whole subtree
553def _keep_tree(only: tuple[str, ...]) -> dict[str, Any]:
554 """The `only=` addresses as a nested keep-tree.
556 Union semantics: `only=["docs", "docs.build"]` is redundant, not an
557 error — the whole-group entry subsumes the leaf.
558 """
559 tree: dict[str, Any] = {}
560 for address in only:
561 node = tree
562 segments = address.split(".")
563 for pos, seg in enumerate(segments):
564 if node.get(seg) is _KEEP:
565 break # a whole-subtree keep already subsumes this address
566 if pos == len(segments) - 1:
567 node[seg] = _KEEP
568 else:
569 node = node.setdefault(seg, {})
570 return tree
573def _apply_only(node: Group, keep: dict[str, Any]) -> None:
574 for name in list(node.tasks):
575 if keep.get(name) is not _KEEP:
576 del node.tasks[name]
577 for name in list(node.groups):
578 wanted = keep.get(name)
579 if wanted is _KEEP:
580 continue # the whole subtree, help text and flags riding along
581 if isinstance(wanted, dict):
582 _apply_only(node.groups[name], wanted)
583 else:
584 del node.groups[name]
587def _remove(node: Group, address: str) -> None:
588 *parents, leaf = address.split(".")
589 for seg in parents:
590 sub = node.groups.get(seg)
591 if sub is None: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true
592 return # an only= filter already dropped the path: nothing left
593 node = sub
594 node.tasks.pop(leaf, None)
595 node.groups.pop(leaf, None)
598def _drop_empty(node: Group) -> None:
599 """A group pruned empty is dropped entirely, never grafted as a shell."""
600 for name, sub in list(node.groups.items()):
601 _drop_empty(sub)
602 if not sub.tasks and not sub.groups:
603 del node.groups[name]
606def _prune(
607 node: Group, only: tuple[str, ...], exclude: tuple[str, ...], verb: str
608) -> None:
609 """Apply `only=`/`exclude=` to the mounted node — full dotted addresses,
610 matched exactly (no globs: the whole-group spelling `only=["docs"]` *is*
611 the glob). Grafting a nested address materialises its path — the
612 intermediate groups are the source's own forked copies — and
613 default-ness survives only if the default survives, literally: the
614 default is the child named `default`, so `only=["lint.python"]` grafts a
615 default-less `lint`, `only=["lint.default"]` grafts *just* the default,
616 and `exclude=["lint.default"]` grafts everything but it. No pointer
617 bookkeeping — dropping the child *is* dropping default-ness.
618 """
619 for address in (*only, *exclude):
620 _validate_filter(node, address, verb)
621 if only:
622 _apply_only(node, _keep_tree(tuple(only)))
623 for address in exclude:
624 _remove(node, address)
625 _drop_empty(node)
628def _mount(
629 verb: str,
630 identity: str,
631 node: Group | Task,
632 *,
633 into: str | Group | None,
634 only: tuple[str, ...] | list[str],
635 exclude: tuple[str, ...] | list[str],
636 override: bool,
637 landing_name: str,
638) -> Group:
639 """The shared engine: fork, stamp, filter, land, merge."""
640 target = _vivify_into(into, verb)
641 if not isinstance(node, Group):
642 # A single task (`plugin("acme.linters.default", into="lint")` — the
643 # adopt-a-default one-liner). Filters filter children; a task has none.
644 if only or exclude:
645 raise RegistrationError(
646 f"{verb}(): only=/exclude= filter a group's children, and "
647 f"{landing_name!r} is a task — mount it bare"
648 )
649 setattr(node, registry._MOUNTED, identity)
650 wrapper = Group("root")
651 wrapper.tasks[landing_name] = node
652 wrapper.mounted_from = identity
653 _merge_group(target, wrapper, override=override, at="")
654 return target
656 fork = _fork(node)
657 _stamp(fork, identity)
658 _prune(fork, tuple(only), tuple(exclude), verb)
659 # A provider's lifecycle contributions act on the whole merged tree
660 # (a `@pre_tasks` hook edits the tree in place), so they belong on the live
661 # root that discovery collects from, never the grafted subtree.
662 for kind, bucket in fork.contributions.items():
663 if verb == "plugin":
664 for item in bucket:
665 # The entry-point identity, written down on every
666 # contribution — the mount always knew it, it just never
667 # said so. Options carry it for the config section
668 # derivation; hooks carry it so a plugin with no tasks at
669 # all still reports as mounted (`--plugins` read only tree
670 # provenance once, and called a riding plugin "(not
671 # mounted)"). One singleton reached through two *different*
672 # mounts has no single identity, and records that instead
673 # (config= on an option then refuses, naming the fix).
674 if kind == "globals":
675 if item._mounted is None:
676 item._mounted = identity
677 elif item._mounted != identity: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true
678 item._mounted = registry._MANY_MOUNTS
679 elif getattr(item, registry._MOUNTED, None) is None:
680 setattr(item, registry._MOUNTED, identity)
681 # By identity, not equality: forks share the provider's hook and
682 # option *objects*, so a provider mounted at two addresses arrives
683 # here twice with the same items — and a second registration would
684 # run its lifecycle twice per run, silently, side effects and all.
685 # The tree mounts twice; the contribution contributes once.
686 existing = registry.root.contributions[kind]
687 for item in bucket:
688 if not any(item is have for have in existing):
689 existing.append(item)
690 bucket.clear()
691 if fork.name == "root":
692 # An anonymous container (a module capture's root): mounting it lands
693 # its *children* — the splat, `import *` for task trees. A devkit
694 # update that adds a group just appears on the next mount.
695 _merge_group(target, fork, override=override, at="")
696 return target
697 # A named node lands under its own name; identity never becomes an
698 # address. Compose with an existing group of that name rather than
699 # clobbering it — an existing task of that name is the usual leaf clash.
700 wrapper = Group("root")
701 wrapper.groups[fork.name] = fork
702 wrapper.mounted_from = identity
703 _merge_group(target, wrapper, override=override, at="")
704 return target
707def plugin(
708 source: str,
709 /,
710 *,
711 into: str | Group | None = None,
712 only: tuple[str, ...] | list[str] = (),
713 exclude: tuple[str, ...] | list[str] = (),
714 override: bool = False,
715) -> Group:
716 """Mount a task tree from an installed package's `footman.tasks` entry
717 point — **entry points only**; for your own modules use `include()`.
719 ```python
720 plugin("acme.devkit") # the import *: every group, top level
721 plugin("acme.devkit.lint") # from acme_devkit import lint
722 plugin("acme.ci", exclude=["deploy"]) # everything but one child
723 plugin("footman.docs", into="footman") # built-ins are ordinary plugins
724 plugin("acme.linters.default", into="lint") # adopt a provider's default
725 ```
727 The longest installed entry-point name is the *identity* (consumed at
728 resolve time, retained as provenance); the rest of the string walks the
729 advertised tree. The mounted node lands under its **own name** — `into=`
730 (a dotted address, created on demand) is the consumer's placement, and
731 there is no rename. Returns the group grafted into.
732 """
733 identity, node = _resolve_plugin(source)
734 return _mount(
735 "plugin",
736 identity,
737 node,
738 into=into,
739 only=only,
740 exclude=exclude,
741 override=override,
742 landing_name=source.rsplit(".", 1)[-1],
743 )
746def include(
747 source: str | ModuleType | Group,
748 /,
749 *,
750 into: str | Group | None = None,
751 only: tuple[str, ...] | list[str] = (),
752 exclude: tuple[str, ...] | list[str] = (),
753 override: bool = False,
754) -> Group:
755 """Mount a task tree from an importable module — **modules only**; for an
756 installed package's advertised tasks use `plugin()`.
758 ```python
759 include("shared_tasks") # everything, at root
760 include("shared_tasks", only=["lint"]) # cherry-pick a child
761 include("mytasks.lint") # one group out of a module
762 include("mkdocs_helpers.tasks", into="docs") # namespaced: fm docs.…
763 ```
765 The longest importable prefix is imported under a registry capture (the
766 provider's decorators can't leak into your tree); the rest of the string
767 walks the captured tree. Collisions are loud (`RegistrationError`)
768 unless `override=True`; your own definitions silently win. Included
769 tasks run from *your* file's directory — a shared lint task lints this
770 project. A `Group` or imported module may be passed programmatically
771 (tests, generated trees). Returns the group grafted into.
772 """
773 node: Group | Task
774 if isinstance(source, Group):
775 identity, node = source.name, source
776 landing = source.name
777 elif isinstance(source, ModuleType):
778 identity, node = source.__name__, _tree_of_module(source)
779 landing = source.__name__
780 else:
781 identity, node = _resolve_module(source)
782 landing = source.rsplit(".", 1)[-1]
783 return _mount(
784 "include",
785 identity,
786 node,
787 into=into,
788 only=only,
789 exclude=exclude,
790 override=override,
791 landing_name=landing,
792 )