Coverage for src/footman/_paths.py: 98%

192 statements  

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

1"""Filesystem locations shared by the execution path and the completion hot 

2path. 

3 

4Stdlib-only and deliberately import-light: the completion hot path imports this 

5module on every TAB press, so it must never reach for the framework or the 

6user's code. 

7""" 

8 

9from __future__ import annotations 

10 

11import hashlib 

12import os 

13 

14# No module-level pathlib: importing it costs ~5 ms (it drags glob and re 

15# along), which is real money against the TAB press's ~30 ms budget. The 

16# warm completion path runs on the `_*_str` string core below (os.path 

17# only); every Path-returning function imports pathlib at call time, which 

18# the cold paths — a build already costing 100 ms+ — never notice. 

19# `test_a_warm_tab_pays_for_no_heavyweight_stdlib` pins the diet. 

20TYPE_CHECKING = False 

21if TYPE_CHECKING: 

22 from collections.abc import Sequence 

23 from pathlib import Path 

24 

25# Ancestor markers that identify the project root. The manifest cache is keyed 

26# by cwd, but these still bound a lone-file lookup when there is no repo root. 

27# Only the brand-independent one lives here; `project_markers()` adds this 

28# brand's config and tasks filenames. No VCS entry belongs here, see below. 

29PROJECT_MARKERS = ("pyproject.toml",) 

30 

31# Marks the ceiling of the upward walk — the repo root where the task cascade 

32# starts and the config search stops: the version-control boundary, whichever 

33# system drew it. footman never runs these tools or reads their metadata; it 

34# notices a directory entry, which is why supporting four costs nothing. 

35REPO_MARKERS = (".git", ".jj", ".hg", ".svn") 

36 

37# Default name of the tasks file, looked for in every folder of the cascade. 

38DEFAULT_TASKS_FILE = "tasks.py" 

39 

40 

41def project_markers() -> tuple[str, ...]: 

42 """The files that mark a project root — exactly the files footman reads. 

43 

44 Two of the three are this brand's: `acme.toml` marks an `acme` project 

45 root as `footman.toml` marks footman's, and `acmetasks.py` marks one as 

46 `tasks.py` does. 

47 

48 The tasks filename here is the **brand's**, never the `tasks` config key 

49 that can override it per project. That is not an oversight: the key 

50 lives in a config file, and finding config needs the ceiling this 

51 function is in the middle of computing. A brand default is the only 

52 answer available this early, and any project reachable through the 

53 config key has a config file marking its root anyway. 

54 """ 

55 return (*PROJECT_MARKERS, config_basename(), _tasks_file) 

56 

57 

58def _entries(directory: Path) -> set[str]: 

59 """The directory's entry names, exactly as spelled on disk.""" 

60 try: 

61 return set(os.listdir(directory)) 

62 except OSError: 

63 return set() 

64 

65 

66def _named( 

67 directory: Path, name: str, seen: list[tuple[Path, str]] | None = None 

68) -> bool: 

69 """Whether *directory* holds an entry spelled exactly *name*. 

70 

71 Case-exact, because a case-insensitive filesystem answers 

72 `(d / "tasks.py").exists()` True for a file named `Tasks.py`, and a 

73 project silently accepted that way stops working the day it reaches a 

74 Linux box. 

75 

76 Probe first, and list only to confirm a hit. The listing used to come 

77 first — one `listdir` beats one `stat` per marker, which is true while 

78 a directory is small and inverts hard when it is not: the cost is 

79 O(entries) against O(markers), so an ancestor holding a few thousand 

80 files cost ~0.3 s *per level* of the walk (a macOS `$TMPDIR` reached 

81 8,747 entries here and made a bare-directory TAB take 1.5 s). A miss 

82 is now one `stat` whatever the directory holds, and a hit — rare, and 

83 the only place the spelling matters — pays the listing to prove 

84 itself. 

85 

86 A probe that hits and then fails to confirm *is* the wrong-case file, 

87 and this is the one moment anything knows it: the listing is already 

88 in hand, so *seen* collects the sighting for free, at whatever depth 

89 it happens. Nothing else has to go looking, which is what keeps the 

90 complaint off any path someone is waiting on. 

91 """ 

92 if not os.path.exists(os.path.join(directory, name)): 

93 return False # cheap and conclusive: no entry answers to that name 

94 entries = _entries(directory) 

95 if name in entries: 

96 return True 

97 if seen is not None: 

98 lowered = name.lower() 

99 # Sorted so the complaint is reproducible rather than however the 

100 # filesystem happened to order its entries. 

101 actual = next((e for e in sorted(entries) if e.lower() == lowered), None) 

102 if actual is not None: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was always true

103 seen.append((directory, actual)) 

104 return False 

105 

106 

107def _any_named(directory: Path, names: Sequence[str]) -> bool: 

108 """Whether *directory* holds any of *names*, case-exactly. 

109 

110 One listing at most, however many names are asked about: the probes 

111 are cheap, and the first that hits pays for the confirmation the rest 

112 can share. 

113 """ 

114 if not any(os.path.exists(os.path.join(directory, n)) for n in names): 

115 return False 

116 entries = _entries(directory) 

117 return any(name in entries for name in names) 

118 

119 

120def find_project_root(start: Path | None = None) -> Path: 

121 """Nearest ancestor of *start* (default: cwd) containing a project marker.""" 

122 from pathlib import Path 

123 

124 start = (start or Path.cwd()).resolve() 

125 markers = project_markers() 

126 for directory in (start, *start.parents): 

127 if _any_named(directory, markers): 

128 return directory 

129 return start 

130 

131 

132def find_repo_root(start: Path | None = None) -> Path: 

133 """Ceiling of the cascade: nearest ancestor with a `REPO_MARKERS` entry. 

134 

135 Git, Jujutsu, Mercurial and Subversion all draw the same boundary, and 

136 footman only ever asks whether the directory is there — it runs none of 

137 these tools and reads none of their metadata. 

138 

139 Falls back to `find_project_root` when there is no VCS boundary, so a 

140 single-package checkout still has a sensible top. That fallback is also 

141 why no VCS marker belongs in `PROJECT_MARKERS`: by the time it runs, 

142 every ancestor has already been searched for every one of them. 

143 """ 

144 from pathlib import Path 

145 

146 start = (start or Path.cwd()).resolve() 

147 for directory in (start, *start.parents): 

148 if _any_named(directory, REPO_MARKERS): 

149 return directory 

150 return find_project_root(start) 

151 

152 

153def dir_chain(cwd: Path, ceiling: Path) -> list[Path]: 

154 """Directories from *ceiling* down to *cwd* inclusive (root first). 

155 

156 If *ceiling* is not an ancestor of *cwd* (unrelated trees), just `[cwd]`. 

157 """ 

158 cwd = cwd.resolve() 

159 ceiling = ceiling.resolve() 

160 chain: list[Path] = [] 

161 for directory in (cwd, *cwd.parents): 

162 chain.append(directory) 

163 if directory == ceiling: 

164 return list(reversed(chain)) 

165 return [cwd] 

166 

167 

168def task_files( 

169 cwd: Path, 

170 ceiling: Path, 

171 filename: str = DEFAULT_TASKS_FILE, 

172 seen: list[tuple[Path, str]] | None = None, 

173) -> list[Path]: 

174 """Existing task files from *ceiling* down to *cwd* (root first, cwd last). 

175 

176 One spelling: a `Tasks.py` is not a tasks file, even where the 

177 filesystem would happily open it under the documented name — it would 

178 work on the machine that wrote it and vanish on the first Linux box. 

179 Passing over it is right; passing over it *quietly* was the second 

180 mistake, so *seen* collects every wrong-case sighting the walk makes 

181 for the caller to complain about.""" 

182 return [ 

183 f 

184 for d in dir_chain(cwd, ceiling) 

185 if _named(d, filename, seen) and (f := d / filename).is_file() 

186 ] 

187 

188 

189# The brand's locations, set once by `App.run` before anything reads them — 

190# the same module-global shape `_app._brand` uses for the brand's *names*. 

191# Plain strings and a path, never a `Brand`: this module is imported on every 

192# TAB press and must not reach for the framework. 

193_prefix = "FOOTMAN" 

194_cache_dir: Path | None = None 

195_data_dir: Path | None = None 

196_config_name = "footman" 

197_tasks_file = DEFAULT_TASKS_FILE 

198_prog = "fm" 

199_brand_version = "" 

200_builtin: tuple[str, ...] = () 

201_dist: str | None = "footman" 

202 

203 

204class LocationError(Exception): 

205 """Two locations that must differ were pointed at the same directory.""" 

206 

207 

208def configure( 

209 *, 

210 prefix: str = "FOOTMAN", 

211 cache_dir: Path | None = None, 

212 data_dir: Path | None = None, 

213 config_name: str = "footman", 

214 tasks_file: str = DEFAULT_TASKS_FILE, 

215 prog: str = "fm", 

216 brand_version: str = "", 

217 builtin: tuple[str, ...] = (), 

218 dist: str | None = "footman", 

219) -> None: 

220 """Point this CLI's locations at one brand's world. 

221 

222 *cache_dir* and *data_dir* are placed by the brand and are unrelated to 

223 each other; `None` keeps the XDG fallback for that one. *prefix* 

224 namespaces the environment variables, so a stray `FOOTMAN_CACHE_DIR` 

225 cannot move another product's cache. *config_name* and *tasks_file* name 

226 the files this brand's users write, which are also what mark a project 

227 root. *prog*, *brand_version* and *builtin* key the global-mode manifest 

228 — the tree they determine is the same in every project-less directory. 

229 Detached children are handed the resolved values rather than re-deriving 

230 them. *dist* is the distribution the lock rule reasons about — the 

231 package a project's `uv.lock` must pin for the invocation to belong to 

232 that project's environment. 

233 """ 

234 global _prefix, _cache_dir, _data_dir, _config_name, _tasks_file 

235 global _prog, _brand_version, _builtin, _dist 

236 _prefix, _cache_dir, _data_dir = prefix, cache_dir, data_dir 

237 _config_name, _tasks_file = config_name, tasks_file 

238 _prog, _brand_version, _builtin = prog, brand_version, builtin 

239 _dist = dist 

240 

241 

242def builtin() -> tuple[str, ...]: 

243 """The brand's declared built-in entry points (empty for stock).""" 

244 return _builtin 

245 

246 

247def dist() -> str: 

248 """The distribution the lock rule pins on — never empty. 

249 

250 The run path spells the same fallback (`_brand.dist or "footman"`), so 

251 a completion child and the run it stands in for can never disagree 

252 about which package makes a project's lockfile authoritative. 

253 """ 

254 return _dist or "footman" 

255 

256 

257def declared_dist() -> str | None: 

258 """The distribution the brand actually declared, or `None`. 

259 

260 The honest twin of `dist()`. That one falls back to `"footman"` 

261 because the lock rule needs *some* package to look for; a caller 

262 asking "what ships this CLI?" must not be told `footman` by a brand 

263 that never said — `App(dist=…)` is opt-in, and `None` is the true 

264 answer when it was not taken. 

265 """ 

266 return _dist 

267 

268 

269def child_args() -> list[str]: 

270 """The configured locations as argv words for a detached child. 

271 

272 Children inherit the environment but not the brand, and re-deriving a 

273 location from the environment would be re-deriving it *wrongly* — the 

274 brand placed it. So the parent hands over resolved values instead, and 

275 the child never reads a variable of its own. 

276 """ 

277 return [ 

278 _prefix, 

279 str(_cache_dir) if _cache_dir is not None else "", 

280 str(_data_dir) if _data_dir is not None else "", 

281 _config_name, 

282 _tasks_file, 

283 _prog, 

284 _brand_version, 

285 ",".join(_builtin), 

286 _dist or "footman", 

287 ] 

288 

289 

290def configure_child( 

291 prefix: str = "", 

292 cache_dir: str = "", 

293 data_dir: str = "", 

294 config_name: str = "", 

295 tasks_file: str = "", 

296 prog: str = "", 

297 brand_version: str = "", 

298 builtin_csv: str = "", 

299 dist: str = "", 

300) -> None: 

301 """The other side of `child_args` — empty strings mean stock defaults.""" 

302 from pathlib import Path 

303 

304 configure( 

305 prefix=prefix or "FOOTMAN", 

306 cache_dir=Path(cache_dir) if cache_dir else None, 

307 data_dir=Path(data_dir) if data_dir else None, 

308 config_name=config_name or "footman", 

309 tasks_file=tasks_file or DEFAULT_TASKS_FILE, 

310 prog=prog or "fm", 

311 brand_version=brand_version, 

312 builtin=tuple(n for n in builtin_csv.split(",") if n), 

313 dist=dist or "footman", 

314 ) 

315 

316 

317def prog() -> str: 

318 """The command this CLI is invoked as — `fm`, or the brand's own. 

319 

320 Beside `config_table()` and `env_prefix()`: the three words generated 

321 help text has to speak in the reader's brand rather than footman's. 

322 """ 

323 return _prog 

324 

325 

326def env_prefix() -> str: 

327 """The configured environment-variable prefix.""" 

328 return _prefix 

329 

330 

331def tasks_file_name() -> str: 

332 """The brand's tasks filename, as `configure`/`configure_child` set it. 

333 

334 The refresh child reads it here: `child_args` hands the resolved value 

335 over on spawn, so a branded default IS knowable in the child — the old 

336 peek at the cached manifest's baked `tasks_file` predated that handoff. 

337 """ 

338 return _tasks_file 

339 

340 

341def env_var(suffix: str) -> str: 

342 """The configured spelling of a variable: `ACME_CACHE_DIR`.""" 

343 return f"{_prefix}_{suffix}" 

344 

345 

346def data_home() -> Path: 

347 """Base data directory, honouring `XDG_DATA_HOME`. 

348 

349 `~/.local/share`, where footman's own completion scripts already live — 

350 the durable sibling of `cache_home`, for things that must survive a 

351 cache sweep. 

352 """ 

353 from pathlib import Path 

354 

355 xdg = os.environ.get("XDG_DATA_HOME") 

356 return Path(xdg) if xdg else Path.home() / ".local" / "share" 

357 

358 

359def config_basename() -> str: 

360 """The standalone config filename for this brand — `acme.toml`.""" 

361 return f"{_config_name}.toml" 

362 

363 

364def config_table() -> str: 

365 """The `pyproject.toml` table for this brand — the `acme` in `[tool.acme]`.""" 

366 return _config_name 

367 

368 

369def _cache_home_str() -> str: 

370 """`cache_home`, as a string — the warm path's spelling.""" 

371 xdg = os.environ.get("XDG_CACHE_HOME") 

372 return xdg if xdg else os.path.join(os.path.expanduser("~"), ".cache") 

373 

374 

375def cache_home() -> Path: 

376 """Base cache directory, honouring `XDG_CACHE_HOME`.""" 

377 from pathlib import Path 

378 

379 return Path(_cache_home_str()) 

380 

381 

382def config_home() -> Path: 

383 """Base config directory, honouring `XDG_CONFIG_HOME`. 

384 

385 `~/.config` on every platform — the convention CLI tools (uv, ruff, 

386 git's own XDG support) follow on macOS and Windows too, and the 

387 symmetric sibling of `cache_home`. 

388 """ 

389 from pathlib import Path 

390 

391 xdg = os.environ.get("XDG_CONFIG_HOME") 

392 return Path(xdg) if xdg else Path.home() / ".config" 

393 

394 

395def footman_config_dir() -> Path: 

396 """This CLI's config directory: `<PREFIX>_CONFIG_DIR`, else 

397 `<config home>/<name>`. 

398 

399 The user's own writing lives here — the user-level config file and the 

400 user tasks file, which travel together. It is never placed by the brand: 

401 `~/.config/<name>/` is where a user looks for their own files, whatever 

402 the brand does with its cache and data. 

403 

404 The environment override exists because the only XDG-level lever is 

405 `XDG_CONFIG_HOME`, which moves every other application's config too. A 

406 task runner should be able to relocate its own corner — a second 

407 installation under a different identity — without dragging the rest of 

408 the machine along. 

409 """ 

410 from pathlib import Path 

411 

412 override = os.environ.get(env_var("CONFIG_DIR")) 

413 if override: 

414 return Path(override).expanduser() 

415 return config_home() / _config_name 

416 

417 

418def footman_config_file() -> Path: 

419 """The user-level config file: this brand's `<PREFIX>_CONFIG` (a file 

420 path, since this is one file — unlike `<PREFIX>_CONFIG_DIR`'s directory), 

421 else `<config dir>/config.toml`. 

422 

423 The bottom rung of the precedence ladder: project config cascades over 

424 it, `--config` replaces it.""" 

425 from pathlib import Path 

426 

427 override = os.environ.get(env_var("CONFIG")) 

428 if override: 

429 return Path(override).expanduser() 

430 return footman_config_dir() / "config.toml" 

431 

432 

433def footman_cache_dir() -> Path: 

434 """This CLI's cache directory: `<PREFIX>_CACHE_DIR`, else where the brand 

435 placed it, else `<cache home>/<name>`. 

436 

437 Derived data, safe to delete — the collector sweeps this and nothing else. 

438 One override moves every cache (completion manifests, timing history), and 

439 the completion hot path resolves through here too, so TAB follows it with 

440 no re-install. 

441 

442 Resolution only: this is on the hot path, so it never creates anything. 

443 The public `footman.cache_dir()` is what makes the directory. 

444 """ 

445 from pathlib import Path 

446 

447 return Path(_footman_cache_dir_str()) 

448 

449 

450def _footman_cache_dir_str() -> str: 

451 """`footman_cache_dir`, as a string — the warm path's spelling.""" 

452 override = os.environ.get(env_var("CACHE_DIR")) 

453 if override: 

454 return override 

455 if _cache_dir is not None: 

456 return os.fspath(_cache_dir) 

457 # `cache_home` is a seam — the suite re-points it at tmp homes in 

458 # dozens of places — so a replacement must be honoured here too, or a 

459 # "redirected" test quietly writes the real user cache. Only while the 

460 # module's own def stands does the import-free spelling apply. 

461 if cache_home.__module__ == __name__: 

462 return os.path.join(_cache_home_str(), _config_name) 

463 return os.path.join(os.fspath(cache_home()), _config_name) 

464 

465 

466def footman_data_dir() -> Path: 

467 """This CLI's data directory: `<PREFIX>_DATA_DIR`, else where the brand 

468 placed it, else `<data home>/<name>`. 

469 

470 Durable and machine-local — credentials, tokens, generated assets. Never 

471 collected, which is exactly what separates it from the cache. 

472 

473 Resolution only; the public `footman.data_dir()` creates it. 

474 """ 

475 from pathlib import Path 

476 

477 override = os.environ.get(env_var("DATA_DIR")) 

478 if override: 

479 return Path(override) 

480 if _data_dir is not None: 

481 return _data_dir 

482 return data_home() / _config_name 

483 

484 

485def check_locations() -> None: 

486 """Refuse a cache directory that coincides with the data or config one. 

487 

488 The collector deletes from the cache by age. Pointed at either of the 

489 other two it would delete durable things — credentials, the user's own 

490 files — so this is a refusal at startup rather than a surprise ninety 

491 days later. Data and config may share (nothing destructive runs in 

492 either); only the cache is dangerous company. 

493 """ 

494 cache = footman_cache_dir() 

495 for other, what, var in ( 

496 (footman_data_dir(), "data", "DATA_DIR"), 

497 (footman_config_dir(), "config", "CONFIG_DIR"), 

498 ): 

499 try: 

500 same = cache.resolve() == other.resolve() 

501 except OSError: # unresolvable (a broken symlink); compare as written 

502 same = cache == other 

503 if same: 

504 raise LocationError( 

505 f"the cache and {what} directories are both {cache} — they " 

506 f"must differ, because the collector deletes from the cache " 

507 f"by age and would eventually delete durable {what}. Set " 

508 f"{env_var('CACHE_DIR')} or {env_var(var)}, or place them " 

509 f"apart in the App(...)." 

510 ) 

511 

512 

513def user_stamp() -> str: 

514 """A fingerprint of the user-level files a manifest was built from — the 

515 user config and the user tasks file. 

516 

517 Every manifest depends on both (the config's `cascade` bounds the walk, 

518 and the user tasks file rides every tree footman builds), yet neither can 

519 join a cache *key*: the key would then have to be recomputed from a TOML 

520 read on the hot path. So the manifest bakes this stamp instead, and the 

521 resolver compares it — two `stat` calls, no parse, no import, and paid 

522 after the candidates are already written, so the keystroke never waits 

523 on it. A changed stamp means the same thing an aged manifest means: 

524 serve what is cached, rebuild behind it. 

525 

526 An **absent** file is a value, not a gap: writing a user tasks file that 

527 was not there before changes the tree exactly as editing one does, and 

528 the stamp has to say so. Size rides along with the timestamp so an edit 

529 inside one clock tick still registers. 

530 """ 

531 marks: list[str] = [] 

532 for path in (footman_config_file(), user_tasks_file(tasks_file_name())): 

533 try: 

534 info = os.stat(path) 

535 marks.append(f"{info.st_mtime_ns}-{info.st_size}") 

536 except OSError: 

537 marks.append("-") # absent, and its arrival is a change 

538 return ":".join(marks) 

539 

540 

541def user_tasks_file(filename: str = DEFAULT_TASKS_FILE) -> Path: 

542 """This CLI's user-level tasks file — `<config dir>/<tasks_file>`. 

543 

544 Beside the user-level config file, because both are the *user's* own 

545 writing rather than anything the brand places — and both move together 

546 under `<PREFIX>_CONFIG_DIR`. It is a *fallback*, not a rung: a project's 

547 cascade wins outright, because there is one way to get tasks into a 

548 project tree and that is mounting them in a tasks file. 

549 """ 

550 return footman_config_dir() / filename 

551 

552 

553# The cache keys hash *resolved* path strings. `os.path.realpath` is what 

554# `Path.resolve()` calls underneath, so the string core and the Path 

555# wrappers key byte-identically — a manifest written by a full run is the 

556# manifest a TAB reads, whichever spelling computed the name. 

557 

558 

559def _dir_key_str(key_dir: str) -> str: 

560 return hashlib.sha256(os.path.realpath(key_dir).encode("utf-8")).hexdigest()[:16] 

561 

562 

563def env_key(prefix: str) -> str: 

564 """A stable short key for one Python environment. 

565 

566 `sys.prefix` names it: a venv, a uv tool environment, the system 

567 interpreter. Hashed like a directory key, and for the same reason — 

568 the store holds one entry per subject and the subject's own name is 

569 not a filename. 

570 """ 

571 return _dir_key_str(prefix) 

572 

573 

574def _manifest_file(key_dir: str) -> str: 

575 """`manifest_path`, as a string — the warm path's spelling.""" 

576 return os.path.join(_footman_cache_dir_str(), f"{_dir_key_str(key_dir)}.json") 

577 

578 

579def manifest_path(key_dir: Path) -> Path: 

580 """Cached-manifest path for *key_dir* (the cwd), keyed by a path hash. 

581 

582 The effective task set depends on where you stand in a monorepo — the 

583 cascade from the repo root down to the cwd — so the cache is per directory. 

584 """ 

585 from pathlib import Path 

586 

587 return Path(_manifest_file(os.fspath(key_dir))) 

588 

589 

590def times_path(key_dir: Path) -> Path: 

591 """Duration-history path for *key_dir* — beside its manifest, same key.""" 

592 from pathlib import Path 

593 

594 return Path( 

595 os.path.join( 

596 _footman_cache_dir_str(), f"{_dir_key_str(os.fspath(key_dir))}.times.json" 

597 ) 

598 ) 

599 

600 

601def _source_file(cwd: str, tasks_file: str) -> str: 

602 """`source_manifest_path`, as a string — the warm path's spelling. 

603 

604 `expanduser` here, in the one key core, so every keyer agrees: 

605 resolving alone leaves `~` literal, and a `-f=~/tasks.py` TAB would key 

606 `<cwd>/~/tasks.py` while the refresh child (which expands before 

607 loading) keys the home-anchored truth — a manifest that never warms. 

608 """ 

609 one = os.path.realpath(os.path.expanduser(tasks_file)) 

610 joined = f"{os.path.realpath(cwd)}\0{one}" 

611 key = hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16] 

612 return os.path.join(_footman_cache_dir_str(), f"{key}.json") 

613 

614 

615def source_manifest_path(cwd: Path, tasks_file: Path) -> Path: 

616 """Cache path for a `-f <file>` run, keyed by *both* the cwd and the file. 

617 

618 A `-f` invocation loads that file's tasks *and* the cwd's config plugins, 

619 so the task set depends on the pair — the same file opened from two projects 

620 is two caches. A separate key from `manifest_path`, so a `-f` run never 

621 poisons the plain-cwd completion cache. 

622 """ 

623 from pathlib import Path 

624 

625 return Path(_source_file(os.fspath(cwd), os.fspath(tasks_file))) 

626 

627 

628def _cwd_manifest_file() -> str: 

629 """`cwd_manifest_path`, as a string — the warm path's spelling.""" 

630 return _manifest_file(os.getcwd()) 

631 

632 

633def cwd_manifest_path() -> Path: 

634 """Manifest path for the current directory (both hot and cold paths agree).""" 

635 from pathlib import Path 

636 

637 return Path(_cwd_manifest_file()) 

638 

639 

640def _global_file() -> str: 

641 """`global_manifest_path`, as a string — the warm path's spelling.""" 

642 key = hashlib.sha256( 

643 "\0".join([_prog, _brand_version, *sorted(_builtin)]).encode("utf-8") 

644 ).hexdigest()[:16] 

645 return os.path.join(_footman_cache_dir_str(), f"global-{key}.json") 

646 

647 

648def global_manifest_path() -> Path: 

649 """Manifest path for global mode — no project task files, the tree from 

650 the brand's built-ins with the user rung over them. 

651 

652 Keyed by what determines that tree — prog, the brand's version, the 

653 sorted builtin names — never by cwd: every project-less directory shares 

654 one manifest, so the cache is cold once per brand version rather than 

655 once per directory. The user tasks file needs no place in the key; it is 

656 machine-global too, and staleness handles its edits. 

657 """ 

658 from pathlib import Path 

659 

660 return Path(_global_file())