Coverage for src/footman/compose.py: 97%
124 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Compose the task surface: adopt tasks from other modules and packages.
3Two public pieces, designed to be used together from a tasks file:
5- `include(source, ...)` grafts another module's task tree into yours —
6 cherry-picked, namespaced under a group, loud on collisions.
7- `plugin(name)` resolves a `footman.tasks` entry point published by an
8 installed package to its advertised `Group`, ready to `include()`.
10Config-mounted plugins (`[tool.footman] plugins = ["name"]`) use the same
11resolution but mount the group *under* the tasks-file cascade, so any name a
12user defines shadows a plugin's. One rule of thumb: *config mounts a tool;
13tasks.py adopts a task.*
15Everything resolves at import/manifest-build time; the completion hot path is
16untouched. `importlib.metadata` is stdlib — footman stays zero-dependency.
17"""
19from __future__ import annotations
21import importlib
22from types import ModuleType
24from footman import registry
25from footman.registry import Group, RegistrationError
27ENTRY_POINT_GROUP = "footman.tasks"
29# One import (and capture) per provider module per process: every cascade
30# file that includes the same module gets the same tree, whatever the import
31# order — the `sys.modules` cache can't half-register a provider.
32_module_trees: dict[str, Group] = {}
35def _tree_of_module(module: ModuleType) -> Group:
36 """The task tree of an already-imported module: the memo, or a taught no.
38 A module imported *outside* `include()` already ran its decorators
39 against whatever registry was live — its tree cannot be reconstructed,
40 and re-executing the module would double every side effect. The answer
41 is guidance, not guesswork.
42 """
43 name = module.__name__
44 if name in _module_trees: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 return _module_trees[name]
46 raise RegistrationError(
47 f"include({name!r}): the module was already imported outside "
48 f"include(), so its tasks were never captured — call include() "
49 f"before anything else imports it, or have the module expose an "
50 f"explicit Group and pass that instead"
51 )
54def _adopt_explicit_group(module: ModuleType) -> Group:
55 """A never-registering provider's single module-level `Group`, if any."""
56 groups = [v for v in vars(module).values() if isinstance(v, Group)]
57 if len(groups) == 1:
58 return groups[0]
59 detail = "no module-level Group" if not groups else f"{len(groups)} Groups"
60 raise RegistrationError(
61 f"include({module.__name__!r}): the module registered no tasks and "
62 f"has {detail} to adopt — define tasks with @task/group(), or expose "
63 f"exactly one Group"
64 )
67def _import_source(dotted: str) -> Group:
68 """Import *dotted* under `capture()` and memoise its captured tree."""
69 if dotted in _module_trees:
70 return _module_trees[dotted]
71 import sys
73 if dotted in sys.modules: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 return _tree_of_module(sys.modules[dotted])
75 with registry.capture() as captured:
76 try:
77 module = importlib.import_module(dotted)
78 except ImportError as exc:
79 # A missing module (a typo, a module not on the path, a missing
80 # dependency) otherwise surfaces as "failed to import <tasks.py>",
81 # blaming the file and never naming the include() call that broke.
82 # Name it, and the reason — the same taught shape plugin() gives. A
83 # RegistrationError from the provider's own body isn't an
84 # ImportError, so it propagates already-taught, untouched.
85 raise RegistrationError(
86 f"include({dotted!r}): failed to import ({type(exc).__name__}: {exc})"
87 ) from exc
88 tree = (
89 captured
90 if (captured.tasks or captured.groups)
91 # Nothing registered at module level: the provider keeps an explicit
92 # Group instead (the entry-point convention) — unambiguous only
93 # because the capture came back empty.
94 else _adopt_explicit_group(module)
95 )
96 _module_trees[dotted] = tree
97 return tree
100def _as_group(source: str | ModuleType | Group) -> Group:
101 if isinstance(source, Group):
102 return source
103 if isinstance(source, ModuleType):
104 return _tree_of_module(source)
105 return _import_source(source)
108def _fork(tree: Group) -> Group:
109 """A structural copy of *tree*: fresh Group objects and dicts, shared fns.
111 A memoised provider tree grafted into a project is later mutated by the
112 cascade overlay/tag in place — without a fork, one project's tasks (and
113 DEFINING_DIR stamps) leak into the shared `_module_trees` memo and thus into
114 every later in-process invocation (F38). The task callables stay shared on
115 purpose: DEFINING_DIR is re-stamped on each load, so sharing them is safe
116 and keeps `recording()`/identity checks meaningful.
117 """
118 fork = Group(tree.name, tree.help)
119 fork.tasks.update(tree.tasks) # share fns, but into a fresh dict
120 for name, sub in tree.groups.items():
121 fork.groups[name] = _fork(sub) # recurse: fresh subgroup objects
122 # A faithful copy carries *every* Group field, not only tasks/groups: a
123 # runnable group keeps its `@group.default` (so the bare-group grammar and
124 # its options survive the graft), and a provider's `@finalize` hooks ride
125 # along. The default action stays the shared fn — like the task fns, it is
126 # re-stamped per load, and an empty-body default fans out its group's own
127 # (equally shared) tasks. `test_compose`'s field census fails the moment a
128 # new Group field is added but not copied here.
129 fork.default_task = tree.default_task
130 fork.finalizers = list(tree.finalizers)
131 return fork
134def include(
135 source: str | ModuleType | Group,
136 /,
137 *,
138 into: Group | None = None,
139 only: tuple[str, ...] | list[str] = (),
140 exclude: tuple[str, ...] | list[str] = (),
141 override: bool = False,
142) -> Group:
143 """Graft another module's tasks into the current tree (or *into* a group).
145 ```python
146 include("shared_tasks") # everything, at root
147 include("shared_tasks", only=["lint", "fmt"]) # cherry-pick by CLI name
148 include("mkdocs_helpers.tasks", into=docs) # namespace: fm docs …
149 include(plugin("mkdocs"), only=["build"]) # from an entry point
150 ```
152 *source* is a dotted module name, an imported module, or a `Group`. The
153 provider imports under a registry capture, so its decorators can't leak
154 into your tree. Collisions are loud (`RegistrationError`) unless
155 `override=True`; unknown `only=`/`exclude=` names are errors too (typo
156 protection). Included tasks run from *your* file's directory — a shared
157 lint task lints this project. Returns the group it grafted into.
158 """
159 tree = _fork(_as_group(source)) # graft a private copy; never the memo
160 target = into if into is not None else registry.root
162 known = set(tree.tasks) | set(tree.groups)
163 for name in (*only, *exclude):
164 if name not in known:
165 raise RegistrationError(
166 f"include(): {source!r} has no task or group named {name!r} "
167 f"(has: {', '.join(sorted(known)) or 'nothing'})"
168 )
169 wanted = set(only) if only else known
170 wanted -= set(exclude)
172 for name, fn in tree.tasks.items():
173 if name not in wanted:
174 continue
175 if not override:
176 target._claim(name)
177 target.groups.pop(name, None)
178 target.tasks[name] = fn
179 for name, sub in tree.groups.items():
180 if name not in wanted:
181 continue
182 if not override: 182 ↛ 184line 182 didn't jump to line 184 because the condition on line 182 was always true
183 target._claim(name)
184 target.tasks.pop(name, None)
185 target.groups[name] = sub
186 # A provider's `@finalize` hooks edit the whole merged tree, so they belong
187 # on the live root that discovery collects from — grafting only moved tasks
188 # and groups, and a finalizer left on the forked subtree would never run.
189 registry.root.finalizers.extend(tree.finalizers)
190 return target
193def plugin(name: str) -> Group:
194 """The `Group` a package advertises under the `footman.tasks` entry point.
196 ```toml
197 # the plugin package's pyproject.toml
198 [project.entry-points."footman.tasks"]
199 mkdocs = "footman_mkdocs:tasks"
200 ```
202 Raises `RegistrationError` naming the installed entry points when *name*
203 isn't one of them — a configured-but-missing plugin should read as the
204 typo or missing install it is.
205 """
206 from importlib.metadata import entry_points
208 found = entry_points(group=ENTRY_POINT_GROUP)
209 matches = [ep for ep in found if ep.name == name]
210 if not matches:
211 installed = ", ".join(sorted(ep.name for ep in found)) or "none"
212 raise RegistrationError(
213 f"plugin {name!r}: no {ENTRY_POINT_GROUP!r} entry point found "
214 f"(installed: {installed})"
215 )
216 if len(matches) > 1:
217 dists = ", ".join(str(ep.dist) for ep in matches)
218 raise RegistrationError(
219 f"plugin {name!r}: claimed by more than one distribution ({dists})"
220 )
221 try:
222 with registry.capture() as captured:
223 loaded = matches[0].load()
224 except RegistrationError:
225 raise # already a taught message; don't re-wrap
226 except Exception as exc:
227 # A plugin with a missing optional dep (or any import-time failure)
228 # would otherwise dump a raw traceback on *every* invocation, `--help`
229 # included. Teach it; the mount guard reports it at exit 2.
230 raise RegistrationError(
231 f"plugin {name!r}: failed to import ({type(exc).__name__}: {exc})"
232 ) from exc
233 if isinstance(loaded, Group):
234 return loaded
235 if isinstance(loaded, ModuleType):
236 name = loaded.__name__
237 if captured.tasks or captured.groups:
238 # Memoise under the module name so re-resolving in the same
239 # process (or a later include of the same module) reuses the tree.
240 _module_trees[name] = captured
241 return captured
242 # Registered nothing at module level. Reuse a memoised tree if a prior
243 # resolve captured one (the entry point re-`load()`s the cached module,
244 # so decorators no longer fire and `captured` comes back empty);
245 # otherwise adopt the module's single explicit Group. Routing through
246 # _import_source would hit sys.modules — the entry point just loaded the
247 # module — and raise the misleading "already imported outside include()"
248 # error.
249 if name in _module_trees:
250 return _module_trees[name]
251 tree = _adopt_explicit_group(loaded)
252 _module_trees[name] = tree
253 return tree
254 raise RegistrationError(
255 f"plugin {name!r}: entry point must resolve to a footman Group "
256 f"(or a module of tasks), got {type(loaded).__name__}"
257 )
260def mount_plugins(base: Group, names: list[str]) -> None:
261 """Mount config-listed plugins at the command path each name spells.
263 A plugin's name *is* its command path. A bare name mounts at the root
264 (`plugins = ["acme"]` → `fm acme …`); a dotted name nests, one group per
265 segment (`plugins = ["footman.tools"]` → `fm footman tools …`). The last
266 segment names the entry point to resolve; the leading segments are
267 namespace groups, created on demand and shared by every plugin that
268 spells the same prefix — so `footman.docs` and `footman.tools` meet under
269 one `footman` group without either owning it.
271 Called by the app layer *before* the cascade overlays, so user-defined
272 names shadow plugin groups silently — consistent with the cascade's own
273 nearest-wins rule.
274 """
275 for raw in names:
276 dotted = str(raw)
277 tree = _fork(plugin(dotted)) # graft a private copy; never the memo
278 *parents, leaf = dotted.split(".")
279 target = base
280 for segment in parents:
281 target = _namespace(target, segment)
282 target.tasks.pop(leaf, None)
283 target.groups[leaf] = tree if tree.name == leaf else _named(tree, leaf)
286def _namespace(parent: Group, name: str) -> Group:
287 """The subgroup *name* of *parent*, reused if present or created if not.
289 Each leading segment of a dotted plugin name is a namespace group; two
290 plugins that share a prefix (`footman.docs`, `footman.tools`) share the
291 group rather than fight over it. A task of the same name yields — a group
292 has to sit there for anything to nest beneath it.
293 """
294 existing = parent.groups.get(name)
295 if isinstance(existing, Group):
296 return existing
297 parent.tasks.pop(name, None)
298 created = Group(name)
299 parent.groups[name] = created
300 return created
303def _named(tree: Group, name: str) -> Group:
304 """Re-home a captured root tree under a named group."""
305 named = Group(name, tree.help)
306 named.tasks.update(tree.tasks)
307 named.groups.update(tree.groups)
308 return named