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

1"""Compose the task surface: adopt tasks from other modules and packages. 

2 

3Two public pieces, designed to be used together from a tasks file: 

4 

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

9 

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

14 

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

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

17""" 

18 

19from __future__ import annotations 

20 

21import importlib 

22from types import ModuleType 

23 

24from footman import registry 

25from footman.registry import Group, RegistrationError 

26 

27ENTRY_POINT_GROUP = "footman.tasks" 

28 

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

33 

34 

35def _tree_of_module(module: ModuleType) -> Group: 

36 """The task tree of an already-imported module: the memo, or a taught no. 

37 

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 ) 

52 

53 

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 ) 

65 

66 

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 

72 

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 

98 

99 

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) 

106 

107 

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

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

110 

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 

132 

133 

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

144 

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

151 

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 

161 

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) 

171 

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 

191 

192 

193def plugin(name: str) -> Group: 

194 """The `Group` a package advertises under the `footman.tasks` entry point. 

195 

196 ```toml 

197 # the plugin package's pyproject.toml 

198 [project.entry-points."footman.tasks"] 

199 mkdocs = "footman_mkdocs:tasks" 

200 ``` 

201 

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 

207 

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 ) 

258 

259 

260def mount_plugins(base: Group, names: list[str]) -> None: 

261 """Mount config-listed plugins at the command path each name spells. 

262 

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. 

270 

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) 

284 

285 

286def _namespace(parent: Group, name: str) -> Group: 

287 """The subgroup *name* of *parent*, reused if present or created if not. 

288 

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 

301 

302 

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