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
« 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.
3The completion hot path spawns this detached, two ways:
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.
13Both rebuild exactly as a real run would and are strictly fire-and-forget: they
14print nothing and never raise.
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"""
23from __future__ import annotations
25import contextlib
26from typing import TYPE_CHECKING
28if TYPE_CHECKING: # runtime imports stay deferred: this child spawns cheap
29 from pathlib import Path
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
41 _script.maybe_reexec(files, ["-P", "-c", entry, *args])
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
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
63def refresh_cwd(*where: str) -> None:
64 """Rebuild the current directory's completion manifest, swallowing errors.
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
77 _paths.configure_child(*where)
78 _rebuild()
81def _rebuild() -> None:
82 from pathlib import Path
84 from footman import _config, _discover, _manifest, _paths, registry
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())
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
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)
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
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 )
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
202def _write_marker(path: Path, exc: BaseException, project: Path | None = None) -> None:
203 """A broken tree is an answer too.
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.
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
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 )
234def refresh_source(tasks_file: str, *where: str) -> None:
235 """Rebuild one `-f <file>`'s (cwd, file) manifest, swallowing errors.
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
244 _paths.configure_child(*where)
245 _rebuild_source(tasks_file)
248def _rebuild_source(tasks_file: str) -> None:
249 from pathlib import Path
251 from footman import _config, _discover, _manifest, _paths, registry
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())
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 )