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

1"""Behavioural settings, discovered the same way tasks are. 

2 

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. 

7 

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. 

12 

13Unknown keys are kept but ignored, so newer settings never break an older 

14footman. 

15""" 

16 

17from __future__ import annotations 

18 

19import os 

20import sys 

21import tomllib 

22from collections.abc import Callable, Sequence 

23from pathlib import Path 

24from typing import Any 

25 

26from footman import _encoding, _paths 

27 

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" 

33 

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

40 

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) 

186 

187 

188class ConfigError(Exception): 

189 """A config TOML file exists but cannot be parsed.""" 

190 

191 

192class CascadeError(ConfigError): 

193 """The cascade mode (env or config) is not one of the known walks.""" 

194 

195 

196CASCADE_MODES = ("none", "repo", "filesystem") 

197 

198 

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

204 

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 

232 

233 

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" 

237 

238 

239def _decode(path: Path, raw: bytes) -> str: 

240 """*raw* as text, or `ConfigError` saying why it is not UTF-8. 

241 

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 

262 

263 

264def _read_toml(path: Path, required: bool = False) -> dict[str, Any] | None: 

265 """Parse *path*; `None` if absent/unreadable, `ConfigError` if malformed. 

266 

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 

289 

290 

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. 

294 

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 

305 

306 

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

313 

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 

335 

336 

337DEFAULT_COMPLETION_MAX_AGE_S = 600 # 10 minutes 

338 

339 

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

343 

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 

374 

375 

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. 

379 

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) 

387 

388 

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 

403 

404 

405def user_level_value(key: str) -> Any: 

406 """One key from the user-level file alone, or None. 

407 

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 

418 

419 

420class BuiltinError(ConfigError): 

421 """The user-level `builtin` key is not a list of names or `true`.""" 

422 

423 

424DISCOVERY_MODES = ("auto", "manual", "internal", "none") 

425"""How the built-in set is assembled. One axis, plus a master switch. 

426 

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 | 

433 

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

439 

440 

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 {} 

445 

446 

447def discovery_mode() -> str: 

448 """How this machine assembles its built-in set — `auto` by default. 

449 

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 

461 

462 

463def user_builtin() -> tuple[str, ...]: 

464 """The entry points *you* named — `[builtins] user = [...]`. 

465 

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 ) 

476 

477 

478def discovered_builtin() -> tuple[str, ...]: 

479 """The entry points discovery found — machine-written, never by hand. 

480 

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. 

485 

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 

491 

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

500 

501 

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. 

505 

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. 

513 

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. 

518 

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. 

532 

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" 

539 

540 

541def write_discovered(names: Sequence[str], prefix: str) -> None: 

542 """Replace the discovered list for the environment at *prefix*. 

543 

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 

553 

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 ) 

560 

561 

562def installed_entry_points() -> tuple[str, ...]: 

563 """Every `footman.tasks` entry point importable from here, sorted. 

564 

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 

570 

571 from footman.compose import ENTRY_POINT_GROUP 

572 

573 return tuple(sorted({ep.name for ep in entry_points(group=ENTRY_POINT_GROUP)})) 

574 

575 

576def effective_builtin(brand: tuple[str, ...]) -> tuple[str, ...]: 

577 """The built-in set this invocation mounts, in mounting order. 

578 

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) 

591 

592 

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. 

601 

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) 

615 

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