Coverage for src/footman/_discover.py: 99%
134 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"""Load the task cascade and merge it into one command tree.
3In a monorepo you rarely want a single tasks file. footman collects every
4`tasks.py` from the repo root down to your current directory and merges them
5top-down: a new name **appends**, a name already present **overrides** (the
6folder nearest your cwd wins), and a command group present at both levels
7**merges** (its tasks overlaid the same way). Each task remembers the folder of
8the file that defined it, so it runs from there regardless of where you stand.
10The registry raises on a duplicate name, so the merge can't be done by importing
11every file into one registry — each file is imported into a fresh registry and
12the resulting trees are overlaid here.
13"""
15from __future__ import annotations
17import importlib.util
18import sys
19from pathlib import Path
21from footman import registry
22from footman.invocation import Invocation
23from footman.registry import Group, Task
25# Attribute stamped on every task fn: the directory of the file that defined
26# it. The scheduler uses it as the task's working directory.
27DEFINING_DIR = "_footman_dir"
28SHADOWED = "_footman_shadowed"
31class TasksImportError(Exception):
32 """A tasks file failed to import; names the file and keeps the cause."""
34 def __init__(self, path: Path, original: BaseException) -> None:
35 self.path = path
36 self.original = original
37 if isinstance(original, SystemExit):
38 # `sys.exit()` at import time would otherwise propagate its raw
39 # code with no words at all — a silent death wearing footman's
40 # exit status.
41 message = (
42 f"{path}: calls sys.exit({original.code!r}) at import time — "
43 f"a task file is imported to list its tasks, so the exit "
44 f"belongs inside a task body"
45 )
46 else:
47 message = f"{path}: {type(original).__name__}: {original}"
48 super().__init__(message)
51class HookError(Exception):
52 """A lifecycle hook raised; the message names the hook, never a bare
53 traceback. A deliberate stop (`fail("…")`) renders as its reason."""
55 def __init__(self, kind: str, name: str, original: BaseException) -> None:
56 self.kind = kind
57 self.name = name
58 self.original = original
59 from footman import context
61 if context._is_deliberate_stop(original):
62 # A hook that *chose* to stop is talking to the user, and its
63 # reason is the whole message — naming the hook in front of it
64 # leaks machinery into a sentence someone wrote for a person
65 # ("@pre_tasks 'load': --env-file: … does not exist"). A hook
66 # that *crashed* still gets named, because then the machinery
67 # is exactly what a reader needs.
68 super().__init__(str(original) or type(original).__name__)
69 return
70 super().__init__(f"@{kind} {name!r}: {type(original).__name__}: {original}")
73def _import_file(path: Path, index: int) -> Group:
74 """Import *path* into a fresh registry and return the populated tree."""
75 registry.reset()
76 spec = importlib.util.spec_from_file_location(f"footman_tasks_{index}", path)
77 if spec is None or spec.loader is None: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 raise TasksImportError(path, ImportError("cannot load tasks file"))
79 module = importlib.util.module_from_spec(spec)
80 sys.modules[spec.name] = module
81 parent = str(path.parent)
82 # Search this file's own dir first for sibling helpers (move-to-front, not
83 # insert-if-absent — a shared dir on sys.path must not shadow it), snapshot
84 # sys.path/sys.modules, and evict the direct siblings it imports afterwards.
85 # Otherwise two cascade files each doing `import helpers` share whoever
86 # imported first (F14/D8). Restoring sys.path also stops it accumulating
87 # across the many load_tree calls an in-process runner makes.
88 saved_path = sys.path[:]
89 before = set(sys.modules)
90 if parent in sys.path:
91 sys.path.remove(parent)
92 sys.path.insert(0, parent)
93 try:
94 spec.loader.exec_module(module)
95 except SystemExit as exc:
96 raise TasksImportError(path, exc) from exc
97 except Exception as exc:
98 raise TasksImportError(path, exc) from exc
99 finally:
100 sys.path[:] = saved_path
101 _evict_siblings(before, Path(parent))
102 return registry.root
105def _evict_siblings(before: set[str], parent: Path) -> None:
106 """Drop modules a cascade file imported that live directly in its dir.
108 A sibling `helpers.py` (`parent/helpers.py`) or a package one level down
109 (`parent/pkg/__init__.py`) — so the next file gets its own copy rather than
110 whoever-imported-first-wins. Deeper imports and editable-installed packages
111 live elsewhere on disk and are deliberately left (D8).
112 """
113 evicted: set[str] = set()
114 for name in set(sys.modules) - before:
115 file = getattr(sys.modules.get(name), "__file__", None)
116 if file is None:
117 continue
118 f = Path(file)
119 sibling = f.parent == parent
120 package = f.name == "__init__.py" and f.parent.parent == parent
121 if sibling or package:
122 evicted.add(name)
123 # A package leaves with its whole subtree: dropping `pkg` alone left
124 # `pkg.sub` in sys.modules, and the next cascade file's `import pkg.sub`
125 # re-imported `pkg` fresh from its own directory and then took the
126 # *stale* submodule straight out of sys.modules — the previous file's
127 # copy, wearing the new package. Name-prefixed, because a submodule's
128 # file lives a level too deep for the directory checks above to see.
129 for name in list(sys.modules):
130 if name in evicted or any(name.startswith(p + ".") for p in evicted):
131 del sys.modules[name]
134class AliasError(Exception):
135 """One function reached from two addresses that disagree about its folder."""
138def _alias_error(
139 path: Path, address: str, directory: str, seen_address: str, seen_directory: str
140) -> TasksImportError:
141 """The refusal for one task mounted twice with two different folders.
143 It names the file being loaded *now* — the last one in cascade order, and
144 the nearer one to the user. That is deliberately not symmetric: the two
145 mounts are equally "the other one", but the farther mount is the shared
146 one, serving every sibling directory, so dropping it is the change with
147 reach. The local addition is the one to take back.
148 """
149 task = address.rpartition(".")[2]
150 # A RegistrationError, not a bare exception: `_app` renders that shape as
151 # "a user mistake, not a crash" and prefixes it with this file, so the
152 # message does not repeat the path it is already shown under.
153 #
154 # The hint answers the goal somebody probably had, not the collision --
155 # `asinvoked` never resolves it, and joining the two with "or" would read
156 # as though it did.
157 return TasksImportError(
158 path,
159 registry.RegistrationError(
160 f"{task!r} is already mounted as {seen_address!r} by "
161 f"{seen_directory} — one task cannot have two defining "
162 f"directories. Drop this mount.\n If you mounted it here to run "
163 f'it in this directory, set cwd="asinvoked" on {seen_address!r} '
164 f"instead."
165 ),
166 )
169def _claim(
170 stamps: dict[int, dict[str, str]], fn: Task, address: str, directory: str
171) -> None:
172 """Record that *address* stamps *fn* with *directory*, or refuse.
174 A task runs in the directory of the tasks file that defined it, and that
175 is stored on the function — so one function can only ever answer with one
176 folder. A cascade may stamp the same function more than once and be
177 perfectly well formed: a nearer file *shadowing* a farther one reuses the
178 same address, and two providers that both include a common helper land it
179 at two addresses with the *same* folder. Neither is a conflict, so the
180 claim is keyed by address and compared by folder.
182 What cannot work is one function at two addresses whose folders differ:
183 whichever stamp lands last wins, and the other address then silently runs
184 somewhere its own tasks file never named. The refusal is not conditional
185 on the task's `cwd` policy, deliberately — `cwd` is a default, and
186 `.opts(cwd=…)` re-resolves it per use, so a task that looks immune today
187 is one call site away from caring.
188 """
189 claims = stamps.setdefault(id(fn), {})
190 for seen_address, seen_directory in claims.items():
191 if seen_address != address and seen_directory != directory:
192 raise AliasError(address, directory, seen_address, seen_directory)
193 claims[address] = directory
196def _tag(
197 group: Group, directory: str, stamps: dict[int, dict[str, str]], prefix: str = ""
198) -> None:
199 """Stamp every task in *group* (recursively) with its defining directory."""
200 for name, fn in group.tasks.items():
201 _claim(stamps, fn, f"{prefix}{name}", directory)
202 setattr(fn, DEFINING_DIR, directory)
203 for name, sub in group.groups.items():
204 _tag(sub, directory, stamps, f"{prefix}{name}.")
207def _overlay(
208 base: Group,
209 overlay: Group,
210 directory: str,
211 stamps: dict[int, dict[str, str]],
212 prefix: str = "",
213) -> None:
214 """Merge *overlay* onto *base* in place: local (overlay) wins by name."""
215 for name, fn in overlay.tasks.items():
216 _claim(stamps, fn, f"{prefix}{name}", directory)
217 setattr(fn, DEFINING_DIR, directory)
218 # Keep the task this one shadows reachable: `inherited()` calls it,
219 # `--where` lists it, `--help` shows its options. Without this the
220 # parent's function is simply dropped and a leaf can only
221 # *replace* the root's task, never extend it.
222 if (previous := base.tasks.get(name)) is not None and previous is not fn:
223 setattr(fn, SHADOWED, previous)
224 base.groups.pop(name, None) # a local task shadows an inherited group
225 base.tasks[name] = fn
226 for name, sub in overlay.groups.items():
227 if name in base.groups:
228 base.groups[name].help = sub.help or base.groups[name].help
229 _overlay(base.groups[name], sub, directory, stamps, f"{prefix}{name}.")
230 else:
231 base.tasks.pop(name, None) # a local group shadows an inherited task
232 _tag(sub, directory, stamps, f"{prefix}{name}.")
233 base.groups[name] = sub
236def load_tree(
237 files: list[Path], base: Group | None = None, inv: Invocation | None = None
238) -> Group:
239 """Import each file (root first) and overlay them into one merged tree.
241 *base* seeds the tree (config-mounted plugin groups go there), so
242 anything a tasks file defines overlays it — user names win over plugins
243 exactly as nearer cascade files win over farther ones.
244 """
245 merged = base if base is not None else Group("root")
246 # Address -> folder, per function, for THIS load only. Not an attribute:
247 # the same function legitimately gets a fresh stamp on every load (a new
248 # process, the refresh child, a second `Runner` invocation), and only a
249 # disagreement *within one cascade* is the contradiction.
250 stamps: dict[int, dict[str, str]] = {}
251 try:
252 for index, path in enumerate(files):
253 tree = _import_file(path, index)
254 try:
255 _overlay(merged, tree, str(path.parent), stamps)
256 except AliasError as exc:
257 raise _alias_error(path, *exc.args) from None
258 # Collect each file's lifecycle contributions in cascade order,
259 # before the next _import_file resets the registry. They live on
260 # the merged tree from here on, so the run can fire the per-task
261 # moments later without re-walking the cascade.
262 for kind, bucket in tree.contributions.items():
263 merged.contributions[kind].extend(bucket)
264 finally:
265 # Leave no global state behind — even when a file registered some tasks
266 # and then raised, which would otherwise strand ghost tasks in
267 # registry.root for the rest of the process (F62).
268 registry.reset()
269 # The first lifecycle moment, run here so *every* path gets it: a real
270 # invocation, the completion refresh child, and `_suggest`. Cascade order
271 # (root first, the folder nearest cwd last), each hook seeing the previous
272 # edits — so a subfolder refines what root did. Discovery-time, before
273 # availability gates and the manifest, so an edit reaches both.
274 if inv is None:
275 # No invocation supplied means no command line to read — the refresh
276 # child's situation, and the reason a tree edit may not depend on one.
277 inv = Invocation(cwd=str(Path.cwd()))
278 inv.tasks = registry.Tasks(merged)
279 from footman._executor import wide_moment
281 with wide_moment("pre_tasks"):
282 for run in merged.contributions["pre_tasks"]:
283 try:
284 run(inv)
285 except Exception as exc: # a bad hook names itself, never a traceback
286 raise HookError(
287 "pre_tasks", getattr(run, "__name__", repr(run)), exc
288 ) from exc
289 # The single-threaded moment is over: every later reader — a per-task hook
290 # on a pool thread, a body call — sees the same invocation, unwritable.
291 inv.freeze()
292 return merged
295def untag(group: Group) -> None:
296 """Drop any defining-directory stamp from *group*'s tasks, recursively.
298 A built-in base task was not defined by a tasks file, so it has no folder
299 to name and must answer `None`, letting the cwd ladder fall through to
300 where `fm` was invoked — which is the whole point of `fm new`.
302 It has to be cleared rather than simply left, because the stamp lives on
303 the *function*, and a built-in is the same object every time it is
304 mounted. An earlier in-process invocation that mounted the same provider
305 from inside a project stamps it with that project; the base is built only
306 when discovery found no task files, so `load_tree(files=[], base=base)`
307 never overlays and nothing overwrites it. `fm new` in an empty directory
308 then refuses, believing it is somewhere it is not.
309 """
310 for fn in group.tasks.values():
311 if hasattr(fn, DEFINING_DIR):
312 delattr(fn, DEFINING_DIR)
313 for sub in group.groups.values():
314 untag(sub)
317def defining_dir(fn: Task) -> str | None:
318 """The folder the task was defined in, if the cascade tagged it."""
319 return getattr(fn, DEFINING_DIR, None)
322def shadowed(fn: Task) -> Task | None:
323 """The task *fn* overrides — the same name, one cascade level up."""
324 return getattr(fn, SHADOWED, None)
327def shadow_chain(fn: Task) -> list[Task]:
328 """*fn* and every task it shadows, nearest first."""
329 chain = [fn]
330 while (previous := shadowed(chain[-1])) is not None:
331 chain.append(previous)
332 return chain