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
« 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.
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"""
9from __future__ import annotations
11import hashlib
12import os
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
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",)
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")
37# Default name of the tasks file, looked for in every folder of the cascade.
38DEFAULT_TASKS_FILE = "tasks.py"
41def project_markers() -> tuple[str, ...]:
42 """The files that mark a project root — exactly the files footman reads.
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.
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)
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()
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*.
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.
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.
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
107def _any_named(directory: Path, names: Sequence[str]) -> bool:
108 """Whether *directory* holds any of *names*, case-exactly.
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)
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
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
132def find_repo_root(start: Path | None = None) -> Path:
133 """Ceiling of the cascade: nearest ancestor with a `REPO_MARKERS` entry.
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.
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
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)
153def dir_chain(cwd: Path, ceiling: Path) -> list[Path]:
154 """Directories from *ceiling* down to *cwd* inclusive (root first).
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]
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).
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 ]
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"
204class LocationError(Exception):
205 """Two locations that must differ were pointed at the same directory."""
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.
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
242def builtin() -> tuple[str, ...]:
243 """The brand's declared built-in entry points (empty for stock)."""
244 return _builtin
247def dist() -> str:
248 """The distribution the lock rule pins on — never empty.
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"
257def declared_dist() -> str | None:
258 """The distribution the brand actually declared, or `None`.
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
269def child_args() -> list[str]:
270 """The configured locations as argv words for a detached child.
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 ]
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
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 )
317def prog() -> str:
318 """The command this CLI is invoked as — `fm`, or the brand's own.
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
326def env_prefix() -> str:
327 """The configured environment-variable prefix."""
328 return _prefix
331def tasks_file_name() -> str:
332 """The brand's tasks filename, as `configure`/`configure_child` set it.
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
341def env_var(suffix: str) -> str:
342 """The configured spelling of a variable: `ACME_CACHE_DIR`."""
343 return f"{_prefix}_{suffix}"
346def data_home() -> Path:
347 """Base data directory, honouring `XDG_DATA_HOME`.
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
355 xdg = os.environ.get("XDG_DATA_HOME")
356 return Path(xdg) if xdg else Path.home() / ".local" / "share"
359def config_basename() -> str:
360 """The standalone config filename for this brand — `acme.toml`."""
361 return f"{_config_name}.toml"
364def config_table() -> str:
365 """The `pyproject.toml` table for this brand — the `acme` in `[tool.acme]`."""
366 return _config_name
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")
375def cache_home() -> Path:
376 """Base cache directory, honouring `XDG_CACHE_HOME`."""
377 from pathlib import Path
379 return Path(_cache_home_str())
382def config_home() -> Path:
383 """Base config directory, honouring `XDG_CONFIG_HOME`.
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
391 xdg = os.environ.get("XDG_CONFIG_HOME")
392 return Path(xdg) if xdg else Path.home() / ".config"
395def footman_config_dir() -> Path:
396 """This CLI's config directory: `<PREFIX>_CONFIG_DIR`, else
397 `<config home>/<name>`.
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.
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
412 override = os.environ.get(env_var("CONFIG_DIR"))
413 if override:
414 return Path(override).expanduser()
415 return config_home() / _config_name
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`.
423 The bottom rung of the precedence ladder: project config cascades over
424 it, `--config` replaces it."""
425 from pathlib import Path
427 override = os.environ.get(env_var("CONFIG"))
428 if override:
429 return Path(override).expanduser()
430 return footman_config_dir() / "config.toml"
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>`.
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.
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
447 return Path(_footman_cache_dir_str())
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)
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>`.
470 Durable and machine-local — credentials, tokens, generated assets. Never
471 collected, which is exactly what separates it from the cache.
473 Resolution only; the public `footman.data_dir()` creates it.
474 """
475 from pathlib import Path
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
485def check_locations() -> None:
486 """Refuse a cache directory that coincides with the data or config one.
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 )
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.
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.
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)
541def user_tasks_file(filename: str = DEFAULT_TASKS_FILE) -> Path:
542 """This CLI's user-level tasks file — `<config dir>/<tasks_file>`.
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
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.
559def _dir_key_str(key_dir: str) -> str:
560 return hashlib.sha256(os.path.realpath(key_dir).encode("utf-8")).hexdigest()[:16]
563def env_key(prefix: str) -> str:
564 """A stable short key for one Python environment.
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)
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")
579def manifest_path(key_dir: Path) -> Path:
580 """Cached-manifest path for *key_dir* (the cwd), keyed by a path hash.
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
587 return Path(_manifest_file(os.fspath(key_dir)))
590def times_path(key_dir: Path) -> Path:
591 """Duration-history path for *key_dir* — beside its manifest, same key."""
592 from pathlib import Path
594 return Path(
595 os.path.join(
596 _footman_cache_dir_str(), f"{_dir_key_str(os.fspath(key_dir))}.times.json"
597 )
598 )
601def _source_file(cwd: str, tasks_file: str) -> str:
602 """`source_manifest_path`, as a string — the warm path's spelling.
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")
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.
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
625 return Path(_source_file(os.fspath(cwd), os.fspath(tasks_file)))
628def _cwd_manifest_file() -> str:
629 """`cwd_manifest_path`, as a string — the warm path's spelling."""
630 return _manifest_file(os.getcwd())
633def cwd_manifest_path() -> Path:
634 """Manifest path for the current directory (both hot and cold paths agree)."""
635 from pathlib import Path
637 return Path(_cwd_manifest_file())
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")
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.
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
660 return Path(_global_file())