Coverage for src/footman/_paths.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +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
13from pathlib import Path
15# Ancestor markers that identify the project root. The manifest cache is keyed
16# by cwd, but these still bound a lone-file lookup when there is no repo root.
17PROJECT_MARKERS = ("pyproject.toml", "footman.toml", ".git", "tasks.py")
19# Marks the ceiling of the upward walk — the repo root where the task cascade
20# starts and the config search stops. `.git` is the natural monorepo edge.
21REPO_MARKERS = (".git",)
23# Default name of the tasks file, looked for in every folder of the cascade.
24DEFAULT_TASKS_FILE = "tasks.py"
27def find_project_root(start: Path | None = None) -> Path:
28 """Nearest ancestor of *start* (default: cwd) containing a project marker."""
29 start = (start or Path.cwd()).resolve()
30 for directory in (start, *start.parents):
31 if any((directory / marker).exists() for marker in PROJECT_MARKERS):
32 return directory
33 return start
36def find_repo_root(start: Path | None = None) -> Path:
37 """Ceiling of the cascade: nearest ancestor with a repo marker (`.git`).
39 Falls back to `find_project_root` when there is no VCS boundary, so a
40 single-package checkout still has a sensible top.
41 """
42 start = (start or Path.cwd()).resolve()
43 for directory in (start, *start.parents):
44 if any((directory / marker).exists() for marker in REPO_MARKERS):
45 return directory
46 return find_project_root(start)
49def dir_chain(cwd: Path, ceiling: Path) -> list[Path]:
50 """Directories from *ceiling* down to *cwd* inclusive (root first).
52 If *ceiling* is not an ancestor of *cwd* (unrelated trees), just `[cwd]`.
53 """
54 cwd = cwd.resolve()
55 ceiling = ceiling.resolve()
56 chain: list[Path] = []
57 for directory in (cwd, *cwd.parents):
58 chain.append(directory)
59 if directory == ceiling:
60 return list(reversed(chain))
61 return [cwd]
64def task_files(
65 cwd: Path, ceiling: Path, filename: str = DEFAULT_TASKS_FILE
66) -> list[Path]:
67 """Existing task files from *ceiling* down to *cwd* (root first, cwd last)."""
68 return [f for d in dir_chain(cwd, ceiling) if (f := d / filename).is_file()]
71def cache_home() -> Path:
72 """Base cache directory, honouring `XDG_CACHE_HOME`."""
73 xdg = os.environ.get("XDG_CACHE_HOME")
74 return Path(xdg) if xdg else Path.home() / ".cache"
77def config_home() -> Path:
78 """Base config directory, honouring `XDG_CONFIG_HOME`.
80 `~/.config` on every platform — the convention CLI tools (uv, ruff,
81 git's own XDG support) follow on macOS and Windows too, and the
82 symmetric sibling of `cache_home`.
83 """
84 xdg = os.environ.get("XDG_CONFIG_HOME")
85 return Path(xdg) if xdg else Path.home() / ".config"
88def footman_config_file() -> Path:
89 """The user-level config file: `$FOOTMAN_CONFIG` when set (a file path,
90 since this is one file — unlike `FOOTMAN_CACHE_DIR`'s directory), else
91 `<config home>/footman/config.toml`. The bottom rung of the precedence
92 ladder: project config cascades over it, `--config` replaces it."""
93 override = os.environ.get("FOOTMAN_CONFIG")
94 if override:
95 return Path(override).expanduser()
96 return config_home() / "footman" / "config.toml"
99def footman_cache_dir() -> Path:
100 """footman's own cache directory: `$FOOTMAN_CACHE_DIR` when set, else
101 `<cache home>/footman`. One override moves every footman cache —
102 completion manifests and timing history alike — and the completion hot
103 path resolves through here too, so TAB follows it with no re-install.
104 """
105 override = os.environ.get("FOOTMAN_CACHE_DIR")
106 return Path(override) if override else cache_home() / "footman"
109def _dir_key(key_dir: Path) -> str:
110 return hashlib.sha256(str(key_dir.resolve()).encode("utf-8")).hexdigest()[:16]
113def manifest_path(key_dir: Path) -> Path:
114 """Cached-manifest path for *key_dir* (the cwd), keyed by a path hash.
116 The effective task set depends on where you stand in a monorepo — the
117 cascade from the repo root down to the cwd — so the cache is per directory.
118 """
119 return footman_cache_dir() / f"{_dir_key(key_dir)}.json"
122def times_path(key_dir: Path) -> Path:
123 """Duration-history path for *key_dir* — beside its manifest, same key."""
124 return footman_cache_dir() / f"{_dir_key(key_dir)}.times.json"
127def source_manifest_path(cwd: Path, tasks_file: Path) -> Path:
128 """Cache path for a `-f <file>` run, keyed by *both* the cwd and the file.
130 A `-f` invocation loads that file's tasks *and* the cwd's config plugins,
131 so the task set depends on the pair — the same file opened from two projects
132 is two caches. A separate key from `manifest_path`, so a `-f` run never
133 poisons the plain-cwd completion cache.
134 """
135 joined = f"{cwd.resolve()}\0{tasks_file.resolve()}"
136 key = hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
137 return footman_cache_dir() / f"{key}.json"
140def cwd_manifest_path() -> Path:
141 """Manifest path for the current directory (both hot and cold paths agree)."""
142 return manifest_path(Path.cwd())