Coverage for src/footman/_refresh.py: 90%

107 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Background completion-manifest refresh — the detached rebuild child. 

2 

3The completion hot path spawns this detached, two ways: 

4 

5- `_maybe_refresh` → `refresh_cwd` is the stale-while-revalidate path: when a 

6 cwd's cached manifest is older than its baked `completion.max_age`, rebuild the 

7 *cwd cascade's* manifest so dynamic completers (git branches, file lists) don't 

8 go stale for "time since your last real `fm` command here". 

9- `_cold_build` → `refresh_source` builds a single `-f <file>`'s (cwd, file) 

10 manifest the first time it is TAB-completed in a fresh directory, so `fm -f 

11 <file> <TAB>` answers accurately instead of empty. 

12 

13Both rebuild exactly as a real run would and are strictly fire-and-forget: they 

14print nothing and never raise. 

15 

16A tasks file that carries its own PEP 723 dependencies is rebuilt from inside 

17its script environment — but only when uv can reach one without the network, 

18since a keystroke must never download anything. Otherwise the rebuild happens 

19here, in place, exactly as it always did: often enough to answer, because a 

20tasks file's *module-level* imports are usually just the runner. 

21""" 

22 

23from __future__ import annotations 

24 

25import contextlib 

26from typing import TYPE_CHECKING 

27 

28if TYPE_CHECKING: # runtime imports stay deferred: this child spawns cheap 

29 from pathlib import Path 

30 

31 

32def _maybe_reexec(files: list[Path], entry: str, *args: str) -> None: 

33 """Continue this rebuild inside a script file's own environment — the 

34 rule lives in `_script.maybe_reexec`, shared with the suggest child. 

35 The re-executed child runs the same one-liner *entry* under the same 

36 interpreter flags, so the two spawn shapes stay identical apart from the 

37 interpreter itself — `-P` included, or the re-exec would reopen the hole 

38 the spawn closed and let a planted `footman.py` win the import.""" 

39 from footman import _script 

40 

41 _script.maybe_reexec(files, ["-P", "-c", entry, *args]) 

42 

43 

44def _project_reexec(cwd: Path, uv_wanted: bool, entry: str, *args: str) -> Path | None: 

45 """The lock rule's half of the children's world rule, asked first — 

46 exactly as the run path asks it (`_app._uv_handoff`): a pinned project 

47 owns this directory, so the manifest must be built by *its* interpreter 

48 from *its* packages, whichever runner answered the TAB. Heals a stale 

49 venv offline on the way, unless the project opted out of uv (the run's 

50 own retry syncs for real). Returns the project root when one owns the 

51 directory — the script rule then stays out of the way, as it does on 

52 the run path — else None. 

53 """ 

54 from footman import _script 

55 

56 root = _script.project_home(cwd) 

57 if root is None: 

58 return None 

59 _script.project_reexec(root, ["-P", "-c", entry, *args], heal=uv_wanted) 

60 return root 

61 

62 

63def refresh_cwd(*where: str) -> None: 

64 """Rebuild the current directory's completion manifest, swallowing errors. 

65 

66 The location words come from the parent (`_paths.child_args`): this child 

67 inherits the environment but not the brand, so it is *told* where the 

68 cache is rather than re-deriving it. Taken as `*where` rather than named 

69 parameters so the two sides cannot disagree about arity — this call is 

70 inside `suppress`, where a `TypeError` would show up as a manifest that 

71 silently never appears. 

72 """ 

73 # A detached background refresh must never crash or print. 

74 with contextlib.suppress(Exception): 

75 from footman import _paths 

76 

77 _paths.configure_child(*where) 

78 _rebuild() 

79 

80 

81def _rebuild() -> None: 

82 from pathlib import Path 

83 

84 from footman import _config, _discover, _manifest, _paths, registry 

85 

86 cwd = Path.cwd() 

87 # The walk's reach is the run's: the user-level `cascade` key (and its 

88 # FOOTMAN_CASCADE override) bounds this child exactly as it bounds the 

89 # runner — otherwise TAB offers tasks the runner then refuses. 

90 mode = _config.cascade_mode() 

91 if mode == "none": 

92 ceiling = cwd 

93 elif mode == "filesystem": 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 ceiling = Path(cwd.anchor) 

95 else: 

96 ceiling = _paths.find_repo_root(cwd) 

97 cfg = _config.load_config(cwd, ceiling) 

98 filename = cfg.get("tasks") 

99 if not isinstance(filename, str): 99 ↛ 105line 99 didn't jump to line 105 because the condition on line 99 was always true

100 # The brand's default filename rode in on the spawn (`child_args` 

101 # hands it over; `configure_child` installed it), so the child asks 

102 # `_paths` — the peek at the cached manifest's baked `tasks_file` 

103 # predated that handoff and could serve a stale answer. 

104 filename = _paths.tasks_file_name() 

105 name = filename 

106 project_files = _paths.task_files(cwd, ceiling, name) 

107 if not project_files: 

108 # The cascade went empty — the last tasks file here was deleted or 

109 # renamed. Nothing else rewrites the cwd-keyed manifest (global mode 

110 # writes the *global* one), so without this the cache outlives its 

111 # project: TAB keeps offering tasks the runner already refuses by 

112 # name, and every aged TAB bumps the mtime, so the collector's idle 

113 # sweep never reaches it either. Removing it puts the directory back 

114 # where a fresh one starts — the hot path falls through to global 

115 # mode. 

116 with contextlib.suppress(OSError): 

117 _paths.manifest_path(cwd).unlink() 

118 files = project_files 

119 user = _paths.user_tasks_file(name) 

120 if user.is_file(): 120 ↛ 124line 120 didn't jump to line 124 because the condition on line 120 was never true

121 # The cascade's outermost rung rides here too: the child must 

122 # rebuild exactly the tree the run serves, or a background refresh 

123 # strips personal tasks from the completions it answers. 

124 files = [user, *files] 

125 # The built-in set the run would mount: the brand's, plus the user's 

126 # own `builtin` key — a background rebuild that skipped the second 

127 # would strip exactly the tasks that key exists to offer. A broken key 

128 # is the run's to report; the child quietly builds what it can. 

129 try: 

130 builtin = _config.effective_builtin(_paths.builtin()) 

131 except Exception: 

132 builtin = _paths.builtin() 

133 if not files and not builtin: 

134 return 

135 # The re-executed child is a fresh interpreter, so it needs telling where 

136 # the cache is exactly as the first child did. Project rule before script 

137 # rule, mirroring the run path: a pinned project owns the directory. 

138 one_liner = ( 

139 "import sys; from footman import _refresh; _refresh.refresh_cwd(*sys.argv[1:])" 

140 ) 

141 root = _project_reexec( 

142 cwd, cfg.get("uv") is not False, one_liner, *_paths.child_args() 

143 ) 

144 if root is None: 

145 _maybe_reexec(files, one_liner, *_paths.child_args()) 

146 

147 # The built-in set is the cascade's outermost rung wherever this child 

148 # stands — project or not — because that is what the run mounts, and a 

149 # background rebuild that mounted less would answer TAB with a tree the 

150 # runner does not serve. 

151 base = registry.Group("root") 

152 if builtin: 

153 from footman import compose 

154 

155 with registry.capture() as base: 

156 for entry in builtin: 

157 compose.plugin(entry) 

158 # Same sealing the app layer does, for the same reason — this child 

159 # rebuilds the very manifest that answers TAB. 

160 registry.seal_expose(base) 

161 

162 if not project_files: 

163 # Global mode: the shared global manifest, keyed by the brand rather 

164 # than the cwd, so one build warms every project-less directory. 

165 try: 

166 reg = _discover.load_tree(files, base=base) 

167 except Exception as exc: 

168 _write_marker(_paths.global_manifest_path(), exc, project=root) 

169 return 

170 _manifest.sync_manifest( 

171 reg, 

172 cwd, 

173 completion_max_age=_config.completion_max_age(cfg), 

174 tasks_file=name, 

175 path=_paths.global_manifest_path(), 

176 bake_cwd=False, 

177 builtin=builtin, 

178 project=False, 

179 ) 

180 return 

181 

182 # Mirror the app layer's cwd cascade build; plugin mounts are authored in 

183 # the tasks files themselves, so discovery alone rebuilds the whole tree. 

184 try: 

185 reg = _discover.load_tree(files, base=base) 

186 except Exception as exc: 

187 _write_marker(_paths.manifest_path(cwd), exc, project=root) 

188 return 

189 _manifest.sync_manifest( 

190 reg, cwd, completion_max_age=_config.completion_max_age(cfg), tasks_file=name 

191 ) 

192 

193 

194# Seconds a broken-tree marker stays authoritative. Short on purpose: within 

195# the window every TAB answers instantly (no spawn storm against a file that 

196# is still broken), and past it stale-while-revalidate rebuilds — so a fixed 

197# file recovers on the press after next, without the marker ever needing to 

198# watch the file. 

199_MARKER_MAX_AGE = 5 

200 

201 

202def _write_marker(path: Path, exc: BaseException, project: Path | None = None) -> None: 

203 """A broken tree is an answer too. 

204 

205 Without this, a tasks file that fails to import leaves nothing behind: 

206 the hot path stays cold, every TAB pays the full cold bound, and the 

207 silence never says why. The marker rides the manifest slot — same 

208 schema, a `broken` line instead of a `tree` — so the hot path can say 

209 what the import said (exit 103) and answer instantly while it does. 

210 

211 *project* is the pinned project that owned the build, when one did: a 

212 failed *import* there is the stale-environment shape, and the marker 

213 names the fix instead of leaving a bare ModuleNotFoundError. 

214 """ 

215 from footman import _manifest, _script 

216 

217 # The message alone when it tells the story (discovery errors carry 

218 # file and cause already); the type name only when there is nothing 

219 # else to show. 

220 told = str(exc).strip() 

221 line = (told.splitlines()[0] if told else type(exc).__name__)[:200] 

222 if project is not None and _script.import_caused(exc): 

223 line += " — the environment may be out of date: run uv sync" 

224 _manifest.write_manifest( 

225 { 

226 "schema": _manifest.SCHEMA_VERSION, 

227 "broken": f"tasks failed to import — {line}", 

228 "completion_max_age": _MARKER_MAX_AGE, 

229 }, 

230 path, 

231 ) 

232 

233 

234def refresh_source(tasks_file: str, *where: str) -> None: 

235 """Rebuild one `-f <file>`'s (cwd, file) manifest, swallowing errors. 

236 

237 `*where` for the same reason as `refresh_cwd`: an arity disagreement 

238 inside `suppress` is a manifest that silently never appears. 

239 """ 

240 # A detached background rebuild must never crash or print. 

241 with contextlib.suppress(Exception): 

242 from footman import _paths 

243 

244 _paths.configure_child(*where) 

245 _rebuild_source(tasks_file) 

246 

247 

248def _rebuild_source(tasks_file: str) -> None: 

249 from pathlib import Path 

250 

251 from footman import _config, _discover, _manifest, _paths, registry 

252 

253 one = Path(tasks_file).expanduser() 

254 if not one.is_file(): 

255 return # a typed-but-missing -f value: nothing to build 

256 cwd = Path.cwd() 

257 one_liner = ( 

258 "import sys; from footman import _refresh; " 

259 "_refresh.refresh_source(*sys.argv[1:])" 

260 ) 

261 # Project rule before script rule, exactly as the run path orders them 

262 # for a `-f` invocation: the lock rule probes the cwd, not the file. 

263 # The config read exists only for its `uv` key — the opt-out must bind 

264 # the child as it binds the run — and a broken config must not take 

265 # down a rebuild that never needed one. 

266 try: 

267 cfg: dict[str, object] = _config.load_config(cwd, _paths.find_repo_root(cwd)) 

268 except Exception: 

269 cfg = {} 

270 root = _project_reexec( 

271 cwd, 

272 cfg.get("uv") is not False, 

273 one_liner, 

274 tasks_file, # the entry reads it back off argv 

275 *_paths.child_args(), # …and the locations behind it 

276 ) 

277 if root is None: 277 ↛ 283line 277 didn't jump to line 283 because the condition on line 277 was always true

278 _maybe_reexec([one], one_liner, tasks_file, *_paths.child_args()) 

279 

280 # Mirror a real `-f` run (see _app._run): one file, no cascade, cached 

281 # under the (cwd, file) key with max_age=0 — no background refresh, 

282 # rebuilt on the next -f run or the next cold TAB. 

283 base = registry.Group("root") 

284 try: 

285 reg = _discover.load_tree([one], base=base) 

286 except Exception as exc: 

287 _write_marker(_paths.source_manifest_path(cwd, one), exc, project=root) 

288 return 

289 _manifest.sync_manifest( 

290 reg, 

291 cwd, 

292 completion_max_age=0, 

293 tasks_file=tasks_file, 

294 path=_paths.source_manifest_path(cwd, one), 

295 )