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

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 

13from pathlib import Path 

14 

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") 

18 

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",) 

22 

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

24DEFAULT_TASKS_FILE = "tasks.py" 

25 

26 

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 

34 

35 

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

37 """Ceiling of the cascade: nearest ancestor with a repo marker (`.git`). 

38 

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) 

47 

48 

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

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

51 

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] 

62 

63 

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()] 

69 

70 

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" 

75 

76 

77def config_home() -> Path: 

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

79 

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" 

86 

87 

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" 

97 

98 

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" 

107 

108 

109def _dir_key(key_dir: Path) -> str: 

110 return hashlib.sha256(str(key_dir.resolve()).encode("utf-8")).hexdigest()[:16] 

111 

112 

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

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

115 

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" 

120 

121 

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" 

125 

126 

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. 

129 

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" 

138 

139 

140def cwd_manifest_path() -> Path: 

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

142 return manifest_path(Path.cwd())