Coverage for src/footman/_config.py: 95%
177 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"""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.
8**`KEYS` below is the list of recognised keys** — name, accepted values,
9default and description, as data. The docs table renders from it, so the
10reference page and the runner cannot disagree; add a key there and it
11documents itself.
13Unknown keys are kept but ignored, so newer settings never break an older
14footman.
15"""
17from __future__ import annotations
19import os
20import sys
21import tomllib
22from collections.abc import Callable, Sequence
23from pathlib import Path
24from typing import Any
26from footman import _encoding, _paths
28# Filenames read in each directory of the cascade. Within one directory the
29# dedicated file wins over `pyproject.toml`'s table. Both the dedicated
30# filename and the table name are the brand's — `acme.toml` and
31# `[tool.acme]` — so ask `_paths` rather than hard-coding footman's.
32PYPROJECT = "pyproject.toml"
34# Keys that only make sense in the user-level file: they govern shared,
35# machine-wide behaviour (the cache collector sweeps one cache for every
36# project), so a per-project value would be a lie waiting to confuse
37# someone. Stripped from cascade files, with a note under -v; an explicit
38# `--config` file keeps them — the user named that file on purpose.
39USER_LEVEL_KEYS = frozenset({"gc", "cascade", "builtins"})
41# Every recognised key, as data — the source the docs table renders from, so
42# a reference page cannot describe a key set the runner doesn't have. The
43# `cwd` key went undocumented for four releases because the only list was
44# prose in a docstring; a table nothing reads is a table nobody updates.
45#
46# (name, values, default, help). `name` uses dotted form for a sub-table key.
47KEYS: tuple[tuple[str, str, str, str], ...] = (
48 (
49 "tasks",
50 "filename",
51 "`tasks.py`",
52 "Task file to look for in each directory of the cascade.",
53 ),
54 (
55 "sequential",
56 "`true` / `false`",
57 "`false`",
58 "Run tasks one at a time by default; `-s` does it for one invocation.",
59 ),
60 (
61 "jobs",
62 "integer",
63 "cores - 1",
64 "Max parallel tasks, never below 2. `-j=N` overrides it.",
65 ),
66 (
67 "color",
68 "`always` / `never` / `auto`",
69 "`auto`",
70 "When to emit ANSI colour, for {config}'s own output and the tools it "
71 "spawns. `--color`/`--no-color` override it.",
72 ),
73 (
74 "cwd",
75 "policy token / absolute path",
76 "`taskfile`",
77 "Where tasks run by default: `taskfile` (the directory of the file "
78 "that defined the task), `root` (the cascade's top), `asinvoked` "
79 "(where you typed the command), `unmanaged` ({config} holds no "
80 "opinion), or an absolute path. A relative suffix belongs on a "
81 "task's `rel=`.",
82 ),
83 (
84 "sort",
85 "`true` / `false`",
86 "`false`",
87 "List tasks alphabetically in `--list`, `--tree`, help and the "
88 "generated docs pages. Default: definition order, so the file's own "
89 "order is the listing's. Presentation only — never changes what runs.",
90 ),
91 (
92 "progress",
93 "`true` / `false`",
94 "`true`",
95 "`false` disables the progress bar, the eta line, and timing capture; "
96 "`--progress` turns them back on for one invocation.",
97 ),
98 (
99 "input",
100 "`true` / `false`",
101 "`true`",
102 "`false` makes this project never prompt: a `confirm()` gate fails and "
103 "an `ask()` without a default errors, rather than waiting for someone. "
104 "`--input` allows prompting for one invocation, `--no-input` refuses "
105 "it for one.",
106 ),
107 (
108 "uv",
109 "`true` / `false`",
110 "`true`",
111 "`false` disables both uv handoffs: re-running through the project's "
112 "pinned {config}, and the script environment of a tasks file carrying "
113 "its own PEP 723 dependencies.",
114 ),
115 (
116 "builtins.discovery_mode",
117 "`auto` / `manual` / `internal` / `none`",
118 "`auto`",
119 "How the built-in set is assembled: `auto` lets `{prog} self.*` keep "
120 "discovered list current, `manual` leaves that list to you, "
121 "`internal` ignores it, `none` drops the runner's own set too. "
122 "`builtins.user` is honoured in every mode. **User-level only.**",
123 ),
124 (
125 "builtins.user",
126 "list of entry points",
127 "empty",
128 "`footman.tasks` entry points *you* name, mounted as built-in tasks "
129 "— the cascade's outermost rung. Nothing else writes this list, and "
130 "a name that is not installed is refused. **User-level only.**",
131 ),
132 (
133 "docs_url",
134 "URL template",
135 "unset",
136 "Link task names in `--list`/`--tree`/`--help` to your generated "
137 "task docs (and put a `docs_url` field on `--json` rows): `{path}` "
138 "is the slash-joined task address, `{slug}` the dash-joined one. "
139 "Terminal links ride the colour switch; piped output stays plain.",
140 ),
141 (
142 "notes",
143 "table of levels",
144 "per kind",
145 "Reclassify note kinds: `[tool.{config}.notes]` maps `[task/]kind` "
146 "patterns (either side `*`, most specific wins) to `trace` / `info` "
147 "/ `warning` / `error` — `error` fails the task at its boundary. "
148 "See the notes page.",
149 ),
150 (
151 "completion.max_age",
152 "duration / `off`",
153 "`10m`",
154 'Age before a background completion refresh (e.g. `"10m"`; `off` to disable).',
155 ),
156 (
157 "fetch.backend",
158 "`urllib` / `curl` / `httpx` / `requests` / `auto`",
159 "`urllib`",
160 "Download engine for `fetch()`.",
161 ),
162 (
163 "shell.default",
164 "`posix` / `native` / `pwsh` / a shell name",
165 "`posix`",
166 "What `run(shell=True)` resolves to. `posix` is bash, then sh — git "
167 "bash on Windows.",
168 ),
169 (
170 "gc",
171 "`true` / `false`",
172 "`true`",
173 "`false` disables the cache collector (it runs at most daily). "
174 "**User-level only** — ignored in a project config, with a note "
175 "under `-v`.",
176 ),
177 (
178 "cascade",
179 "`none` / `repo` / `filesystem`",
180 "`repo`",
181 "How far discovery ranges for task files *and* config: this directory "
182 "only, the repository, or across repositories. **User-level only**; "
183 "`{env}_CASCADE` overrides it per invocation.",
184 ),
185)
188class ConfigError(Exception):
189 """A config TOML file exists but cannot be parsed."""
192class CascadeError(ConfigError):
193 """The cascade mode (env or config) is not one of the known walks."""
196CASCADE_MODES = ("none", "repo", "filesystem")
199def cascade_mode(cli_path: str | None = None) -> str:
200 """How far discovery ranges for task files and config: `"none"` (the
201 cwd's own files only), `"repo"` (the `.git` ceiling — the default,
202 today's walk), or `"filesystem"` (past repo boundaries, up to the
203 filesystem root).
205 `FOOTMAN_CASCADE` overrides the `cascade` key — env over durable config,
206 per-invocation over machine-wide. The key itself is user-level-only:
207 what sits above a repo is the machine owner's layout, not any project's
208 business — and the walk's own reach depends on it, so without an
209 explicit `--config` it is read from the user-level file alone, before
210 any walk. An unknown value is a taught error, never a silent default.
211 """
212 # This brand's spelling, in the read *and* in the error: naming
213 # `FOOTMAN_CASCADE` at an `acme` user teaches a variable that does nothing.
214 cascade_var = _paths.env_var("CASCADE")
215 value, source = os.environ.get(cascade_var), cascade_var
216 if not value:
217 path = Path(cli_path).expanduser() if cli_path else _paths.footman_config_file()
218 try:
219 raw = _footman_table(path).get("cascade")
220 except ConfigError:
221 raw = None # load_config warns about the malformed file itself
222 if raw is None:
223 return "repo"
224 value, source = str(raw), f"`cascade` (in {path})"
225 if value not in CASCADE_MODES:
226 raise CascadeError(
227 f"{source}: unknown cascade mode {value!r} — use 'none' (this "
228 f"directory only), 'repo' (the repository, default), or "
229 f"'filesystem' (across repositories)"
230 )
231 return value
234# One tail for every "this file is not UTF-8" refusal, so the two spellings
235# (a foreign BOM, a stray byte) teach the same fix.
236_UTF8_ONLY = "TOML must be UTF-8; re-save the file as UTF-8"
239def _decode(path: Path, raw: bytes) -> str:
240 """*raw* as text, or `ConfigError` saying why it is not UTF-8.
242 TOML's own spec makes UTF-8 mandatory, so a config in any other encoding
243 is malformed by the format's rule and there is nothing to guess at:
244 falling back to latin-1 the way rc files do would invent settings nobody
245 wrote. The one unambiguous reading is a byte-order mark — a UTF-8 one
246 (what Windows editors write) is stripped and the file reads normally, and
247 any other mark names the encoding the refusal can teach against.
248 """
249 if raw.startswith(_encoding.UTF8_BOM):
250 return raw.decode("utf-8-sig")
251 sniffed = _encoding.sniff_bom(raw)
252 if sniffed is not None:
253 raise ConfigError(
254 f"{path}: a {sniffed[0].upper()} byte-order mark — {_UTF8_ONLY}"
255 )
256 try:
257 return raw.decode("utf-8")
258 except UnicodeDecodeError as exc:
259 raise ConfigError(
260 f"{path}: not valid UTF-8 ({exc.reason} at byte {exc.start}) — {_UTF8_ONLY}"
261 ) from exc
264def _read_toml(path: Path, required: bool = False) -> dict[str, Any] | None:
265 """Parse *path*; `None` if absent/unreadable, `ConfigError` if malformed.
267 A missing file is normal (most directories have no config); a file that
268 exists but doesn't parse — bad TOML or bad bytes — is a user mistake that
269 must not be silently read as "no settings". When *required* (an explicit
270 `--config`), an unreadable file is loud too, not silently skipped.
271 """
272 if path.exists() and not path.is_file():
273 # A FIFO or a device would block `read_bytes` without bound; TOML
274 # config is a regular file or it is nothing to read.
275 if required:
276 raise ConfigError(f"{path}: not a regular file — config is read from one")
277 return None
278 try:
279 text = _decode(path, path.read_bytes())
280 except OSError as exc:
281 if required: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 raise ConfigError(f"{path}: {exc.strerror or exc}") from exc
283 return None
284 try:
285 data = tomllib.loads(text)
286 except tomllib.TOMLDecodeError as exc:
287 raise ConfigError(f"{path}: {exc}") from exc
288 return data if isinstance(data, dict) else None
291def _footman_table(path: Path, required: bool = False) -> dict[str, Any]:
292 """The settings in *path* — `[tool.<name>]` for a pyproject, the whole
293 file for anything else. Empty dict if absent/unreadable.
295 The table is this brand's (`[tool.acme]`), so two branded CLIs in one
296 repo read their own settings instead of fighting over one table."""
297 data = _read_toml(path, required=required)
298 if data is None:
299 return {}
300 if path.name == PYPROJECT:
301 tool = data.get("tool")
302 table = tool.get(_paths.config_table()) if isinstance(tool, dict) else None
303 return table if isinstance(table, dict) else {}
304 return data
307def _dir_config(
308 directory: Path,
309 on_warning: Callable[[str], None] | None,
310 on_note: Callable[[str], None] | None = None,
311) -> dict[str, Any]:
312 """Merged footman settings for one directory (footman.toml wins).
314 A malformed file in the discovered cascade is warned about and skipped —
315 one broken pyproject.toml between the repo root and the cwd should not
316 brick every `fm` invocation. User-level-only keys are stripped here,
317 with an advisory through *on_note* (verbose runs wire it; quiet ones
318 don't) pointing at where the key belongs.
319 """
320 merged: dict[str, Any] = {}
321 for name in (PYPROJECT, _paths.config_basename()):
322 try:
323 merged.update(_footman_table(directory / name))
324 except ConfigError as exc:
325 if on_warning is not None:
326 on_warning(f"ignoring malformed config: {exc}")
327 for key in USER_LEVEL_KEYS & merged.keys():
328 del merged[key]
329 if on_note is not None:
330 on_note(
331 f"`{key}` is a user-level setting — it belongs in "
332 f"{_paths.footman_config_file()}; ignoring it in {directory}"
333 )
334 return merged
337DEFAULT_COMPLETION_MAX_AGE_S = 600 # 10 minutes
340def _parse_duration(value: object, *, strict: bool = False) -> int | None:
341 """Seconds from a duration (`"10m"`, `"30s"`, `"1h"`, or a plain int); `None`
342 to disable (`off`/`0`/negative).
344 An unparseable value falls back to the default — or, under *strict*,
345 refuses by name like every other config key. Both readings exist on
346 purpose: the refresh child answers a keystroke and must never crash on
347 a typo, while a real run is exactly where the typo should be taught.
348 """
349 if value is None:
350 return DEFAULT_COMPLETION_MAX_AGE_S
351 if isinstance(value, bool): # bool is an int subclass — treat as on/off
352 return DEFAULT_COMPLETION_MAX_AGE_S if value else None
353 if isinstance(value, int):
354 return value if value > 0 else None
355 if isinstance(value, str):
356 text = value.strip().lower()
357 if text in ("off", "none", ""):
358 return None
359 units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
360 unit = units.get(text[-1:])
361 try:
362 n = int(text[:-1]) if unit else int(text)
363 except ValueError:
364 pass
365 else:
366 seconds = n * (unit or 1)
367 return seconds if seconds > 0 else None
368 if strict:
369 raise ConfigError(
370 f"`completion.max_age` expects a duration — seconds, or a number "
371 f'with a unit ("30s", "10m", "1h", "1d"), or "off" (got {value!r})'
372 )
373 return DEFAULT_COMPLETION_MAX_AGE_S
376def completion_max_age(cfg: dict[str, Any], *, strict: bool = False) -> int | None:
377 """Seconds before the completion cache is considered stale, or `None` if
378 disabled. Reads `[tool.footman] completion.max_age`; default 10 minutes.
380 *strict* is the execution path's reading: a run refuses a mistyped
381 value by name, the way `sort` and the other keys do. The refresh child
382 keeps the quiet default — a background rebuild must never crash, and a
383 keystroke is nobody's moment to learn about a config typo."""
384 completion = cfg.get("completion")
385 raw = completion.get("max_age") if isinstance(completion, dict) else None
386 return _parse_duration(raw, strict=strict)
389def sort_listing(cfg: dict[str, Any]) -> bool:
390 """Whether listings show names alphabetically instead of in definition
391 order. One setting for every human-facing walk of the tree: `--list`,
392 `--tree`, help, and the docs pages. Never the run: what executes and in
393 what order is the DAG's business, not a presentation setting."""
394 raw = cfg.get("sort")
395 if raw is None:
396 return False
397 if not isinstance(raw, bool): 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 raise ConfigError(
399 f"`sort` expects true (list tasks alphabetically) or false "
400 f"(definition order, the default) — got {raw!r}"
401 )
402 return raw
405def user_level_value(key: str) -> Any:
406 """One key from the user-level file alone, or None.
408 For the settings whose meaning must not be steered by a nearer file:
409 the user tasks file's *name* is the user's own writing, so a project's
410 `tasks` key — which renames the project's file — must not reach into
411 the user's home and silently drop the personal rung by looking for a
412 file the user never wrote.
413 """
414 try:
415 return _footman_table(_paths.footman_config_file()).get(key)
416 except ConfigError:
417 return None # the load_config walk warns about it; quiet here
420class BuiltinError(ConfigError):
421 """The user-level `builtin` key is not a list of names or `true`."""
424DISCOVERY_MODES = ("auto", "manual", "internal", "none")
425"""How the built-in set is assembled. One axis, plus a master switch.
427| mode | the brand's own | the discovered list | your own list |
428| ---------- | --------------- | ------------------- | ------------- |
429| `auto` | yes | yes, rewritten | yes |
430| `manual` | yes | yes, left alone | yes |
431| `internal` | yes | ignored | yes |
432| `none` | no | no | yes |
434`builtins.user` is honoured in every mode, so `none` does not mean "off"
435— it means **nothing automatic, only exactly what I named**, which is a
436more useful thing to be able to say and leaves a way back in:
437`user = ["footman.self"]` restores the runner's own commands.
438"""
441def _builtins_table() -> dict[str, Any]:
442 """The user-level `[builtins]` table, or an empty one."""
443 table = user_level_value("builtins")
444 return table if isinstance(table, dict) else {}
447def discovery_mode() -> str:
448 """How this machine assembles its built-in set — `auto` by default.
450 User-level only, like `cascade`: which packages a runner carries is
451 the machine owner's business, not any project's.
452 """
453 value = _builtins_table().get("discovery_mode", "auto")
454 if not isinstance(value, str) or value not in DISCOVERY_MODES:
455 raise BuiltinError(
456 f"builtins.discovery_mode = {value!r} in "
457 f"{_paths.footman_config_file()}: one of "
458 f"{', '.join(DISCOVERY_MODES)}"
459 )
460 return value
463def user_builtin() -> tuple[str, ...]:
464 """The entry points *you* named — `[builtins] user = [...]`.
466 Honoured in every mode, because it is the one list nothing else
467 writes: it says what you asked for, and footman never edits it.
468 """
469 value = _builtins_table().get("user", [])
470 if isinstance(value, list) and all(isinstance(v, str) for v in value):
471 return tuple(value)
472 raise BuiltinError(
473 f"builtins.user in {_paths.footman_config_file()}: a list of "
474 f'entry-point names (user = ["acme_devkit"]), not {value!r}'
475 )
478def discovered_builtin() -> tuple[str, ...]:
479 """The entry points discovery found — machine-written, never by hand.
481 Kept in the data directory rather than the config file so that "do not
482 edit this" is true by construction rather than by comment: the config
483 file stays yours alone, and footman replaces one small file it owns
484 instead of rewriting a table inside a file you also write.
486 Missing, unreadable or malformed all mean the same thing — nothing
487 discovered yet — because this is a record footman can always rebuild
488 (`fm self.add`), never a declaration whose loss should refuse a run.
489 """
490 import json
492 try:
493 raw = json.loads(discovered_path().read_text(encoding="utf-8"))
494 except (OSError, ValueError):
495 return ()
496 names = raw.get("builtin") if isinstance(raw, dict) else None
497 if not isinstance(names, list): 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true
498 return ()
499 return tuple(n for n in names if isinstance(n, str))
502def discovered_path(prefix: str | None = None) -> Path:
503 """Where the discovered list lives: this brand's data directory, keyed
504 by the **environment** the list describes.
506 What it records — which `footman.builtin` entry points are importable —
507 is a property of one Python environment, not of the machine. Keyed by
508 brand alone, the record a `self.install` wrote from the *tool*
509 environment was then applied by every other footman on the machine: a
510 project's dev venv read it, could not import a package that was never
511 installed there, and refused to run. The value varies by environment,
512 so the key does too.
514 Keyed the way manifests are keyed by cwd — one shared store, each entry
515 named for its subject — rather than written into the environment
516 itself, which a `uv tool upgrade` rebuilds and a read-only venv
517 forbids.
519 **Why the record exists at all**, since the query behind it is cheap:
520 mounting already calls `entry_points`, so asking a second time for the
521 `footman.builtin` group costs about 1.6 ms, and a passing reading of
522 this file as a cache concludes it could be replaced by discovering
523 live on every run. It could not. Live discovery is *auto-activation*:
524 any package in the environment advertising that group would mount
525 itself, so `uv add` on a library would put its tasks in your CLI —
526 free to collide with your project's own names — with nothing recording
527 when it happened, and a transitive dependency could change the command
528 surface. This file is the consent boundary. It changes only when
529 someone runs a `self.*` command, which is the act of saying "mount
530 this beside my runner". That is why it is a record and not a cache,
531 and why it is kept rather than derived.
533 A record written before the key existed names no environment, so it is
534 simply not found, which already means "nothing discovered yet": one
535 `self.install` writes the keyed one.
536 """
537 key = _paths.env_key(prefix if prefix is not None else sys.prefix)
538 return _paths.footman_data_dir() / f"builtins-{key}.json"
541def write_discovered(names: Sequence[str], prefix: str) -> None:
542 """Replace the discovered list for the environment at *prefix*.
544 *prefix* is required, and names the environment the list **describes**
545 — never the one doing the writing. `fm self.*` is the only writer, and
546 it discovers by asking the *tool* environment's own interpreter, which
547 is a different world from the process that typed the command. Defaulted
548 to `sys.prefix` this would file the tool environment's answer under
549 whichever venv the person happened to be standing in, which is the bug
550 the key exists to prevent.
551 """
552 import json
554 path = discovered_path(prefix)
555 path.parent.mkdir(parents=True, exist_ok=True)
556 path.write_text(
557 json.dumps({"schema": 1, "builtin": sorted(set(names))}, indent=2) + "\n",
558 encoding="utf-8",
559 )
562def installed_entry_points() -> tuple[str, ...]:
563 """Every `footman.tasks` entry point importable from here, sorted.
565 The `builtin = true` set. Sorted so the tree — and the manifest built
566 from it — is the same on every run whatever order the metadata is read
567 in.
568 """
569 from importlib.metadata import entry_points
571 from footman.compose import ENTRY_POINT_GROUP
573 return tuple(sorted({ep.name for ep in entry_points(group=ENTRY_POINT_GROUP)}))
576def effective_builtin(brand: tuple[str, ...]) -> tuple[str, ...]:
577 """The built-in set this invocation mounts, in mounting order.
579 Three sources, and `builtins.discovery_mode` says which contribute:
580 the brand's own declarations (the product — first, because everything
581 else is an addition to it, never a replacement), then whatever
582 discovery found, then the names you wrote yourself. Duplicates are
583 dropped, so a name the brand already declares is never mounted twice.
584 """
585 mode = discovery_mode()
586 names = [] if mode == "none" else list(brand)
587 if mode in ("auto", "manual"):
588 names += [n for n in discovered_builtin() if n not in names]
589 names += [n for n in user_builtin() if n not in names]
590 return tuple(names)
593def load_config(
594 cwd: Path,
595 ceiling: Path,
596 cli_path: str | None = None,
597 on_warning: Callable[[str], None] | None = None,
598 on_note: Callable[[str], None] | None = None,
599) -> dict[str, Any]:
600 """Merge config from *ceiling* down to *cwd*; *cli_path* overrides all.
602 A malformed discovered file warns (via *on_warning*) and is skipped; a
603 missing or malformed explicit *cli_path* raises `ConfigError` — the user
604 named that file on purpose, so it failing quietly (a typo silently ignored)
605 is not an option. *on_note* carries advisories (a user-level key found in
606 a project file) — verbose runs wire it, others leave it `None`.
607 """
608 if cli_path:
609 # The explicit file is total control: it replaces the global file
610 # and the cascade both — the user named exactly what applies.
611 path = Path(cli_path).expanduser()
612 if not path.is_file():
613 raise ConfigError(f"{path}: no such file")
614 return _footman_table(path, required=True)
616 merged: dict[str, Any] = {}
617 try:
618 # The bottom rung: the user-level file. Whole-file footman settings,
619 # like footman.toml; every project layer cascades over it.
620 merged.update(_footman_table(_paths.footman_config_file()))
621 except ConfigError as exc:
622 if on_warning is not None: 622 ↛ 624line 622 didn't jump to line 624 because the condition on line 622 was always true
623 on_warning(f"ignoring malformed config: {exc}")
624 for directory in _paths.dir_chain(cwd, ceiling):
625 merged.update(_dir_config(directory, on_warning, on_note))
626 return merged