Coverage for tasks.py: 86%
405 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"""footman's own tasks — dogfooding the runner, run(), and toolroom's handles.
3Run with ``fm <task>`` (or ``uv run fm <task>`` before it is installed).
4Chaining works: ``fm format lint --fix test``.
5"""
7from __future__ import annotations
9import dataclasses
10import re
11from dataclasses import dataclass
12from pathlib import Path
13from typing import Annotated, Any, Literal
15from toolroom import (
16 basedpyright,
17 mypy,
18 pyrefly,
19 pytest,
20 ruff,
21 ruff_format,
22 ty,
23 uv,
24 zensical,
25)
27from footman import (
28 RunFailed,
29 doc,
30 fail,
31 group,
32 lane,
33 parallel,
34 plugin,
35 run,
36 stdin,
37 step,
38 task,
39)
41docs = group("docs", help="Documentation site (Zensical)")
43# The whole repo, as CI lints it (`ruff check .`). Anything narrower lets a
44# tracked file outside src/tests — a comparison script, a scratch demo — pass
45# the gate and fail the build; tracking `notes/` proved it within minutes.
46SRC = (".",)
48# The light checks share one slot: pytest fans out `-n auto` workers of its
49# own, and checkers running wide beside them fight those workers for cores
50# (the `fm --profile check` trace drew it). Serialised against each other —
51# and only each other — their sum still fits inside the suite's shadow.
52CHECKERS = lane("checkers", reason="pytest -n auto owns the cores")
55@task(lanes=(CHECKERS,))
56def lint(fix: Annotated[bool, doc("apply safe fixes in place")] = False):
57 """Lint with ruff."""
58 ruff.check(*SRC, fix=fix)
61@task(lanes=(CHECKERS,))
62def format(check: bool = False):
63 """Format with ruff.
65 Args:
66 check: report instead of rewriting
67 """
68 ruff_format(*SRC, check=check)
71@task(lanes=(CHECKERS,))
72def typecheck():
73 """Type-check with all four gating checkers, in parallel.
75 basedpyright runs `--warnings` so a warning fails the gate exactly as an
76 error does — a warning nobody has to act on is a warning everybody stops
77 reading. mypy is strict on footman itself and checks every test body as
78 consumer code — once per platform (linux from config, darwin and win32
79 by flag), since mypy has no all-platforms mode; ty and pyrefly check
80 every platform at once (`python-platform = "all"`) at the scopes
81 pyproject pins. All four gate: a checker footman uses is a checker the
82 tree is clean against.
83 """
85 def based():
86 basedpyright(warnings=True)
88 # Each run gets its own cache dir: mypy's SQLite cache (2.x default)
89 # does not tolerate three concurrent writers on one file.
90 def mypy_linux():
91 mypy(cache_dir=".mypy_cache/linux")
93 def mypy_darwin():
94 mypy(platform="darwin", cache_dir=".mypy_cache/darwin")
96 def mypy_win32():
97 mypy(platform="win32", cache_dir=".mypy_cache/win32")
99 def run_ty():
100 ty.check()
102 def run_pyrefly():
103 pyrefly("check")
105 parallel(
106 step(based, title="basedpyright")(),
107 step(mypy_linux)(),
108 step(mypy_darwin)(),
109 step(mypy_win32)(),
110 step(run_ty, title="ty")(),
111 step(run_pyrefly, title="pyrefly")(),
112 )
115@task(lanes=(CHECKERS,))
116def typecomplete():
117 """Verify the public API is 100% type-complete (pyright --verifytypes).
119 The exit code is the verdict: 0 only when every public symbol has a
120 fully known type — a new unannotated export fails the gate here before
121 a consumer's checker ever sees it. `--ignoreexternal` scopes the claim
122 to footman's own surface (the pytest plugin necessarily references
123 pytest types, whose completeness is not ours to promise).
124 """
125 basedpyright(verifytypes="footman", ignoreexternal=True)
128@task
129def test(*pytest_args: str):
130 """Run the test suite.
132 Args:
133 pytest_args: forwarded to pytest verbatim
134 """
135 pytest.opts(in_process=False)(*pytest_args)
138@task
139def check():
140 """Run format --check, lint, both type gates, and the covered suite — in parallel.
142 The gate: run it before every commit, and CI runs the same checks. The
143 test step runs under coverage against a local floor, so this one command
144 is the whole local gate — no separate `pytest --cov`.
145 """
146 import os
147 import shutil
148 import tempfile
150 # Coverage data goes to a per-invocation file: two `fm check` runs
151 # sharing the repo's .coverage (a second session, a hook racing a manual
152 # run) clobber the SQLite file mid-write, and the reporter then sees a
153 # bogus partial total with every test passing.
154 #
155 # Per-invocation means one directory per run, and the gate runs on every
156 # commit and from the stop hook on every turn — so left behind they
157 # accumulate at the rate the agent works. A day of one session left 308
158 # of them, 80 MB, and nothing would ever have collected them.
159 cov_dir = tempfile.mkdtemp(prefix="fm-check-cov-")
160 cov_file = os.path.join(cov_dir, "coverage")
162 def covered():
163 # `fail_under` (pyproject, 92) is the *merged* bar: CI combines three
164 # OSes x five Pythons plus the shell jobs, and disables the per-job
165 # threshold because one slice can only ever see its own branches
166 # (Windows run(), pwsh profiles, the other Pythons, the network-gated
167 # tool fetch). One laptop measured against that bar is red by
168 # construction, so the local run gets its own floor — low enough that
169 # a single platform can clear it, high enough to catch a real
170 # regression before CI merges the whole picture.
171 run(
172 "pytest --cov --cov-report= --cov-fail-under=90",
173 # `env=` is the child's whole environment, as subprocess means it:
174 # spread the task's own (which `os.environ` *is* in here) and add.
175 env={**os.environ, "COVERAGE_FILE": cov_file},
176 )
178 try:
179 # The block form carries a task's arguments naturally, and the one
180 # foreign def lifts into a named step — a receipt instead of a
181 # borrowed __name__.
182 with parallel() as p:
183 format(check=True)
184 lint()
185 typecheck()
186 typecomplete()
187 p(step(covered, title="test")())
188 finally:
189 # In a `finally`, because a red gate is the common case and the one
190 # that would otherwise leak: `parallel` raises on the first failing
191 # step, and a run that never reaches its own cleanup line is exactly
192 # the run you make most often while fixing something.
193 shutil.rmtree(cov_dir, ignore_errors=True)
196@task
197def sync():
198 """Sync the environment to the lockfile (uv sync).
200 Runs through the project's own uv (a dev dependency, resolved by the
201 lockfile), so the same uv version writes uv.lock on every laptop and in CI.
202 A mismatched system uv silently rewriting the lock is the source of the
203 one-line churn this avoids.
204 """
205 uv.sync()
208def _scaffold_suggest_demo() -> str:
209 """A scratch project whose tasks.py is typing.md's dynamic-completion
210 example, extracted verbatim from the page — the recording exercises the
211 documented code by construction, so example and cast cannot drift."""
212 import re
213 import tempfile
214 from pathlib import Path
216 section = Path("docs/typing.md").read_text(encoding="utf-8")
217 section = section.split("## Dynamic completion", 1)[1]
218 code = re.search(r"```python\n(.*?)```", section, re.S)
219 assert code is not None, "typing.md lost its dynamic-completion example"
220 demo = Path(tempfile.gettempdir()) / "footman-suggest-demo"
221 demo.mkdir(parents=True, exist_ok=True)
222 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
223 (demo / "tasks.py").write_text(code.group(1), encoding="utf-8")
224 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves
225 return str(demo)
228def _scaffold_completion_demo() -> str:
229 """The project every completion cast is recorded against.
231 One small CLI, so the five recordings differ only in *shell behaviour* —
232 which is the whole reason to show five. Recording against footman's own
233 tasks.py meant the casts drifted whenever the gate did, and the tasks a
234 reader saw were ones only this repo has.
236 It carries what the casts need to show: documented options (a
237 description column has to have something to put in it), a nested group
238 (dotted addressing), and a `Literal` (value completion).
239 """
240 import tempfile
241 from pathlib import Path
243 demo = Path(tempfile.gettempdir()) / "footman-completion-demo"
244 demo.mkdir(parents=True, exist_ok=True)
245 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
246 (demo / "tasks.py").write_text(
247 "from typing import Literal\n"
248 "from footman import group, task\n\n"
249 "@task\n"
250 "def build(release: bool = False, jobs: int = 4):\n"
251 ' """Compile and bundle.\n\n'
252 " Args:\n"
253 " release: optimise and strip symbols\n"
254 " jobs: parallel compile jobs\n"
255 ' """\n\n'
256 "@task\n"
257 "def test(watch: bool = False, marker: str = ''):\n"
258 ' """Run the test suite.\n\n'
259 " Args:\n"
260 " watch: re-run on every file change\n"
261 " marker: only tests carrying this marker\n"
262 ' """\n\n'
263 "@task\n"
264 "def lint(fix: bool = False):\n"
265 ' """Check style and types.\n\n'
266 " Args:\n"
267 " fix: apply safe fixes in place\n"
268 ' """\n\n'
269 'deploy = group("deploy", help="Ship it somewhere")\n\n'
270 "@deploy.task\n"
271 "def staging():\n"
272 ' "Deploy to staging."\n\n'
273 "@deploy.task\n"
274 "def prod(region: Literal['eu', 'us', 'ap'] = 'eu'):\n"
275 ' """Deploy to production.\n\n'
276 " Args:\n"
277 " region: which region to ship to\n"
278 ' """\n',
279 encoding="utf-8",
280 )
281 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves
282 return str(demo)
285def _scaffold_hero_demo() -> str:
286 """The project the front page's recording is made against.
288 Its point is the one thing a TOML-configured runner cannot do: a
289 parameter whose candidates are *computed*. `branch` completes to the
290 real branches of a real git repo, so the recording shows footman asking
291 git rather than reading a list somebody typed out. It is a git repo for
292 that reason alone: the branches have to be real for the demo to mean
293 anything.
295 The tasks file is the one printed above the recordings on the front
296 page, read from the page itself, so the code a reader copies is the
297 code the shells were driven against. The same construction typing.md's
298 dynamic-completion cast uses.
299 """
300 import re
301 import shutil
302 import subprocess
303 import tempfile
304 from pathlib import Path
306 page = Path("docs/index.md").read_text(encoding="utf-8")
307 marker = "<!-- hero-demo:"
308 assert marker in page, "index.md lost its hero-demo marker"
309 code = re.search(r"```python\n(.*?)```", page.split(marker, 1)[1], re.S)
310 assert code is not None, "the hero-demo marker no longer leads a python block"
312 demo = Path(tempfile.gettempdir()) / "footman-hero-demo"
313 shutil.rmtree(demo, ignore_errors=True)
314 demo.mkdir(parents=True, exist_ok=True)
315 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
316 (demo / "tasks.py").write_text(code.group(1), encoding="utf-8")
317 git = ["git", "-C", str(demo)]
318 subprocess.run([*git, "init", "-q", "-b", "main"], check=True)
319 subprocess.run([*git, "add", "-A"], check=True)
320 subprocess.run(
321 [
322 *git,
323 "-c",
324 "user.email=demo@example.com",
325 "-c",
326 "user.name=demo",
327 # Signing is global config, and this throwaway repo has no business
328 # reaching for the maintainer's key: a gated agent turns a docs
329 # build into `failed to write commit object`, and an unattended one
330 # (CI, a release) would hang on a prompt nobody can answer. The
331 # identity above is overridden for the same reason.
332 "-c",
333 "commit.gpgsign=false",
334 "commit",
335 "-qm",
336 "demo",
337 ],
338 check=True,
339 )
340 for branch in ("feat/typed-flags", "fix/completion-cache", "release/v0.40.0"):
341 subprocess.run([*git, "branch", branch], check=True)
342 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves
343 return str(demo)
346def _scaffold_interactive_demo() -> str:
347 """A scratch project with one task per interactive shape — an `ask()`
348 parameter, a `confirm=` gate, and an `interactive=True` wizard — so
349 orchestration.md's interactive-input casts play the documented shapes."""
350 import tempfile
351 from pathlib import Path
353 demo = Path(tempfile.gettempdir()) / "footman-interactive-demo"
354 demo.mkdir(parents=True, exist_ok=True)
355 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
356 (demo / "tasks.py").write_text(
357 "from typing import Annotated\n"
358 "from footman import ask, prompt, select, task\n\n"
359 "@task\n"
360 "def release(version: Annotated[str, ask()]):\n"
361 ' "Cut a release."\n'
362 ' print(f"Releasing {version}")\n\n'
363 '@task(confirm="Deploy to production?")\n'
364 "def deploy():\n"
365 ' "Ship it."\n'
366 ' print("Deploying to production")\n\n'
367 "@task(interactive=True)\n"
368 "def scaffold():\n"
369 ' "Start a project."\n'
370 ' name = prompt("project name? ")\n'
371 ' kind = select("what kind?", ["library", "app", "plugin"])\n'
372 ' print(f"Scaffolding {name} ({kind})")\n',
373 encoding="utf-8",
374 )
375 return str(demo)
378def _bash_renders_value_menus() -> bool:
379 """Whether the bash on PATH draws candidates after `--opt=`.
381 bash 3.2 — which is what macOS ships, and still the default `/bin/bash`
382 there — does not, so a hero recording made on a stock Mac shows the
383 typed line and no menu. bash 4+ (Homebrew's, and every Linux runner's)
384 does. The completion itself is fine either way: footman answers every
385 argv shape bash sends, verified against the resolver directly.
387 So the beat is asserted where the shell can show it, rather than
388 dropped everywhere to accommodate the oldest bash in the room.
389 """
390 import shutil
392 from toolroom import bash
394 if shutil.which("bash") is None:
395 return False
396 # toolroom asks the binary itself and reads the answer with the same
397 # parser its stub extractor uses, outside the task context — so a
398 # dry run or a recording() cannot answer this one for us.
399 return bash.installed_version() >= (4,)
402def _record_cast(
403 *keys: str,
404 beats: list[str],
405 out: Path,
406 shell: Literal["zsh", "bash", "fish", "pwsh", "nushell"],
407 width: int,
408 height: int,
409 cwd: Path,
410 pace: float = 1.2,
411) -> None:
412 """Record a cast, and re-record it once if the interaction went missing.
414 `cast()` already retries a session that produced *no* frames — a shell
415 too cold to draw anything inside one settle window. It cannot retry the
416 other failure, because it looks identical to success: a session that
417 boots, misses the keystrokes, and renders a perfectly valid SVG of a
418 prompt sitting there. Only the caller knows what the recording was
419 supposed to contain, so only the caller can tell the two apart.
421 That failure took down a docs build on a loaded runner — pwsh, whose
422 .NET startup is the slowest of the five — and a failed docs build means
423 the published site silently stays stale. Cheap insurance: the happy path
424 pays nothing, and a mute capture costs one more recording instead of the
425 whole build.
426 """
427 from footman.tasks.docs import cast as taskdocs_cast
429 for attempt in (1, 2):
430 taskdocs_cast(
431 *keys,
432 out=out,
433 shell=shell,
434 width=width,
435 height=height,
436 cwd=cwd,
437 pace=pace,
438 # The retry is not the same recording again: a drop means the
439 # quiet-detection read a CPU-starved shell as settled and typed
440 # into a menu that had not opened. The second take triples the
441 # quiet windows (`steady`), which is patience, not slowness —
442 # a loaded CI runner dropped the nushell hero cast on BOTH
443 # attempts when the retry only repeated the first take.
444 steady=1.0 if attempt == 1 else 3.0,
445 )
446 try:
447 _assert_cast_captured(out, beats)
448 except RuntimeError:
449 if attempt == 2:
450 raise
451 print(f"note: {out.name} came back mute — re-recording, steadier")
452 continue
453 return
456def _assert_cast_captured(svg: Path, needles: list[str]) -> None:
457 """A cast whose answer landed too early (a timing regression) still renders
458 a valid SVG — just without the interaction. Strip the markup and fail the
459 build loudly if a prompt, its answer, or the result went missing, rather
460 than shipping a mute recording no test would catch."""
461 import re
463 text = re.sub(
464 r" ", "", re.sub(r"<[^>]+>", "", svg.read_text(encoding="utf-8"))
465 )
466 missing = [n for n in needles if n not in text]
467 if missing:
468 raise RuntimeError(f"{svg.name} dropped {missing} — cast timing regressed?")
471# The release heading the home page's latest block is lifted from. Named so
472# the drift test pins *this* reading rather than a copy of it — a copy is
473# how the two drifted apart for nine releases.
474_LATEST_HEADING = r"^## \[(\d[^\]]+)\] [-—] (.+?)$"
477def _write_latest_changes() -> None:
478 """Extract the newest release's section from CHANGELOG.md into a
479 collapsed admonition the home page includes — version, date, and the
480 entries, straight from the one source of truth. Rolling the changelog
481 for a release updates the home page by construction."""
482 import re
483 from pathlib import Path
485 text = Path("CHANGELOG.md").read_text(encoding="utf-8")
486 # Either separator: Keep a Changelog spells the heading with a hyphen
487 # and this file used an em dash for its first forty-odd releases. The
488 # em-dash-only reading did not fail loudly when the convention changed
489 # — `search` simply found the newest heading that still matched, so the
490 # home page advertised a release from nine versions back and nothing
491 # said so. Accepting both keeps the archive parsable and cannot silently
492 # pick the wrong entry again.
493 head = re.search(_LATEST_HEADING, text, re.M)
494 if head is None: # a fresh fork with only [Unreleased]: skip quietly
495 body_block = ""
496 else:
497 rest = text[head.end() :]
498 nxt = re.search(r"^## \[", rest, re.M)
499 entries = rest[: nxt.start() if nxt else len(rest)].strip()
500 indented = "\n".join(
501 f" {line}" if line else "" for line in entries.splitlines()
502 )
503 title = f"Latest release: {head.group(1)} — {head.group(2)}"
504 # No links in here: the file is validated as its own page, where
505 # relative targets differ from the including page's. The home page
506 # carries the changelog link itself, right after the include.
507 body_block = f'??? info "{title}"\n\n{indented}\n'
509 out = Path("_generated/latest-changes.md")
510 out.parent.mkdir(parents=True, exist_ok=True)
511 out.write_text(body_block, encoding="utf-8")
512 print(f"wrote {out}")
515def _write_llms_txt() -> None:
516 """Generate docs/llms.txt and docs/llms-full.txt from the nav.
518 llms.txt (https://llmstxt.org) is the agent-facing index of the docs
519 site: every page in nav order, each with a one-line description pulled
520 from its first prose line. llms-full.txt is the whole site's text in one
521 file. Both are derived — never hand-edited — and the build copies them
522 into site/ like any other docs/ file.
523 """
524 import tomllib
525 from pathlib import Path
527 site = "https://willemkokke.github.io/footman/"
528 with open("zensical.toml", "rb") as fh:
529 nav = tomllib.load(fh)["project"]["nav"]
531 pages: list[tuple[str, str]] = [] # (title, md filename), nav order
533 def walk(items: list[dict[str, Any]]) -> None:
534 for item in items:
535 for title, value in item.items():
536 if isinstance(value, list):
537 walk(value)
538 else:
539 pages.append((title, value))
541 walk(nav)
543 def first_sentence(text: str) -> str:
544 """The first sentence of the page's first *prose* paragraph.
546 Prose, strictly: list items and table rows are structure, not
547 description — a quiz admonition's `1. What four conditions…` and a
548 generated page's `| Task | Description |` both used to be served
549 as the page's one-line summary, which read as junk in every agent
550 index built on this file.
551 """
552 if text.startswith("---\n"): # strip front-matter
553 _, _, text = text.partition("\n---\n")
554 skip = ("#", "---", "[![", "!!!", ">", "<", "--8<--", "|", "- ", "* ")
555 fenced = False
556 admonition = False
557 paragraph: list[str] = []
558 for raw in text.splitlines():
559 line = raw.strip()
560 if admonition:
561 # An admonition's whole indented body is chrome — the quiz
562 # block's numbered questions AND its "skip ahead" prose
563 # both read as junk when served as a page description.
564 if not line or raw.startswith((" ", "\t")):
565 continue
566 admonition = False
567 if line.startswith("!!!"):
568 admonition = True
569 continue
570 if line.startswith("```"):
571 fenced = not fenced
572 continue
573 digits = line.split(".", 1)[0]
574 numbered = digits.isdigit() and line.startswith(f"{digits}.")
575 if fenced or line.startswith(skip) or numbered:
576 if paragraph: 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true
577 break # structure ends the paragraph it follows
578 continue
579 if not line:
580 if paragraph:
581 break # paragraph complete
582 continue
583 paragraph.append(line)
584 prose = " ".join(paragraph)
586 # A relative .md link 404s the moment an agent resolves it against
587 # the site root; rewrite to the published URL, index pages included.
588 def absolute(match: re.Match[str]) -> str:
589 target = match.group(1)
590 if target == "index.md" or target.endswith("/index.md"):
591 stem = target.removesuffix("index.md").rstrip("/")
592 return f"]({site}{stem + '/' if stem else ''})"
593 return f"]({site}{target.removesuffix('.md')}/)"
595 prose = re.sub(r"\]\((?!https?://)([^)#]+\.md)\)", absolute, prose)
596 end = prose.find(". ")
597 return prose[: end + 1] if end != -1 else prose
599 index = [
600 "# footman",
601 "",
602 "> A Python task runner: typed function signatures become real CLI "
603 "flags, modules become command groups, independent tasks run in "
604 "parallel by default, and shell completion answers from a cached "
605 "manifest without importing your code (building that cache imports "
606 "it once, in a detached subprocess). Machine surface: `fm --json "
607 "--list` (catalog), `fm --json <chain>` (results), `fm --help <task>`.",
608 "",
609 "## Docs",
610 "",
611 ]
612 full = ["# footman — full documentation", ""]
614 def resolve_snippets(text: str) -> str:
615 """Inline `--8<-- "path"` includes, the way the site build does.
617 llms-full.txt promises the whole site's text in one file; a raw
618 directive where a section belongs is six missing sections and a
619 syntax no reader resolves. Paths are repo-relative, matching the
620 pymdownx configuration; a missing file keeps the directive so the
621 gap is visible rather than silent.
622 """
624 def inline(match: re.Match[str]) -> str:
625 path = Path(match.group(1))
626 if not path.is_file():
627 return match.group(0)
628 return resolve_snippets(path.read_text(encoding="utf-8").rstrip())
630 return re.sub(
631 r"""^--8<-- ["']([^"']+)["']$""", inline, text, flags=re.MULTILINE
632 )
634 for title, name in pages:
635 if name == "coverage.md":
636 continue # an embedded HTML report; nothing for a reader here
637 text = (Path("docs") / name).read_text(encoding="utf-8")
638 if name.endswith("index.md"):
639 # A directory's index publishes at the directory, not at
640 # …/index/ — the generated Task reference 404'd on the wrong
641 # form for as long as nothing read this file critically.
642 stem = name.removesuffix("index.md").rstrip("/")
643 url = f"{site}{stem + '/' if stem else ''}"
644 else:
645 url = f"{site}{name.removesuffix('.md')}/"
646 # Snippets resolve before anything reads the page: the description
647 # of a page that IS an include (changelog.md) lives in the included
648 # text, and llms-full.txt carries the sections, not the directives.
649 resolved = resolve_snippets(text.rstrip())
650 desc = first_sentence(resolved)
651 index.append(f"- [{title}]({url}): {desc}" if desc else f"- [{title}]({url})")
652 full += [
653 "",
654 "---",
655 "",
656 f"<!-- {title} — {url} -->",
657 "",
658 resolved,
659 ]
660 (Path("docs") / "llms.txt").write_text("\n".join(index) + "\n", encoding="utf-8")
661 joined = "\n".join(full) + "\n"
662 (Path("docs") / "llms-full.txt").write_text(joined, encoding="utf-8")
665@docs.task(infinite=True) # runs until Ctrl-C — and now says so
666def serve():
667 """Build and serve the docs with live reload."""
668 _write_llms_txt()
669 run("zensical serve")
672# The output contract, dogfooded: the annotation below bakes this shape into
673# the manifest, `fm --json docs.coverage` carries it as `returned_schema`
674# beside the data, `fm --describe=docs.coverage` renders it as JSON Schema,
675# and a rename here goes red in our own gate via `returned_mismatch`.
676@dataclass
677class CoverageReport:
678 percent: float
679 statements: int
680 missing: int
681 report: Path
684@docs.task
685def coverage() -> CoverageReport:
686 """Generate the coverage HTML report into docs/htmlcov (embedded in the site).
688 Returns:
689 The measured total and where the HTML report landed.
690 """
691 import json
692 import tempfile
694 with tempfile.TemporaryDirectory(prefix="fm-cov-totals-") as tmp:
695 totals_file = Path(tmp) / "coverage.json"
696 run(
697 "pytest --cov --cov-report=html:docs/htmlcov "
698 f"--cov-report=json:{totals_file} -q"
699 )
700 totals = json.loads(totals_file.read_text("utf-8"))["totals"]
701 return CoverageReport(
702 percent=round(totals["percent_covered"], 2),
703 statements=totals["num_statements"],
704 missing=totals["missing_lines"],
705 report=Path("docs/htmlcov/index.html"),
706 )
709# --- The API reference page is generated: correct by construction --------
710# Input: the typechecked TYPE_CHECKING export table in src/footman/
711# __init__.py (a wrong module path there fails basedpyright). Presentation:
712# the declaration below. The build refuses on divergence in either
713# direction, naming the name — a new export cannot ship undocumented, and a
714# stale entry cannot outlive its export. Directives use PUBLIC paths
715# (`::: footman.run`), so anchors and the objects.inv inventory carry the
716# contract spelling, not the defining module.
718_API_INTRO = """\
719<!-- Generated by 'fm docs.api' — edit _API_SECTIONS in tasks.py, not this file. -->
721# API reference
723Auto-generated from the source via
724[mkdocstrings](https://mkdocstrings.github.io/). Everything here is importable
725straight from the `footman` package (`from footman import task, run, App`).
726"""
728# (section title, intro prose or "", entries). An entry is a dotted path
729# under `footman.`; its first component must be a root export, or the whole
730# entry sits in _API_EXTRA with a reason.
731_API_SECTIONS: list[tuple[str, str, list[str]]] = [
732 ("Defining tasks", "", ["task", "group", "Group", "expose"]),
733 (
734 "Availability gates",
735 "Stack these above `@task` to list a task as unavailable (with a "
736 "reason) where it can't run. Every availability gate is evaluated "
737 "live, and all failures are collected.",
738 ["requires", "requires_dep", "requires_tool", "requires_env"],
739 ),
740 (
741 "Running commands",
742 "",
743 [
744 "run",
745 "Result",
746 "ResultView",
747 "AuditEntry",
748 "Argv",
749 "RunFailed",
750 "parallel",
751 "step",
752 "pre_record",
753 "passthrough",
754 "inherited",
755 ],
756 ),
757 ("Failing on purpose", "", ["fail", "Failed", "TimedOut"]),
758 ("Progress", "", ["progress", "track"]),
759 (
760 "Profiling from inside a task",
761 "",
762 ["section", "mark", "stream", "Stream", "Section"],
763 ),
764 (
765 "Asking the person running it",
766 "",
767 ["prompt", "confirm", "select", "attended", "tty", "colored"],
768 ),
769 (
770 "The process boundary",
771 "stdin binds to typed parameters, and a `Stdout[T]` return owns "
772 "stdout. The full contract lives on [Pipelines](pipelines.md) and "
773 "[JSON output](json.md).",
774 ["Stdin", "stdin", "Stdout", "stdout"],
775 ),
776 (
777 "The working directory & lanes",
778 "",
779 ["cwd", "chdir", "Lane", "lane", "cwd_lane", "console_lane"],
780 ),
781 (
782 "Where a task keeps things",
783 "Two folders footman writes, both created on access. `cache_dir()` "
784 "is derived data the collector sweeps by age; `data_dir()` is "
785 "durable and machine-local — credentials, tokens, generated assets "
786 "— and is never collected. The rest are places footman *reads*, "
787 "created by nobody but you: `config_dir()` and `config_file()` are "
788 "your user-level settings, `user_tasks_file()` the personal rung "
789 "riding every project, and `project_root()` the top of this "
790 "invocation's cascade (`None` outside a project). `fm self.path` "
791 "is the same set from the command line. Where each one lands is the "
792 "CLI's business, not the task's; see "
793 "[Custom CLIs](custom-cli.md#two-folders-of-your-own).",
794 [
795 "cache_dir",
796 "data_dir",
797 "config_dir",
798 "config_file",
799 "user_tasks_file",
800 "project_root",
801 ],
802 ),
803 (
804 "Which CLI is running",
805 "For output that has to call the CLI back — a generated CI workflow, "
806 "a README line — so a branded runner emits its own name instead of "
807 "`fm`. `prog()` is the command someone types, `dist()` the package "
808 "that ships it (`None` when the brand never declared one). Inside a "
809 "task body `ctx.prog` says the same and is the better reach: it is "
810 "the invocation's own answer rather than process state.",
811 ["prog", "dist"],
812 ),
813 ("Fetching", "", ["fetch", "FetchError"]),
814 (
815 "The task context",
816 "`given()` answers whether the caller supplied a parameter or footman "
817 "filled it in — the difference between asking for the default and "
818 "having no opinion, which the value alone cannot tell you.",
819 ["Context", "given", "use_context"],
820 ),
821 ("Composing tasks", "", ["include", "plugin", "capture"]),
822 (
823 "The invocation, and editing the discovered tree",
824 "`@pre_tasks` runs a hook once per invocation, over the fully-merged "
825 "cascade and before anything else — see "
826 "[Hooks & plugin options](hooks.md#editing-the-discovered-tree). It is "
827 "handed the `Invocation`, whose `tasks` is a `Tasks` view; iterating "
828 "or indexing that yields a `TaskView` that reads and edits one task. "
829 "The per-task pair — `@pre_task` and `@post_task` — runs around every "
830 "execution; see "
831 "[Around every task](hooks.md#around-every-task-pre_task-and-post_task).",
832 [
833 "pre_tasks",
834 "pre_bind",
835 "pre_task",
836 "post_task",
837 "post_tasks",
838 "GlobalOption",
839 "config_section",
840 "wrap_task",
841 "wrap_bind",
842 "Invocation",
843 "Tasks",
844 "TaskView",
845 ],
846 ),
847 ("Custom CLI", "", ["App", "Brand", "main"]),
848 (
849 "Typed-parameter helpers",
850 "",
851 [
852 "Many",
853 "Arg",
854 "Forward",
855 "forward",
856 "NoSplit",
857 "nosplit",
858 "Hidden",
859 "hidden",
860 "suggest",
861 "exists",
862 "isfile",
863 "isdir",
864 "Exists",
865 "IsFile",
866 "IsDir",
867 "matching",
868 "between",
869 "env",
870 "check",
871 "default",
872 "doc",
873 "ask",
874 "Secret",
875 ],
876 ),
877 (
878 "Docstrings",
879 "Standalone (stdlib-only, no footman imports) — reusable outside footman.",
880 ["docstrings.parse", "docstrings.Docstring"],
881 ),
882 (
883 "Markdown export",
884 "Pure functions over manifest tree nodes — see "
885 "[Your tasks, documented](taskdocs.md) for the task-level surface.",
886 ["markdown.render_page", "markdown.render_site"],
887 ),
888 ("Testing", "", ["Runner", "testing.InvokeResult", "recording"]),
889]
891# Entries documented beyond the export table — each with its reason, never
892# silent — and exports deliberately absent from the page, ditto.
893_API_EXTRA: dict[str, str] = {
894 "testing.InvokeResult": "Runner.invoke's return type",
895}
896_API_OMITTED: dict[str, str] = {}
899@docs.task
900def api(out: Path = Path("docs/api.md")):
901 "Generate the API reference from the export table; refuse on drift."
902 out.parent.mkdir(parents=True, exist_ok=True)
903 out.write_text(_api_markdown(), encoding="utf-8")
906def _api_markdown() -> str:
907 """The API page content, validated against the export table.
909 Pure — no filesystem writes — so the docs-drift test can join the
910 generated page into its blob by construction: a fresh checkout has no
911 docs/api.md on disk until the docs build runs."""
912 import ast
914 src = Path("src/footman/__init__.py").read_text("utf-8")
915 exported: set[str] = set()
916 for node in ast.walk(ast.parse(src)):
917 if (
918 isinstance(node, ast.Assign)
919 and isinstance(node.value, ast.List | ast.Tuple)
920 and any(getattr(t, "id", "") == "__all__" for t in node.targets)
921 ):
922 exported = {ast.literal_eval(e) for e in node.value.elts}
923 exported -= {"__version__"}
925 entries = [e for _, _, names in _API_SECTIONS for e in names]
926 dupes = sorted({e for e in entries if entries.count(e) > 1})
927 if dupes: 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true
928 fail(f"docs.api: duplicated entries: {', '.join(dupes)}")
929 covered = {e.split(".")[0] for e in entries}
930 problems = []
931 missing = sorted(exported - covered - set(_API_OMITTED))
932 if missing: 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true
933 problems.append(
934 f"exported but undocumented: {', '.join(missing)} — add each to "
935 f"a section in _API_SECTIONS, or to _API_OMITTED with a reason"
936 )
937 stale = sorted(
938 e for e in entries if e.split(".")[0] not in exported and e not in _API_EXTRA
939 )
940 if stale: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true
941 problems.append(
942 f"documented but not exported: {', '.join(stale)} — drop the "
943 f"entry, or record it in _API_EXTRA with a reason"
944 )
945 ghosts = sorted(set(_API_OMITTED) & covered) + sorted(set(_API_OMITTED) - exported)
946 if ghosts: 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 problems.append(
948 f"_API_OMITTED disagrees with the page or the exports: "
949 f"{', '.join(dict.fromkeys(ghosts))}"
950 )
951 if problems: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true
952 fail(
953 "docs.api: the page and the export table disagree — " + "; ".join(problems)
954 )
956 parts = [_API_INTRO]
957 for title, intro, names in _API_SECTIONS:
958 parts.append(f"\n## {title}\n")
959 if intro:
960 parts.append(f"\n{intro}\n")
961 for name in names:
962 parts.append(f"\n::: footman.{name}\n")
963 return "".join(parts)
966def _generate_pages() -> None:
967 """Every generated page except the pty-recorded shots and casts.
969 The cheap, pure prefix of the docs build — API reference, task pages,
970 the grammar/config/errors tables, latest-changes — split out so the
971 llms drift guard can regenerate it from an empty checkout: the guard
972 failed on a fresh CI tree precisely because these pages are gitignored
973 and only the full build had ever written them.
974 """
975 import shutil
976 from pathlib import Path
978 from footman.tasks.docs import config as taskdocs_config
979 from footman.tasks.docs import errors as taskdocs_errors
980 from footman.tasks.docs import globals_ as taskdocs_globals
981 from footman.tasks.docs import notes as taskdocs_notes
982 from footman.tasks.docs import page as taskdocs_page
983 from footman.tasks.docs import site as taskdocs_site
985 # Start from nothing, which is the only state that ever gets validated:
986 # the trees are gitignored, so CI builds them from an empty checkout
987 # while a working copy accumulates whatever an older layout left behind.
988 # Clearing costs nothing: every file below is rewritten unconditionally.
989 # Two generated homes on purpose: snippet sources live in `_generated/`
990 # *outside* docs/, so the site never publishes them as orphan pages —
991 # only what a page `--8<--`-includes reaches the reader. `docs/_generated`
992 # keeps what genuinely is site content: the errors reference (in the
993 # nav) and the shots/ images the pages embed.
994 for stale in (Path("docs/_generated"), Path("docs/tasks"), Path("_generated")):
995 shutil.rmtree(stale, ignore_errors=True)
997 # The API reference regenerates from the export table every build — the
998 # page cannot omit an export, because the generator refuses to.
999 api()
1000 taskdocs_site(Path("docs/tasks"), all=True)
1001 taskdocs_page(
1002 target="docs",
1003 heading=3,
1004 flavor="material",
1005 out=Path("_generated/tasks-page.md"),
1006 )
1007 # The CLI reference's global-options table, from the grammar itself —
1008 # reference.md snippet-includes it, so it can't drift from --help.
1009 taskdocs_globals(out=Path("_generated/globals.md"))
1010 # The [tool.footman] key table, from the keys the runner recognises —
1011 # configuration.md snippet-includes it, so it can't fall behind.
1012 taskdocs_config(out=Path("_generated/config.md"))
1013 # The note-kind table, from the runtime's own registry — notes.md
1014 # snippet-includes it, so a new kind cannot ship undocumented.
1015 taskdocs_notes(out=Path("_generated/notes.md"))
1016 # Every error and note the runtime can say, extracted from the source —
1017 # a reference page that regenerates each build and so can never drift.
1018 taskdocs_errors(out=Path("docs/_generated/errors.md"))
1019 _write_latest_changes()
1022@docs.task(name="build")
1023def docs_build(check: bool = False): # pragma: no cover — see below
1024 """Build the docs site into ./site; regenerates llms.txt and docs/tasks/.
1026 Not unit-tested, and deliberately: the body is orchestration over
1027 zensical, a pty screenshotter and five real shells, so a test could only
1028 stub twenty collaborators and assert the call order back — a change
1029 detector that passes while the site breaks. Its real test is CI's
1030 strict docs build, which runs the whole thing against the actual tools.
1031 The pieces with logic of their own — the llms.txt generator, the cast
1032 guard, the scratch projects — are tested separately, where a test can
1033 say something true.
1035 Args:
1036 check: build strictly (what CI runs)
1037 """
1038 # Dogfood the first-party plugin: regenerate the live task-reference
1039 # pages (site mode) and the single-page example the taskdocs guide
1040 # embeds (page mode). Plain calls — @task returns plain functions.
1041 # Order matters on a fresh checkout: llms.txt walks the nav, and the
1042 # nav includes the generated tasks/ pages — generate them first.
1043 from pathlib import Path
1045 from footman.tasks.docs import shots as taskdocs_shots
1047 _generate_pages()
1048 # Terminal screenshots, captured from the real CLI on a pty and framed
1049 # as SVGs — the pages show footman exactly as a terminal does, and a
1050 # rebuild regenerates them, so they cannot drift either.
1051 shot = Path("docs/_generated/shots")
1052 taskdocs_shots("--tree", out=shot / "tree.svg", width=100)
1053 taskdocs_shots("--help", out=shot / "help.svg", width=100)
1054 taskdocs_shots("format", "lint", out=shot / "run.svg", width=72)
1055 # The animated ones: five real shells, one script, one demo project, so
1056 # the recordings differ only in shell behaviour — which is the entire
1057 # reason to show five. Same regeneration rule as the stills: the docs
1058 # play what the CLI does, because they are recordings of it doing it.
1059 from footman.tasks.docs import cast as taskdocs_cast
1061 demo = Path(_scaffold_completion_demo())
1062 for sh in ("zsh", "bash", "fish", "pwsh", "nushell"):
1063 # Two per-shell adjustments, both about *default* behaviour rather
1064 # than a tuned config nobody reading this has:
1065 #
1066 # Vanilla bash reveals the candidate list on the SECOND Tab — the
1067 # first only rings the bell — so it presses twice everywhere the
1068 # script means "show me".
1069 tab = ("<TAB>", "<TAB>") if sh == "bash" else ("<TAB>",)
1070 # PSReadLine's menu swallows Ctrl-C while it is open, so the line
1071 # survived and the next keystrokes landed inside it — one recording
1072 # ended on `fm build -- fm deploy.release`. Escape closes the menu
1073 # first, and then the cancel reaches the line.
1074 clear = ("<ESC>", "<CTRL-C>") if sh == "pwsh" else ("<CTRL-C>",)
1075 out = shot / f"{sh}-cast.svg"
1076 # A cast whose keystrokes raced the shell still renders a valid SVG,
1077 # just a mute one. Pin the beats every shell can show; descriptions
1078 # are checked only where the shell has a column for them (bash has
1079 # none, pwsh puts them in a tooltip).
1080 beats = ["build", "deploy", "--release", "--jobs", "deploy.staging"]
1081 if sh in ("zsh", "fish", "nushell"):
1082 beats += ["Compileandbundle", "optimiseandstripsymbols"]
1083 _record_cast(
1084 # Every task, each with its summary.
1085 "fm ",
1086 *tab,
1087 "<WAIT>",
1088 # Prefix-complete to one of them.
1089 "bui",
1090 "<TAB>",
1091 "<WAIT:600>",
1092 # Its options — and what each one does.
1093 " --",
1094 *tab,
1095 "<WAIT>",
1096 # Descend a group by its dotted address.
1097 *clear,
1098 "fm deploy.",
1099 *tab,
1100 "<WAIT>",
1101 beats=beats,
1102 out=out,
1103 shell=sh,
1104 width=80,
1105 height=18,
1106 cwd=demo,
1107 )
1108 # The front page's recording, one per shell behind a tab. It shows the
1109 # beat the per-shell pages above cannot: `--branch` completes to the
1110 # real branches of a real git repo, because its candidates are computed
1111 # by a function rather than listed in a config file.
1112 hero = Path(_scaffold_hero_demo())
1113 # fish leads because the tab order decides the front page's default.
1114 hero_shells: tuple[Literal["zsh", "bash", "fish", "pwsh", "nushell"], ...] = (
1115 "fish",
1116 "zsh",
1117 "bash",
1118 "pwsh",
1119 "nushell",
1120 )
1121 # What a shell needs after a completion is taken, before the next token
1122 # can be typed. fish, zsh and bash insert a space themselves; nushell
1123 # does not, so `deploy` and `-` ran together into one token that
1124 # completed nothing. PSReadLine leaves its menu open and lets the next
1125 # key *replace* the selection — `-` wiped `deploy` off the line — so it
1126 # needs the menu accepted first, which is what Right does.
1127 accept: dict[str, tuple[str, ...]] = {
1128 "nushell": ("<SPACE>",),
1129 "pwsh": ("<RIGHT>", "<SPACE>"),
1130 }
1131 for sh in hero_shells:
1132 tab = ("<TAB>",)
1133 # Taking a value out of the menu, spelled the way each shell means
1134 # it. fish and zsh walk and insert as the selection moves; pwsh
1135 # lists the *values* only once a character narrows them, so it
1136 # narrows first and then walks its grid; nushell shows the menu but
1137 # commits nothing until Enter, which selects rather than submits
1138 # while a menu is open; bash does not walk at all, so it takes the
1139 # prefix route — which shows the filtering as well.
1140 pick = {
1141 "fish": (*tab, *tab),
1142 "zsh": (*tab, *tab),
1143 "pwsh": ("f", *tab, *tab),
1144 "nushell": (*tab, "<DOWN>", "<ENTER>"),
1145 }.get(sh, ("f", *tab, "i", *tab))
1146 pick_region = {
1147 "fish": tab,
1148 "zsh": tab,
1149 "nushell": (*tab, "<DOWN>", "<ENTER>"),
1150 }.get(sh, ("u", *tab))
1151 # What ends a menu so the *next* Tab asks again rather than filtering
1152 # what is already listed. Only PSReadLine keeps a session like that.
1153 close = ("<SPACE>", "<BACKSPACE>") if sh == "pwsh" else ()
1154 # `*-cast.svg` is the convention the docs' player keys on: any
1155 # recording ending that way gets play/pause/scrub, a still does not.
1156 out = shot / f"hero-{sh}-cast.svg"
1157 # `deploy` is typed, so it proves nothing about the menus.
1158 # `fix/completion-cache` and the regions never are: they can only
1159 # appear if the completer answered. Assert the beat, not the echo.
1160 beats = ["deploy", "build", "test"]
1161 if sh != "bash" or _bash_renders_value_menus():
1162 beats += ["feat/typed-flags", "fix/completion-cache"]
1163 _record_cast(
1164 # Nothing is typed out in full: every token on the line arrives
1165 # by completion, and the longest thing typed is two characters.
1166 # The task surface, each task with its summary.
1167 "fm ",
1168 *tab,
1169 # The task, by prefix — deterministic whatever order a shell
1170 # lists in, which is the shell's choice and not ours.
1171 "de",
1172 *tab,
1173 *accept.get(sh, ()),
1174 # What it takes. `-` keeps the menu to options: with the task's
1175 # arity satisfied, a bare Tab also offers the next task in the
1176 # chain, which is true but not what this beat is about.
1177 "-",
1178 *tab,
1179 # One option, both spellings — a value is `=`-attached.
1180 "b",
1181 *tab,
1182 # Close the menu before typing the `=`. PSReadLine does not
1183 # re-ask on the next keystroke: a character typed into an open
1184 # menu filters the candidates it already holds, so `=` narrowed
1185 # `--branch`/`--branch=` to one entry and footman was never asked
1186 # for the branch values at all. Space accepts and closes it;
1187 # Backspace takes back the space, leaving the token whole.
1188 # (Right accepts the *selection*, and Escape reverts — measured.)
1189 *close,
1190 # Its values: every branch in the repo, asked of git on the spot.
1191 "=",
1192 *tab,
1193 # Take one, then a Literal straight from the signature. Both
1194 # dashes on `--r`: the candidates are `--region`/`--region=`, so
1195 # `-r` is a prefix of neither — the beat above only reaches `b`
1196 # because `-`+Tab expanded the common `--` first.
1197 *pick,
1198 *accept.get(sh, ()),
1199 "--r",
1200 *tab,
1201 *close,
1202 "=",
1203 *tab,
1204 *pick_region,
1205 pace=0.75,
1206 beats=beats,
1207 out=out,
1208 shell=sh,
1209 width=84,
1210 height=16,
1211 cwd=hero,
1212 )
1213 # Dynamic completion, recorded against typing.md's own example (the
1214 # demo project's tasks.py is extracted from the page): TAB offers the
1215 # values a plain function returned, and TAB again walks the menu.
1216 taskdocs_cast(
1217 "fm mount ",
1218 "<TAB>",
1219 "<WAIT>",
1220 "<TAB>",
1221 "<WAIT:1200>",
1222 out=shot / "pwsh-suggest-cast.svg",
1223 shell="pwsh",
1224 width=80,
1225 height=12,
1226 cwd=Path(_scaffold_suggest_demo()),
1227 )
1228 # Interactive input, one cast per shape (orchestration.md), against a demo
1229 # project with an ask() parameter, a confirm= gate, and an interactive wizard.
1230 # <SETTLE> holds each answer until the prompt has finished rendering (output
1231 # goes quiet), so a frame always lands on the prompt and a slow boot can't
1232 # race the script — timing-independent, unlike a fixed wait.
1233 interactive_demo = Path(_scaffold_interactive_demo())
1234 taskdocs_cast(
1235 "fm release",
1236 "<ENTER>",
1237 "<SETTLE>",
1238 "1.4.0",
1239 "<ENTER>",
1240 "<WAIT:2000>",
1241 out=shot / "ask-cast.svg",
1242 shell="zsh",
1243 width=64,
1244 height=10,
1245 cwd=interactive_demo,
1246 )
1247 taskdocs_cast(
1248 "fm deploy",
1249 "<ENTER>",
1250 "<SETTLE>",
1251 "y",
1252 "<ENTER>",
1253 "<WAIT:2000>",
1254 out=shot / "confirm-cast.svg",
1255 shell="zsh",
1256 width=64,
1257 height=10,
1258 cwd=interactive_demo,
1259 )
1260 taskdocs_cast(
1261 "fm scaffold",
1262 "<ENTER>",
1263 "<SETTLE>",
1264 "myapp",
1265 "<ENTER>",
1266 "<SETTLE>",
1267 "2",
1268 "<ENTER>",
1269 "<WAIT:2000>",
1270 out=shot / "interactive-cast.svg",
1271 shell="zsh",
1272 width=64,
1273 height=12,
1274 cwd=interactive_demo,
1275 )
1276 # Each interactive cast must show its whole interaction — the <SETTLE>
1277 # timing exists to guarantee it, so verify the SVGs actually captured the
1278 # prompt, the typed answer, and the result before shipping them.
1279 _assert_cast_captured(shot / "ask-cast.svg", ["version", "1.4.0", "Releasing"])
1280 _assert_cast_captured(shot / "confirm-cast.svg", ["production", "Deploying"])
1281 _assert_cast_captured(
1282 shot / "interactive-cast.svg",
1283 # single tokens: the SVG drops inter-word spaces ( ), so "what
1284 # kind?" would collapse — "kind" alone still proves the select rendered.
1285 ["project", "kind", "library", "plugin", "Scaffolding", "myapp"],
1286 )
1287 _write_llms_txt()
1288 # A conditional flag needs no ternary: strict=check is --strict when
1289 # check is true, omitted otherwise (strict is off by default in zensical).
1290 zensical.opts(in_process=False).build(clean=True, strict=check)
1293# The agent-hook adapters: machine-called, human-invisible (the hidden
1294# group keeps the whole subtree out of --list/--tree/completion). Each one
1295# reads its harness's JSON payload from stdin as a typed dataclass — no jq,
1296# no host dependency — and speaks the exit-code contract its caller reads:
1297# 0 all quiet, 2 a blocking verdict with the receipts on stderr, and
1298# footman's own 64 passing through untouched, so a broken hook line reaches
1299# the human who wired it, never the model as a fake verdict. Coupling a
1300# task to its caller is fine here — this is user-land; only src/footman/
1301# must stay caller-blind. Wired in .claude/settings.json as
1302# `uv run fm hooks.<name> 1>&2`.
1303hooks = group("hooks", hidden=True, help="Agent lifecycle hooks (stdin-driven)")
1306@dataclass
1307class ToolInput:
1308 file_path: str = ""
1309 command: str = "" # Bash only: what the agent is about to run
1312@dataclass
1313class HookEvent:
1314 tool_input: ToolInput = dataclasses.field(default_factory=ToolInput)
1315 stop_hook_active: bool = False
1316 session_id: str = ""
1319def _worktree(path: str) -> Path | None:
1320 """The checkout *path* belongs to — a git worktree root, or `None`.
1322 A worktree's `.git` is a file rather than a directory, so `exists` is the
1323 test and not `is_dir`.
1324 """
1325 from pathlib import Path
1327 if not path:
1328 return None
1329 start = Path(path).expanduser().resolve()
1330 for candidate in (start, *start.parents):
1331 if (candidate / ".git").exists():
1332 return candidate
1333 return None
1336def _gate_dir(session: str) -> Path:
1337 """Where a session records the checkout its edits landed in.
1339 A hook runs in the session's *shell* directory, which is not reliably the
1340 checkout being worked on: `cd` somewhere outside the workspace and the
1341 harness resets it to the project root. Several agents share this repo
1342 through worktrees, so a stop hook that ran wherever the shell finished
1343 would gate one session's work on another's half-finished tree — which it
1344 did, reporting a failure in a branch this session had never touched.
1346 So the edits say where the work is, and the file keys on the session so
1347 two agents cannot answer for each other.
1348 """
1349 import tempfile
1350 from pathlib import Path
1352 return Path(tempfile.gettempdir()) / f"footman-gate-{session or 'unknown'}"
1355_QUOTED = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
1356_RUNS_FM = re.compile(r"^\s*(?:uv run(?: --\S+)* )?f(?:m|ootman)\b")
1357_TRUNCATES = re.compile(r"\|\s*(?:tail|head)\b")
1358_PUSHES = re.compile(r"^\s*git\s+(?:-C\s+(\S+)\s+)?push\b")
1359_PUSH_EXEMPT = re.compile(r"\s(?:--delete|-d|--tags)\b|\bpush\s+(?:\S+\s+)?main\b")
1362def _push_conflicts(repo: str | None) -> bool:
1363 """Whether HEAD conflicts with `origin/main` — GitHub's test-merge, run
1364 locally in milliseconds, before the push can create the silent state.
1366 Fails open on every uncertainty: an offline fetch probes whatever
1367 `origin/main` the clone last saw, and a repo with no such ref (a scratch
1368 checkout, a fresh clone of something else) is not this guard's business.
1369 Only a conflict — `merge-tree` exit 1, distinct from its other failures —
1370 speaks.
1371 """
1372 import contextlib
1374 git = ["git", *(("-C", repo) if repo else ())]
1375 with contextlib.suppress(RunFailed):
1376 # Offline / no remote: the last-seen origin/main still answers.
1377 run([*git, "fetch", "--quiet", "origin", "main"], capture=True)
1378 try:
1379 run([*git, "rev-parse", "--verify", "-q", "origin/main^{commit}"], capture=True)
1380 except RunFailed:
1381 return False # no origin/main at all: not this guard's business
1382 try:
1383 run([*git, "merge-tree", "--write-tree", "origin/main", "HEAD"], capture=True)
1384 except RunFailed as exc:
1385 # With the ref verified, exit 1 is merge-tree's one honest meaning:
1386 # "merged, with conflicts". (Unverified, 1 also means "no such ref".)
1387 return exc.result == 1
1388 return False
1391@hooks.task
1392def pre_bash(event: Annotated[HookEvent, stdin]) -> None:
1393 """Refuse the Bash commands that succeed while creating a silently broken
1394 state: a footman gate piped into tail/head, and a `git push` of a branch
1395 that conflicts with `origin/main`.
1397 **The pipe guard.** A gate's **exit code is its verdict**, and a pipe
1398 replaces it with the filter's — so `fm check | tail -4` reports 0
1399 whatever happened, and prints the parallel step summary while the failing
1400 step scrolls past above. This session called a red gate green exactly
1401 that way.
1403 **The push guard.** Several agent sessions share this repo, so `main`
1404 moves while a branch is being built — and a branch pushed from a stale
1405 base opens a CONFLICTING pull request, for which GitHub cannot build its
1406 test-merge and therefore **spawns no CI at all**: no red X, no checks,
1407 just an absence nothing points at (PR #304 sat exactly that way). The
1408 guard runs the same test-merge locally (`git merge-tree --write-tree`)
1409 before letting the push through. Tag pushes, deletions and pushes of
1410 `main` itself pass untouched.
1412 Both are deliberately narrow. Command separators split first, so `fm
1413 check && echo done | tail` stays legal; quoted spans are data, so `rg "fm
1414 check" | head` passes. Nudges, not a sandbox: `grep` destroys a verdict
1415 just as well, and `--force` is still force — widening either guard
1416 starts eating ordinary commands.
1417 """
1418 segments = re.split(r";|&&|\|\|", event.tool_input.command)
1419 blind = [_QUOTED.sub('""', segment) for segment in segments]
1420 if any(
1421 _TRUNCATES.search(segment[match.end() :])
1422 for segment in blind
1423 if (match := _RUNS_FM.search(segment)) is not None
1424 ):
1425 fail(
1426 "piping a footman command into tail/head replaces its exit code "
1427 "with the filter's and hides the failing step — this session "
1428 "reported a red gate as green that way. Run it unpiped and read "
1429 "the exit code; to keep the output short, redirect to a file and "
1430 "slice the file.",
1431 code=2,
1432 )
1433 for segment in blind:
1434 push = _PUSHES.search(segment)
1435 if push is None or _PUSH_EXEMPT.search(segment):
1436 continue
1437 repo = push.group(1) # a quoted -C path was blinded; probe the cwd then
1438 if _push_conflicts(None if repo in (None, '""') else repo):
1439 fail(
1440 "git push refused: this branch conflicts with origin/main. A "
1441 "conflicting PR spawns no CI at all — GitHub cannot build its "
1442 "test-merge, so there is no red X, no checks, just silence "
1443 "(PR #304 sat that way). Rebase (git fetch origin && git "
1444 "rebase origin/main), re-run the gate, then push.",
1445 code=2,
1446 )
1449@hooks.task
1450def post_edit(event: Annotated[HookEvent, stdin]) -> None:
1451 """Format and lint a Python file the agent just edited."""
1452 # Every edit, not only the Python ones: `stop` needs to know which
1453 # checkout this session is working in whatever it touched last.
1454 if root := _worktree(event.tool_input.file_path):
1455 _gate_dir(event.session_id).write_text(str(root), encoding="utf-8")
1456 if not event.tool_input.file_path.endswith(".py"):
1457 return
1458 try:
1459 format()
1460 lint()
1461 except RunFailed:
1462 fail("format/lint failed — fix it before continuing", code=2)
1465@hooks.task
1466def stop(event: Annotated[HookEvent, stdin]) -> None:
1467 """Refuse to let a session end on a red gate."""
1468 if event.stop_hook_active:
1469 return # this stop already is the retry — never ping-pong
1470 from pathlib import Path
1472 here = _worktree(str(Path.cwd()))
1473 try:
1474 recorded = _gate_dir(event.session_id).read_text(encoding="utf-8").strip()
1475 except OSError:
1476 recorded = "" # nothing was edited, or the note is gone: gate here
1477 target = _worktree(recorded)
1478 try:
1479 if target is None or target == here:
1480 check()
1481 else:
1482 # Another checkout entirely, so the gate runs there — as a
1483 # subprocess, because that tree has its own environment and its
1484 # own footman, and this one has no business standing in for it.
1485 run(["uv", "run", "fm", "check"], cwd=target)
1486 except RunFailed:
1487 where = "" if target is None or target == here else f" in {target.name}"
1488 fail(f"the gate is red{where} — fix it before stopping", code=2)
1491dist = group("dist", help="Build and publish")
1494@dist.task
1495def build():
1496 """Build the sdist and wheel."""
1497 uv("build")
1500@dist.task
1501def clean():
1502 """Remove build artifacts."""
1503 run("rm -rf dist")
1506# Dogfood: pull footman's own first-party plugins, exactly as a user would.
1507# Each node lands under its own name and merges with what the file defines
1508# — the docs tasks join the local `docs` group leaf by leaf. Order doesn't
1509# matter (a local group() adopts a pulled one, and pulls compose with
1510# existing groups); pulled last only so the file's own tasks list first.
1511plugin("footman.docs")
1512plugin("footman.profile")