Coverage for src/footman/config.py: 97%
84 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"""Behavioural settings, discovered the same way tasks are.
3footman reads `[tool.footman]` from `pyproject.toml` and a standalone
4`footman.toml` (whole-file), walking from the repo root down to the current
5directory. Nearer files win, so a package can override repo-wide defaults; a
6`--config PATH` on the command line overrides everything. Recognised keys:
8* `tasks` — name of the task file to look for in the cascade (default
9 `tasks.py`).
10* `sequential` — run tasks one at a time by default (`fm` still overrides
11 with `-s` / a parallel default).
12* `color` — `always` | `never` | `auto` (default): when to emit ANSI colour,
13 for footman's own output and the tools it spawns. `--color` / `--no-color`
14 override it; `NO_COLOR` / `FORCE_COLOR` in the environment are consulted
15 below it.
16* `plugins` — `footman.tasks` entry points to mount as command groups
17 (opt-in; installing a package never adds tasks by itself). A plugin's
18 name is its command path: `["footman.tools"]` mounts as `fm footman
19 tools …`, nesting one group per dotted segment.
21Unknown keys are kept but ignored, so newer settings never break an older
22footman.
23"""
25from __future__ import annotations
27import tomllib
28from collections.abc import Callable
29from pathlib import Path
30from typing import Any
32from footman import _paths
34# Filenames read in each directory of the cascade. Within one directory the
35# dedicated `footman.toml` wins over `pyproject.toml`'s `[tool.footman]`.
36PYPROJECT = "pyproject.toml"
37FOOTMAN_TOML = "footman.toml"
39# Keys that only make sense in the user-level file: they govern shared,
40# machine-wide behaviour (the cache collector sweeps one cache for every
41# project), so a per-project value would be a lie waiting to confuse
42# someone. Stripped from cascade files, with a note under -v; an explicit
43# `--config` file keeps them — the user named that file on purpose.
44USER_LEVEL_KEYS = frozenset({"gc"})
47class ConfigError(Exception):
48 """A config TOML file exists but cannot be parsed."""
51def _read_toml(path: Path, required: bool = False) -> dict[str, Any] | None:
52 """Parse *path*; `None` if absent/unreadable, `ConfigError` if malformed.
54 A missing file is normal (most directories have no config); a file that
55 exists but doesn't parse is a user mistake that must not be silently
56 read as "no settings". When *required* (an explicit `--config`), an
57 unreadable file is loud too, not silently skipped.
58 """
59 try:
60 text = path.read_text("utf-8")
61 except OSError as exc:
62 if required: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 raise ConfigError(f"{path}: {exc.strerror or exc}") from exc
64 return None
65 try:
66 data = tomllib.loads(text)
67 except tomllib.TOMLDecodeError as exc:
68 raise ConfigError(f"{path}: {exc}") from exc
69 return data if isinstance(data, dict) else None
72def _footman_table(path: Path, required: bool = False) -> dict[str, Any]:
73 """The footman settings in *path* — `[tool.footman]` for a pyproject,
74 the whole file for anything else. Empty dict if absent/unreadable."""
75 data = _read_toml(path, required=required)
76 if data is None:
77 return {}
78 if path.name == PYPROJECT:
79 tool = data.get("tool")
80 table = tool.get("footman") if isinstance(tool, dict) else None
81 return table if isinstance(table, dict) else {}
82 return data
85def _dir_config(
86 directory: Path,
87 on_warning: Callable[[str], None] | None,
88 on_note: Callable[[str], None] | None = None,
89) -> dict[str, Any]:
90 """Merged footman settings for one directory (footman.toml wins).
92 A malformed file in the discovered cascade is warned about and skipped —
93 one broken pyproject.toml between the repo root and the cwd should not
94 brick every `fm` invocation. User-level-only keys are stripped here,
95 with an advisory through *on_note* (verbose runs wire it; quiet ones
96 don't) pointing at where the key belongs.
97 """
98 merged: dict[str, Any] = {}
99 for name in (PYPROJECT, FOOTMAN_TOML):
100 try:
101 merged.update(_footman_table(directory / name))
102 except ConfigError as exc:
103 if on_warning is not None:
104 on_warning(f"ignoring malformed config: {exc}")
105 for key in USER_LEVEL_KEYS & merged.keys():
106 del merged[key]
107 if on_note is not None:
108 on_note(
109 f"`{key}` is a user-level setting — it belongs in "
110 f"{_paths.footman_config_file()}; ignoring it in {directory}"
111 )
112 return merged
115DEFAULT_COMPLETION_MAX_AGE_S = 600 # 10 minutes
118def _parse_duration(value: object) -> int | None:
119 """Seconds from a duration (`"10m"`, `"30s"`, `"1h"`, or a plain int); `None`
120 to disable (`off`/`0`/negative). An unparseable value falls back to the
121 default rather than crashing the completion build."""
122 if value is None:
123 return DEFAULT_COMPLETION_MAX_AGE_S
124 if isinstance(value, bool): # bool is an int subclass — treat as on/off
125 return DEFAULT_COMPLETION_MAX_AGE_S if value else None
126 if isinstance(value, int):
127 return value if value > 0 else None
128 if not isinstance(value, str):
129 return DEFAULT_COMPLETION_MAX_AGE_S
130 text = value.strip().lower()
131 if text in ("off", "none", ""):
132 return None
133 units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
134 unit = units.get(text[-1:])
135 try:
136 n = int(text[:-1]) if unit else int(text)
137 except ValueError:
138 return DEFAULT_COMPLETION_MAX_AGE_S
139 seconds = n * (unit or 1)
140 return seconds if seconds > 0 else None
143def completion_max_age(cfg: dict[str, Any]) -> int | None:
144 """Seconds before the completion cache is considered stale, or `None` if
145 disabled. Reads `[tool.footman] completion.max_age`; default 10 minutes."""
146 completion = cfg.get("completion")
147 raw = completion.get("max_age") if isinstance(completion, dict) else None
148 return _parse_duration(raw)
151def load_config(
152 cwd: Path,
153 ceiling: Path,
154 cli_path: str | None = None,
155 on_warning: Callable[[str], None] | None = None,
156 on_note: Callable[[str], None] | None = None,
157) -> dict[str, Any]:
158 """Merge config from *ceiling* down to *cwd*; *cli_path* overrides all.
160 A malformed discovered file warns (via *on_warning*) and is skipped; a
161 missing or malformed explicit *cli_path* raises `ConfigError` — the user
162 named that file on purpose, so it failing quietly (a typo silently ignored)
163 is not an option. *on_note* carries advisories (a user-level key found in
164 a project file) — verbose runs wire it, others leave it `None`.
165 """
166 if cli_path:
167 # The explicit file is total control: it replaces the global file
168 # and the cascade both — the user named exactly what applies.
169 path = Path(cli_path).expanduser()
170 if not path.is_file():
171 raise ConfigError(f"{path}: no such file")
172 return _footman_table(path, required=True)
174 merged: dict[str, Any] = {}
175 try:
176 # The bottom rung: the user-level file. Whole-file footman settings,
177 # like footman.toml; every project layer cascades over it.
178 merged.update(_footman_table(_paths.footman_config_file()))
179 except ConfigError as exc:
180 if on_warning is not None: 180 ↛ 182line 180 didn't jump to line 182 because the condition on line 180 was always true
181 on_warning(f"ignoring malformed config: {exc}")
182 for directory in _paths.dir_chain(cwd, ceiling):
183 merged.update(_dir_config(directory, on_warning, on_note))
184 return merged