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

1"""Load the task cascade and merge it into one command tree. 

2 

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. 

9 

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""" 

14 

15from __future__ import annotations 

16 

17import importlib.util 

18import sys 

19from pathlib import Path 

20 

21from footman import registry 

22from footman.invocation import Invocation 

23from footman.registry import Group, Task 

24 

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" 

29 

30 

31class TasksImportError(Exception): 

32 """A tasks file failed to import; names the file and keeps the cause.""" 

33 

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) 

49 

50 

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.""" 

54 

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 

60 

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}") 

71 

72 

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 

103 

104 

105def _evict_siblings(before: set[str], parent: Path) -> None: 

106 """Drop modules a cascade file imported that live directly in its dir. 

107 

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] 

132 

133 

134class AliasError(Exception): 

135 """One function reached from two addresses that disagree about its folder.""" 

136 

137 

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. 

142 

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 ) 

167 

168 

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. 

173 

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. 

181 

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 

194 

195 

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}.") 

205 

206 

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 

234 

235 

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. 

240 

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 

280 

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 

293 

294 

295def untag(group: Group) -> None: 

296 """Drop any defining-directory stamp from *group*'s tasks, recursively. 

297 

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`. 

301 

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) 

315 

316 

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) 

320 

321 

322def shadowed(fn: Task) -> Task | None: 

323 """The task *fn* overrides — the same name, one cascade level up.""" 

324 return getattr(fn, SHADOWED, None) 

325 

326 

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