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

1"""Compose the task surface: two typed verbs over one engine. 

2 

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. 

12 

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. 

19 

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. 

25 

26Everything resolves at import/manifest-build time; the completion hot path 

27is untouched. `importlib.metadata` is stdlib — footman stays zero-dependency. 

28""" 

29 

30from __future__ import annotations 

31 

32import importlib 

33import sys 

34from types import ModuleType 

35from typing import Any 

36 

37from footman import registry 

38from footman.registry import Group, RegistrationError, Task, mounted_from 

39 

40ENTRY_POINT_GROUP = "footman.tasks" 

41 

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] = {} 

46 

47 

48def _declares_tasks(module: ModuleType) -> bool: 

49 """Whether *module* holds anything whose registration cannot be rebuilt. 

50 

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 ) 

61 

62 

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. 

66 

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. 

72 

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) 

101 

102 

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. 

106 

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 

141 

142 

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 ) 

154 

155 

156def _import_source(dotted: str, *, allow_empty: bool = False) -> Group: 

157 """Import *dotted* under `capture()` and memoise its captured tree. 

158 

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] 

166 

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 

200 

201 

202def _fork(tree: Group) -> Group: 

203 """A structural copy of *tree*: fresh Group objects and dicts, shared fns. 

204 

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 

226 

227 

228def _stamp(node: Group, identity: str) -> None: 

229 """Record *identity* as provenance on every node of a mounted tree. 

230 

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) 

240 

241 

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 

261 

262 

263def _load_entry_point(name: str) -> Group: 

264 """Load the installed `footman.tasks` entry point *name* to its Group. 

265 

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. 

275 

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 

283 

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 ) 

329 

330 

331def _resolve_plugin(source: str) -> tuple[str, Group | Task]: 

332 """Resolve *source* against the installed `footman.tasks` entry points. 

333 

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 

340 

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 

375 

376 

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. 

380 

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 

428 

429 

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 

465 

466 

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. 

470 

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

477 

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 ) 

485 

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 

523 

524 

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 ) 

548 

549 

550_KEEP = object() # sentinel: keep this whole subtree 

551 

552 

553def _keep_tree(only: tuple[str, ...]) -> dict[str, Any]: 

554 """The `only=` addresses as a nested keep-tree. 

555 

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 

571 

572 

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] 

585 

586 

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) 

596 

597 

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] 

604 

605 

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) 

626 

627 

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 

655 

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 

705 

706 

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

718 

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

726 

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 ) 

744 

745 

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

757 

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

764 

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 )