Coverage for src/footman/_describe.py: 91%
525 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"""Phrase manifest nodes for humans: labels, usage lines, examples.
3The one home for turning a task's manifest entry into words, shared by the
4help renderer (`_app`) and the markdown exporter (`markdown`) so the two can
5never drift. Everything here is a pure function over manifest dicts — no
6registry, no I/O.
7"""
9from __future__ import annotations
11import dataclasses
12import datetime
13import decimal
14import enum
15import json
16import re
17import unicodedata
18import uuid
19from collections.abc import Callable, Iterator
20from pathlib import Path, PurePath
21from typing import Any
23TYPE_WORD = {
24 "bool": "true/false",
25 "int": "an integer",
26 "float": "a number",
27 "path": "a path",
28 "str": "text",
29}
32# --- the palette --------------------------------------------------------------
33# One visual language for the whole CLI: bold for names and headers, dim for
34# mechanics and secondary text, cyan for footman's own numbers and accents,
35# green/red for verdicts. Every helper is a no-op when *on* is False, and
36# every surface gates *on* by its own stream's tty-ness — piped output stays
37# byte-clean.
40def wants_color(stream: Any, mode: str = "auto") -> bool:
41 """Whether to paint output for *stream* under a resolved colour *mode*.
43 `"always"`/`"never"` are the explicit tri-state answers (`--color=…`, config,
44 `FORCE_COLOR`/`NO_COLOR`); `"auto"` (the default) falls back to the stream's
45 own tty-ness, honouring `NO_COLOR` and a dumb terminal.
46 """
47 if mode == "never":
48 return False
49 if mode == "always":
50 return True
51 import os as _os
53 return (
54 ansi_capable(stream)
55 and "NO_COLOR" not in _os.environ
56 and _os.environ.get("TERM") != "dumb"
57 )
60def ansi_capable(stream: Any) -> bool:
61 """tty-ness plus, on Windows, a console that interprets ANSI.
63 A Windows tty is not yet an ANSI surface: legacy conhost prints the
64 escapes as `←[1m` noise. Ask the console for VT processing the way
65 every modern CLI does — a no-op where it is already on (Windows
66 Terminal), an upgrade where it is off but available (Windows 10's
67 conhost), and a refusal exactly where the escapes would show as noise.
68 """
69 try:
70 tty = bool(stream.isatty())
71 except Exception:
72 return False
73 import os as _os
75 if not tty or _os.name != "nt":
76 return tty
77 try:
78 fileno = stream.fileno()
79 except Exception:
80 # No OS handle to interrogate: a tty-claiming stream without one is
81 # a wrapper (or a test fake) that answers for its own rendering, so
82 # its claim stands. Every real console stream has a fileno, so the
83 # probe below still covers the console that cannot paint.
84 return True
85 try:
86 import ctypes
87 import msvcrt
89 # getattr with a default, not attribute access: both exist only on
90 # Windows, and the checkers read this file on every platform.
91 get_handle = getattr(msvcrt, "get_osfhandle", None)
92 windll = getattr(ctypes, "windll", None)
93 if get_handle is None or windll is None: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return False
95 handle = get_handle(fileno)
96 kernel32 = windll.kernel32
97 mode = ctypes.c_uint32()
98 if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true
99 return False
100 vt = 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING
101 if mode.value & vt:
102 return True
103 return bool(kernel32.SetConsoleMode(handle, mode.value | vt))
104 except Exception:
105 return False
108def link(text: str, url: str | None, on: bool) -> str:
109 """*text* as a terminal hyperlink (OSC 8), when *on* and a URL exists.
111 Zero visible width, so band layouts that track plain lengths stay
112 untouched. Rides the same switch as colour: piped output never carries
113 it, and a terminal without OSC 8 support ignores the sequence.
114 """
115 if not on or not url:
116 return text
117 return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
120_DOCS_PLACEHOLDERS = ("path", "slug")
123def docs_url_error(template: object) -> str | None:
124 """The refusal a broken `docs_url` template earns, or None for a sound
125 one — a link scheme that silently pointed nowhere would be worse than
126 no links at all."""
127 from footman import _paths
129 if not isinstance(template, str) or not template.strip():
130 return (
131 f"the [tool.{_paths.config_table()}] docs_url key is a URL "
132 "template, e.g. "
133 '"https://docs.example.dev/tasks/{path}/" — {path} is the '
134 "slash-joined task address, {slug} the dash-joined one"
135 )
136 unknown = [
137 m for m in re.findall(r"\{([^{}]*)\}", template) if m not in _DOCS_PLACEHOLDERS
138 ]
139 if unknown:
140 return (
141 f"docs_url: unknown placeholder {{{unknown[0]}}} — use {{path}} "
142 f"(slash-joined address) or {{slug}} (dash-joined)"
143 )
144 return None
147def docs_url_for(template: str | None, address: str) -> str | None:
148 """*address*'s documentation URL under *template*, or None without one.
150 `{path}` walks the generated per-task pages (`docs.site`: one file per
151 task, directories per group); `{slug}` jumps to the one-page export's
152 anchors (`docs.page`: `{ #docs-build }`). Plain replacement, not
153 `format` — a URL's own braces are already refused by validation.
154 """
155 if not template or not address:
156 return None
157 return template.replace("{path}", address.replace(".", "/")).replace(
158 "{slug}", address.replace(".", "-")
159 )
162def bold(text: str, on: bool) -> str:
163 return f"\033[1m{text}\033[0m" if on else text
166def dim(text: str, on: bool) -> str:
167 return f"\033[2m{text}\033[0m" if on else text
170def cyan(text: str, on: bool) -> str:
171 return f"\033[36m{text}\033[0m" if on else text
174def bold_cyan(text: str, on: bool) -> str:
175 return f"\033[1;36m{text}\033[0m" if on else text
178def red(text: str, on: bool) -> str:
179 return f"\033[31m{text}\033[0m" if on else text
182# --- columns ------------------------------------------------------------------
183# Every aligned surface asks the same question — how wide is this on screen? —
184# and `len()` answers a different one, in three ways at once: an escape
185# sequence is bytes the terminal eats rather than shows, a combining mark
186# rides on the character before it, and an East-Asian wide character (or an
187# emoji) takes two cells. One helper, so a column is a column everywhere.
189_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
192def _cell(ch: str) -> int:
193 if unicodedata.category(ch) in ("Mn", "Me", "Cf"):
194 return 0 # combining marks and format codes ride on their neighbour
195 return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
198def display_width(text: str) -> int:
199 """How many terminal cells *text* occupies."""
200 if text.isascii() and "\033" not in text:
201 # Plain ASCII is one cell per character and carries no escapes — two
202 # C-speed checks so the common case (every column footman prints
203 # about itself) costs what `len()` cost before.
204 return len(text)
205 return sum(_cell(ch) for ch in _ANSI.sub("", text))
208def pad_to(text: str, width: int) -> str:
209 """*text* followed by spaces up to *width* cells — `str.ljust` counting
210 what the terminal counts."""
211 return text + " " * max(width - display_width(text), 0)
214def fit(text: str, width: int) -> str:
215 """*text* cut to *width* cells, escape sequences kept whole.
217 Never cuts inside an escape (which would print its tail as literal
218 gibberish) and never splits a wide character down the middle; whatever
219 styling was still open at the cut is closed, so a truncated status line
220 cannot leave the terminal wearing a colour nobody chose.
221 """
222 if display_width(text) <= width:
223 return text
224 out: list[str] = []
225 used = 0
226 styled = False
228 def take(run: str) -> bool:
229 nonlocal used
230 for ch in run:
231 cells = _cell(ch)
232 if used + cells > width:
233 return True
234 out.append(ch)
235 used += cells
236 return False
238 pos = 0
239 for match in _ANSI.finditer(text):
240 if take(text[pos : match.start()]):
241 break
242 out.append(match.group())
243 styled = match.group() != "\033[0m"
244 pos = match.end()
245 else:
246 take(text[pos:])
247 return "".join(out) + ("\033[0m" if styled else "")
250def value_hint(p: dict[str, Any]) -> str:
251 """The value placeholder shown for an option/argument in help output."""
252 if p.get("mapping"): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 return "KEY=VALUE"
254 if group := p.get("group"):
255 # `--size=width,height` beats `--size=VALUE`: the shape named its own
256 # slots, so the help can too.
257 return str(group["label"])
258 choices = p.get("choices")
259 if choices:
260 return "{" + "|".join(choices) + "}"
261 types = p.get("types")
262 if types:
263 return "|".join(t.upper() for t in types)
264 return "VALUE"
267def _repeats(p: dict[str, Any]) -> bool:
268 """Whether the parameter takes a *stream* of values rather than one.
270 A grouped shape is `multiple` to the splitter because its values
271 accumulate the same way, but `--size=width,height` takes one size — the
272 `...` belongs only to a container of them.
273 """
274 if group := p.get("group"):
275 return bool(group["many"])
276 return bool(p.get("multiple"))
279def usage_fragment(p: dict[str, Any]) -> str:
280 kind = p["kind"]
281 required = p.get("required")
282 if kind == "stdin":
283 return "" # a whole-document parameter has no token spelling
284 if kind == "flag":
285 name = f"no-{p['name']}" if p.get("default") is True else p["name"]
286 return f"--{name}" if required else f"[--{name}]"
287 if kind == "option":
288 core = f"--{p['name']}={value_hint(p)}"
289 if _repeats(p) or p.get("mapping"): 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 core += " ..."
291 return core if required else f"[{core}]"
292 if kind == "variadic":
293 return f"[<{p['name']}> ...]"
294 suffix = "..." if p.get("multiple") else ""
295 return f"<{p['name']}>{suffix}"
298def param_label(p: dict[str, Any]) -> str:
299 kind = p["kind"]
300 if kind == "flag":
301 return f"--no-{p['name']}" if p.get("default") is True else f"--{p['name']}"
302 if kind == "option":
303 return f"--{p['name']}={value_hint(p)}"
304 suffix = "..." if kind == "variadic" or p.get("multiple") else ""
305 return f"<{p['name']}>{suffix}"
308def param_detail(p: dict[str, Any]) -> str:
309 doc, mechanics = param_detail_parts(p)
310 return "; ".join(bit for bit in (doc, mechanics) if bit)
313def param_detail_parts(p: dict[str, Any]) -> tuple[str, str]:
314 """(author's doc, the mechanical suffix) — split so help can dim the
315 mechanics under the author's words."""
316 return p.get("doc", ""), _mechanics(p)
319def default_text(p: dict[str, Any]) -> str:
320 """The declared default, spelled the way the command line spells values, or
321 `""` when there is nothing worth printing.
323 `None` prints as nothing rather than "None": absence is what it means, and
324 "the default is None" tells a reader less than saying nothing does. An empty
325 string is printed as `""`, because there it is the value.
326 """
327 if "default" not in p or p.get("required"):
328 return ""
329 value = p["default"]
330 if value is None:
331 return ""
332 if isinstance(value, bool): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 return "true" if value else "false"
334 if isinstance(value, (list, tuple)): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 return ",".join(str(v) for v in value) if value else "(none)"
336 if isinstance(value, dict): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 return ",".join(f"{k}={v}" for k, v in value.items()) if value else "(none)"
338 return str(value) if value != "" else '""'
341def _mechanics(p: dict[str, Any]) -> str:
342 bits: list[str] = []
343 if p["kind"] == "flag":
344 # A flag defaulting true is only ever *turned off*, so the spelling
345 # that does something leads, and the inert one is the parenthetical.
346 if p.get("default") is True:
347 bits.append(f"flag (--{p['name']} to enable)")
348 else:
349 bits.append(f"flag (--no-{p['name']} to disable)")
350 choices = p.get("choices")
351 if choices:
352 # A dynamic parameter's values are what its completer answered just
353 # now, not a fixed set — say so, the way a computed default does, so
354 # nobody reads this list as the law of the task. A *static* option's
355 # set is already in its own spelling (`--mode={dev|prod}`), so
356 # repeating it here would print the list twice in one row; only a
357 # positional, whose spelling is just its name, still needs the list.
358 listed = "one of " + "|".join(choices)
359 if p.get("dynamic"):
360 bits.append(f"{listed} (dynamic)")
361 elif p["kind"] == "positional":
362 bits.append(listed)
363 elif p.get("types"):
364 bits.append(" or ".join(TYPE_WORD.get(str(t), str(t)) for t in p["types"]))
365 if p.get("mapping"): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 bits.append("KEY=VALUE pairs (repeat appends)")
367 if group := p.get("group"): 367 ↛ 371line 367 didn't jump to line 371 because the condition on line 367 was never true
368 # A grouped shape is `multiple` to the splitter — commas and repetition
369 # feed one stream — but saying "comma-split" would describe the wiring
370 # rather than the spelling. What a reader needs is the arity.
371 if group["many"]:
372 bits.append(f"repeatable in groups of {group['max']}")
373 elif group["min"] < group["max"]:
374 bits.append(f"{group['min']} to {group['max']} values")
375 elif p.get("multiple") or p.get("mapping"): 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 bits.append("repeatable" if p.get("nosplit") else "repeatable/comma-split")
377 if p["kind"] == "variadic":
378 bits.append("extra arguments (also receives everything after --)")
379 source = p.get("stdin")
380 if source:
381 if source.startswith("field:"):
382 note = f"reads stdin (JSON field {source[6:]!r})"
383 elif source == "lines": 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 note = "reads stdin (one line per value)"
385 elif source == "bytes": 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true
386 note = "reads stdin (raw bytes)"
387 elif source == "json": 387 ↛ 392line 387 didn't jump to line 392 because the condition on line 387 was always true
388 shape = p.get("shape")
389 named = f" → {shape['name']}" if shape else ""
390 note = f"reads stdin (JSON document{named})"
391 else:
392 note = "reads stdin (text)"
393 bits.append(note)
394 # In ladder order, so the line reads the way resolution happens: the
395 # environment answers first, the declared default last. Both were in the
396 # manifest all along and neither was ever printed — a help page that knows
397 # a parameter falls back to $DEPLOY_ENV and does not say so is holding out.
398 if (var := p.get("env")) is not None: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 bits.append(f"from ${var}")
400 # A flag's default is already said twice over by the label and its
401 # parenthetical, so printing it again would be noise.
402 if p["kind"] != "flag":
403 shown, computed = default_text(p), p.get("computed")
404 if shown and computed:
405 # `default: 13` reads as an arbitrary constant. Saying it is
406 # computed turns a number a reader might copy into one they know
407 # is theirs — the machine's cores, today's date, this shell.
408 bits.append(f"default: {shown} (computed)")
409 elif shown:
410 bits.append(f"default: {shown}")
411 elif computed:
412 # Computed from the other options, so there is no value to print
413 # here: only an invocation knows it. Say that much rather than
414 # nothing, or the parameter looks like it has no default at all.
415 bits.append("default computed")
416 if p.get("required"):
417 bits.append("required")
418 return "; ".join(bits)
421def uses_line(task: dict[str, Any], tree: dict[str, Any]) -> str:
422 """The globals a task declared it reads (`@task(uses=[...])`), with
423 provenance — the dependency shown where the option will be typed."""
424 names = task.get("uses")
425 if not names:
426 return ""
427 owners = {g["name"]: g.get("owner") for g in tree.get("globals", ())}
428 bits = [
429 f"--{name}" + (f" (from {owner})" if (owner := owners.get(name)) else "")
430 for name in names
431 ]
432 return "reads " + ", ".join(bits)
435def sample_value(p: dict[str, Any]) -> str:
436 """A realistic value for a param in a synthesised example: its first choice
437 when it has one, else an `<name>` placeholder."""
438 choices = p.get("choices")
439 return choices[0] if choices else f"<{p['name']}>"
442# CLI lines (usage, examples) are token lists — (kind, text) — so every
443# renderer paints the same structure: `prog` bold, `group` bold cyan (as in
444# the tree), `task` bold, `req`/`value` cyan, `opt` dim, `flag` plain.
445_CLI_PAINT: dict[str, Callable[[str, bool], str]] = {
446 "prog": bold,
447 "group": bold_cyan,
448 "task": bold,
449 "req": cyan,
450 "value": cyan,
451 "opt": dim,
452 "flag": lambda text, on: text,
453}
456def paint_cli(parts: list[tuple[str, str]], on: bool) -> str:
457 """The one way to print a command line, syntax-lit by token kind."""
458 return " ".join(_CLI_PAINT.get(kind, bold)(text, on) for kind, text in parts)
461def invocation_parts(prog: str, path: list[str]) -> list[tuple[str, str]]:
462 """`prog dotted.task` as tokens — the head of every usage and example.
464 The address is one dotted token (`fm docs.build`), never a token walk —
465 what help shows must be what the splitter accepts."""
466 parts: list[tuple[str, str]] = [("prog", prog)]
467 if path: 467 ↛ 469line 467 didn't jump to line 469 because the condition on line 467 was always true
468 parts.append(("task", ".".join(path)))
469 return parts
472def listed_params(
473 task: dict[str, Any], *, show_hidden: bool = False
474) -> list[dict[str, Any]]:
475 """The parameters a listing shows — everything but the hidden ones.
477 The task-level rule, one level down: `hidden` takes a parameter out of
478 what a human reads and out of nothing else. It still binds, it still
479 completes, and the manifest still carries it marked, because hiding and
480 completing are different questions. `--all` shows it.
481 """
482 return [p for p in task["params"] if show_hidden or not p.get("hidden")]
485def usage_parts(
486 prog: str, path: list[str], task: dict[str, Any], *, show_hidden: bool = False
487) -> list[tuple[str, str]]:
488 parts = invocation_parts(prog, path)
489 for p in listed_params(task, show_hidden=show_hidden):
490 fragment = usage_fragment(p)
491 if fragment: 491 ↛ 489line 491 didn't jump to line 489 because the condition on line 491 was always true
492 kind = "opt" if fragment.startswith("[") else "req"
493 parts.append((kind, fragment))
494 return parts
497def example_parts(
498 path: list[str], task: dict[str, Any], prog: str
499) -> list[tuple[str, str]]:
500 """A realistic invocation synthesised straight from the signature — required
501 positionals and options with sample values, plus one representative flag.
503 Derived, never written, so it can't drift from the task's actual parameters.
504 Optional options are skipped as noise; the shape teaches the invocation.
505 """
506 parts = invocation_parts(prog, path)
507 flag_shown = False
508 for p in listed_params(task):
509 kind = p["kind"]
510 if kind in ("positional", "variadic"):
511 parts.append(("value", sample_value(p)))
512 elif kind == "option" and p.get("required"):
513 # One attached token, because that is the only spelling footman
514 # accepts: `--out <file>` is exit 64 with "did you mean --out=…?".
515 # An example is a line to copy, so it has to be a line that runs.
516 parts.append(("value", f"--{p['name']}={sample_value(p)}"))
517 elif kind == "flag" and (p.get("required") or not flag_shown):
518 parts.append(("flag", f"--{p['name']}"))
519 flag_shown = True
520 return parts
523def example(path: list[str], task: dict[str, Any], prog: str) -> str:
524 """The example invocation as plain text (the markdown exporter's form)."""
525 return " ".join(text for _, text in example_parts(path, task, prog))
528def task_line(task: dict[str, Any]) -> str:
529 """A task's one-line description, plus how it ends when that's notable:
530 availability if disabled, the Ctrl-C note if it runs until stopped."""
531 notes = []
532 if task.get("infinite"):
533 notes.append("(runs until Ctrl-C)")
534 if task.get("disabled"):
535 notes.append(f"(unavailable: {task['disabled']})")
536 line: str = task["help"]
537 if not notes:
538 return line
539 return f"{line} {' '.join(notes)}".strip()
542def default_line(node: dict[str, Any]) -> str:
543 """The one-line description of a runnable group's default action.
545 The author's docstring when there is one; otherwise generated from what
546 the default actually does — an empty body fans the group's tasks out, a
547 custom body runs as written — so an undocumented default is still
548 explained, never a blank cell.
549 """
550 default = node["default"]
551 line = task_line(default)
552 if line:
553 return line
554 if node.get("default_fanout"):
555 return "run every task in this group"
556 return "run this group's default action"
559def listed(node: dict[str, Any], *, show_hidden: bool = False) -> bool:
560 """Whether a task or group node belongs in a human listing.
562 Two things take one out, and they are different in kind. `hidden` is a
563 task nobody is meant to *read about*: it stays callable, still completes,
564 shows up under `--json` marked, and `--all` reveals it — because a machine
565 is exactly who calls it. `unexposed` is a task that does not belong
566 *here at all*: nothing outside a project can run it, so listing it would
567 be an offer footman cannot honour, and `--all` does not bring it back.
568 """
569 if node.get("unexposed"):
570 return False
571 return show_hidden or not node.get("hidden")
574def has_listed(node: dict[str, Any], *, show_hidden: bool = False) -> bool:
575 """Whether anything under *node* is worth printing.
577 Deliberately *not* short-circuited on the group's own `hidden`: hiding a
578 group hides everything that inherits from it, but a child that answered
579 `hidden=False` is still listed, and it needs its heading to be placed.
580 A group with nothing listed under it prints no heading at all, rather
581 than one with nothing beneath it.
582 """
583 if "default" in node and listed(node["default"], show_hidden=show_hidden):
584 return True
585 return any(
586 listed(t, show_hidden=show_hidden) for t in node["tasks"].values()
587 ) or any(
588 has_listed(sub, show_hidden=show_hidden) for sub in node["groups"].values()
589 )
592def ordered_tasks(node: dict[str, Any]) -> dict[str, Any]:
593 """A group's tasks with its `default` first, then declaration order.
595 The default *is* the group — `fm db` runs it, and the group's own row is
596 described by it — so a listing that showed it wherever it happened to be
597 written put the group's headline act somewhere in the middle, or at the
598 bottom. Where it sits in the file is the author's business; where it sits
599 in a listing is footman's.
600 """
601 tasks: dict[str, Any] = node["tasks"]
602 if "default" not in tasks:
603 return tasks
604 return {"default": tasks["default"], **tasks}
607def walk(
608 node: dict[str, Any],
609 prefix: str = "",
610 depth: int = 0,
611 *,
612 show_hidden: bool = False,
613 dedupe_defaults: bool = False,
614 _covered: bool = False,
615) -> Iterator[tuple[int, str, str, str, str]]:
616 """The one traversal every human listing reads — `--list`, `--tree`, group
617 help, and the did-you-mean index.
619 Yields `(depth, address, leaf, help, kind)` per row, parents before their
620 children, hidden nodes and empty groups already gone: `--list` renders the
621 address and ignores the depth, `--tree` renders the leaf and indents by it.
622 Two views of one walk, so a rule about what is listed cannot be true of
623 one and false of the other. *show_hidden* — `--all`, and the did-you-mean
624 index, which owes an answer about every address a human can type — keeps
625 the hidden rows in.
627 *dedupe_defaults* is the listings' rule: a runnable group's bare row IS
628 its default action, so the `x.default` child row would be one action
629 wearing two lines — skipped wherever the bare row was just emitted
630 (*_covered* carries that fact into the recursion). The did-you-mean
631 index leaves it off: `x.default` is an address a human can type, and a
632 typo of it deserves the real spelling back.
633 """
634 for name, task in ordered_tasks(node).items():
635 if _covered and name == "default":
636 continue # the caller's bare-group row already says this
637 if listed(task, show_hidden=show_hidden):
638 yield depth, f"{prefix}{name}", name, task_line(task), "task"
639 for name, sub in node["groups"].items():
640 if not has_listed(sub, show_hidden=show_hidden):
641 continue
642 # A runnable group is itself a *runnable address* — the bare
643 # `fm <group>` spelling, described by its default action — so it earns
644 # a row in the flat listing. A plain group is only a heading.
645 runnable = (
646 listed(sub, show_hidden=show_hidden)
647 and "default" in sub
648 and listed(sub["default"], show_hidden=show_hidden)
649 )
650 yield (
651 depth,
652 f"{prefix}{name}",
653 name,
654 default_line(sub) if runnable else sub["help"],
655 "runnable-group" if runnable else "group",
656 )
657 yield from walk(
658 sub,
659 f"{prefix}{name}.",
660 depth + 1,
661 show_hidden=show_hidden,
662 dedupe_defaults=dedupe_defaults,
663 _covered=dedupe_defaults and runnable,
664 )
667def iter_tasks(
668 node: dict[str, Any],
669 prefix: str = "",
670 *,
671 show_hidden: bool = False,
672 dedupe_defaults: bool = False,
673 covered: bool = False,
674) -> Iterator[tuple[str, str]]:
675 """`walk()` as the flat listing sees it: `(address, help)` for everything
676 you can actually type, headings dropped. *covered* says the caller has
677 already shown a row standing in for *node*'s own `default` (group help
678 inserts the bare-group row itself)."""
679 for _depth, address, _leaf, help_text, kind in walk(
680 node,
681 prefix,
682 show_hidden=show_hidden,
683 dedupe_defaults=dedupe_defaults,
684 _covered=dedupe_defaults and covered,
685 ):
686 if kind != "group":
687 yield address, help_text
690def sort_tree(node: dict[str, Any]) -> dict[str, Any]:
691 """A copy of *node* with tasks and groups each in name order, recursively.
693 The listing shape survives — tasks still come before groups at every
694 level, and a group's `default` still leads it — so `[tool.footman] sort =
695 true` changes only where names fall, never the two-band layout."""
696 copy = dict(node)
697 by_name = {name: node["tasks"][name] for name in sorted(node["tasks"])}
698 copy["tasks"] = ordered_tasks({"tasks": by_name})
699 copy["groups"] = {
700 name: sort_tree(sub) for name, sub in sorted(node["groups"].items())
701 }
702 return copy
705def iter_group_paths(node: dict[str, Any], prefix: str = "") -> Iterator[str]:
706 for name, sub in node["groups"].items():
707 yield f"{prefix}{name}"
708 yield from iter_group_paths(sub, f"{prefix}{name}.")
711def json_default(value: object) -> object:
712 """JSON forms for the types footman coerces *in* — Path, Enum, datetime,
713 UUID, Decimal, dataclasses, sets — so a task may return what it accepts.
714 Anything else raises TypeError; the caller turns that into a
715 `returned_error` note rather than a broken envelope."""
716 if isinstance(value, PurePath):
717 return str(value)
718 if isinstance(value, enum.Enum): 718 ↛ 723line 718 didn't jump to line 723 because the condition on line 718 was never true
719 # The one output face: the member's token. Int/str-subclassed enums
720 # never reach this hook (they ride the encoder's fast paths), which
721 # is why the `redact` pre-walk transforms them first — this branch
722 # covers the plain-Enum stragglers that arrive through `default`.
723 from footman import _coerce
725 return _coerce.token_of(value)
726 if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
727 return value.isoformat()
728 if isinstance(value, uuid.UUID):
729 return str(value)
730 if isinstance(value, decimal.Decimal):
731 return str(value) # str, not float: Decimal exists to keep precision
732 if dataclasses.is_dataclass(value) and not isinstance(value, type): 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 return dataclasses.asdict(value)
734 if isinstance(value, (set, frozenset)):
735 return sorted(value, key=repr) # deterministic order for golden tests
736 raise TypeError(f"{type(value).__name__} is not JSON-serialisable")
739# --- the output contract ------------------------------------------------------
740# Three pure functions over the `returned` spec `_manifest.returned_spec`
741# bakes: the JSON Schema renderer (the describe door's interop dialect), the
742# producer-side value check, and the phrase help/docs print. The native shape
743# never expresses anything JSON Schema cannot say — golden-pair tests hold the
744# renderer to that.
746_RETURN_SCHEMA: dict[str, dict[str, Any]] = {
747 "str": {"type": "string"},
748 "int": {"type": "integer"},
749 "float": {"type": "number"},
750 "bool": {"type": "boolean"},
751 "none": {"type": "null"},
752 "any": {},
753 "path": {"type": "string"},
754 "datetime": {"type": "string", "format": "date-time"},
755 "date": {"type": "string", "format": "date"},
756 "time": {"type": "string", "format": "time"},
757 "uuid": {"type": "string", "format": "uuid"},
758 "decimal": {"type": "string"},
759}
762def returns_json_schema(spec: dict[str, Any]) -> dict[str, Any]:
763 """Render a baked `returned` spec as JSON Schema (2020-12 vocabulary).
765 The describe door's dialect — and a *contract*, not presentation:
766 consumer snapshots pin this output, so a change here is envelope-grade
767 and belongs in the CHANGELOG, never a cosmetic tweak.
768 """
769 kind = spec["kind"]
770 if kind == "enum":
771 schema: dict[str, Any] = {"enum": list(spec["values"])}
772 elif kind == "list":
773 schema = {"type": "array", "items": returns_json_schema(spec["items"])}
774 elif kind == "map":
775 schema = {
776 "type": "object",
777 "additionalProperties": returns_json_schema(spec["values"]),
778 }
779 elif kind == "object":
780 fields: dict[str, Any] | None = spec.get("fields")
781 if not fields:
782 schema = {"type": "object"} # no field claims (dict[str, Any])
783 else:
784 required = [n for n, f in fields.items() if f.get("required", True)]
785 schema = {
786 "type": "object",
787 "properties": {n: returns_json_schema(f) for n, f in fields.items()},
788 # A declared shape is the whole claim: a dataclass cannot
789 # carry extras, and an undeclared key is exactly the drift
790 # the schema exists to catch.
791 "additionalProperties": False,
792 }
793 if required: 793 ↛ 805line 793 didn't jump to line 805 because the condition on line 793 was always true
794 schema["required"] = required
795 elif kind == "row":
796 items = [returns_json_schema(f) for f in spec["fields"].values()]
797 schema = {
798 "type": "array",
799 "prefixItems": items,
800 "minItems": len(items),
801 "maxItems": len(items),
802 }
803 else:
804 schema = dict(_RETURN_SCHEMA[kind])
805 if "name" in spec and kind in ("object", "row", "enum"):
806 schema = {"title": spec["name"], **schema}
807 if spec.get("nullable"):
808 return {"anyOf": [schema, {"type": "null"}]}
809 return schema
812def returned_mismatch(
813 value: Any, spec: dict[str, Any], path: str = "returned"
814) -> str | None:
815 """The first place *value* breaks its declared spec, or None.
817 The producer-side drift check: validated against the *serialised* shape
818 a consumer reads (a Path satisfies "path" because that is a JSON string
819 either way), walked recursively, first mismatch wins. A mismatch is a
820 loud-but-local note — never an exit-code change."""
821 if spec.get("nullable") and value is None:
822 return None
823 kind = spec["kind"]
824 checks: dict[str, tuple[Any, str]] = {
825 "str": (str, "text"),
826 "bool": (bool, "true/false"),
827 "none": (type(None), "null"),
828 "path": ((PurePath, str), "a path"),
829 "datetime": ((datetime.datetime, str), "a datetime"),
830 "time": ((datetime.time, str), "a time"),
831 "uuid": ((uuid.UUID, str), "a UUID"),
832 "decimal": ((decimal.Decimal, str), "a decimal string"),
833 }
834 if kind == "any": 834 ↛ 835line 834 didn't jump to line 835 because the condition on line 834 was never true
835 return None
836 if kind == "int":
837 if isinstance(value, int) and not isinstance(value, bool):
838 return None
839 return f"{path}: expected an integer, got {_got(value)}"
840 if kind == "float":
841 if isinstance(value, (int, float)) and not isinstance(value, bool): 841 ↛ 843line 841 didn't jump to line 843 because the condition on line 841 was always true
842 return None
843 return f"{path}: expected a number, got {_got(value)}"
844 if kind == "date":
845 # `datetime.datetime` is a `date`; declared date, returned datetime
846 # still serialises to an ISO string.
847 if isinstance(value, (datetime.date, str)): 847 ↛ 849line 847 didn't jump to line 849 because the condition on line 847 was always true
848 return None
849 return f"{path}: expected a date, got {_got(value)}"
850 if kind in checks:
851 expected, word = checks[kind]
852 if isinstance(value, expected):
853 return None
854 return f"{path}: expected {word}, got {_got(value)}"
855 if kind == "enum":
856 # The spec speaks tokens, so the member is compared in the same
857 # face — validating `.value` against a token spec would refuse
858 # every declared member (the wrong-face bug the lockstep exists to
859 # prevent). An already-encoded value (a post-walk token string)
860 # compares as itself.
861 if isinstance(value, enum.Enum):
862 from footman import _coerce
864 encoded: Any = _coerce.token_of(value)
865 else:
866 encoded = value
867 if any(v == encoded for v in spec["values"]):
868 return None
869 return f"{path}: {encoded!r} is not one of the declared values"
870 if kind == "list":
871 if not isinstance(value, (list, tuple, set, frozenset)): 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true
872 return f"{path}: expected a list, got {_got(value)}"
873 for i, element in enumerate(value):
874 if note := returned_mismatch(element, spec["items"], f"{path}[{i}]"):
875 return note
876 return None
877 if kind == "map":
878 if not isinstance(value, dict): 878 ↛ 879line 878 didn't jump to line 879 because the condition on line 878 was never true
879 return f"{path}: expected a mapping, got {_got(value)}"
880 for key, element in value.items():
881 if not isinstance(key, str):
882 return f"{path}: key {key!r} is not a string"
883 if note := returned_mismatch(element, spec["values"], f"{path}[{key!r}]"):
884 return note
885 return None
886 if kind == "object":
887 return _object_mismatch(value, spec, path)
888 if kind == "row": 888 ↛ 901line 888 didn't jump to line 901 because the condition on line 888 was always true
889 fields: dict[str, Any] = spec["fields"]
890 if not isinstance(value, (list, tuple)): 890 ↛ 891line 890 didn't jump to line 891 because the condition on line 890 was never true
891 return f"{path}: expected {spec['name']} (a row), got {_got(value)}"
892 if len(value) != len(fields):
893 return (
894 f"{path}: expected {len(fields)} values "
895 f"({', '.join(fields)}), got {len(value)}"
896 )
897 for element, (name, fspec) in zip(value, fields.items(), strict=True):
898 if note := returned_mismatch(element, fspec, f"{path}.{name}"):
899 return note
900 return None
901 return None # an unknown kind (a newer manifest) makes no claims here
904def _object_mismatch(value: Any, spec: dict[str, Any], path: str) -> str | None:
905 fields: dict[str, Any] | None = spec.get("fields")
906 if dataclasses.is_dataclass(value) and not isinstance(value, type):
907 # Read through `asdict`'s eyes without its deep conversion: the
908 # instance's own field names and values, one shallow mapping.
909 have = {f.name: getattr(value, f.name) for f in dataclasses.fields(value)}
910 elif isinstance(value, dict): 910 ↛ 913line 910 didn't jump to line 913 because the condition on line 910 was always true
911 have = value
912 else:
913 shape = spec.get("name", "an object")
914 return f"{path}: expected {shape}, got {_got(value)}"
915 if not fields:
916 return None # no field claims to check
917 for name, fspec in fields.items():
918 if name not in have:
919 if fspec.get("required", True):
920 return f"{path}: missing key {name!r}"
921 continue
922 if note := returned_mismatch(have[name], fspec, f"{path}.{name}"):
923 return note
924 # The declared shape is the whole claim — an undeclared key IS the
925 # silent-rename story, seen from the other side.
926 if extra := sorted(set(have) - set(fields)):
927 return f"{path}: undeclared key {extra[0]!r}"
928 return None
931def _got(value: Any) -> str:
932 return "null" if value is None else type(value).__name__
935_RETURN_WORD: dict[str, tuple[str, str]] = {
936 # (singular, plural) — the plural serves "a list of …".
937 "str": ("text", "text"),
938 "int": ("an integer", "integers"),
939 "float": ("a number", "numbers"),
940 "bool": ("true/false", "true/false"),
941 "none": ("null", "nulls"),
942 "any": ("anything", "anything"),
943 "path": ("a path", "paths"),
944 "datetime": ("a datetime", "datetimes"),
945 "date": ("a date", "dates"),
946 "time": ("a time", "times"),
947 "uuid": ("a UUID", "UUIDs"),
948 "decimal": ("a decimal", "decimals"),
949}
952def returns_phrase(spec: dict[str, Any], *, plural: bool = False) -> str:
953 """The one-line phrase for a `returned` spec — the returns line in
954 `--help` and the type cells on a docs page."""
955 kind = spec["kind"]
956 if kind == "enum":
957 core = "one of " + "|".join(str(v) for v in spec["values"])
958 elif kind == "object" and spec.get("fields"):
959 name = spec.get("name", "an object")
960 core = f"{name} {{{', '.join(spec['fields'])}}}"
961 elif kind == "object": 961 ↛ 962line 961 didn't jump to line 962 because the condition on line 961 was never true
962 core = "an object"
963 elif kind == "row":
964 core = f"{spec['name']} ({', '.join(spec['fields'])})"
965 elif kind == "map":
966 core = "a mapping of " + returns_phrase(spec["values"], plural=True)
967 elif kind == "list":
968 core = ("lists of " if plural else "a list of ") + returns_phrase(
969 spec["items"], plural=True
970 )
971 else:
972 singular, plural_word = _RETURN_WORD[kind]
973 core = plural_word if plural else singular
974 if spec.get("nullable"):
975 core += " (or null)"
976 return core
979def redact(value: Any) -> Any:
980 """The one redaction walk: any `params.Secret` inside *value* becomes
981 `***` on its way to a surface that *shows* it — the `--json` envelope,
982 baked manifest defaults, and the command line a step is printed under
983 (`context._shown`). A str subclass rides json.dumps' fast path and never
984 reaches the `default` hook, so this pre-walk is the only reliable
985 interception. Records are walked on the way out, never on the way in:
986 what `recording()` and a dependent read is the value that was passed."""
987 from footman.params import Secret
989 if isinstance(value, Secret):
990 return "***"
991 if isinstance(value, enum.Enum):
992 # The enum wire face, in the same pre-walk and for the same reason
993 # as Secret: `IntEnum` and str-valued enums subclass int/str, ride
994 # `json.dumps`' fast paths, and never reach the `default` hook —
995 # and dict *keys* never consult the hook for any type. The walk is
996 # the only reliable interception, values and keys both; a member
997 # always speaks its canonical token on a self-describing surface.
998 from footman import _coerce
1000 return _coerce.token_of(value)
1001 if isinstance(value, dict):
1002 return {redact(k): redact(v) for k, v in value.items()}
1003 if dataclasses.is_dataclass(value) and not isinstance(value, type):
1004 # The shape a structured return is most likely to arrive in, and the
1005 # one this walk used to step straight over: `json_default` turns a
1006 # dataclass into a dict with `asdict`, which deep-copies — so a
1007 # `Secret` field survived as the str subclass it is, rode `json.dumps`'
1008 # fast path, and printed in the clear. A dict here is what the encoder
1009 # would have produced anyway, now with the fields walked first.
1010 return {
1011 field.name: redact(getattr(value, field.name))
1012 for field in dataclasses.fields(value)
1013 }
1014 if isinstance(value, tuple):
1015 return tuple(redact(v) for v in value)
1016 if isinstance(value, list):
1017 return [redact(v) for v in value]
1018 if isinstance(value, (set, frozenset)):
1019 return {redact(v) for v in value}
1020 return value
1023def jsonable(value: Any) -> tuple[bool, Any]:
1024 """(True, encoded) when *value* survives the JSON coercion mirror —
1025 used to bake parameter defaults into the manifest; (False, None) when it
1026 doesn't, in which case the key is simply omitted."""
1027 try:
1028 return True, json.loads(json.dumps(redact(value), default=json_default))
1029 except (TypeError, ValueError):
1030 return False, None
1033# --- where user code lives ----------------------------------------------------
1034#
1035# One answer to "where is this?", for every message that needs it: the refusals
1036# that name a declaration, the shadow chain in `--help`, and the failure line
1037# for an exception nobody expected. Kept here, with the rest of the phrasing,
1038# because three copies of `co_filename:co_firstlineno` is how they drift.
1040_OURS = str(Path(__file__).resolve().parent)
1043def source_of(fn: Any) -> str:
1044 """`file:line` where *fn* is written, or `""` when it cannot be told.
1046 `unwrap` first: a decorated body should point at what somebody wrote, not
1047 at the wrapper standing in front of it.
1048 """
1049 import inspect
1051 code = getattr(inspect.unwrap(fn), "__code__", None) if fn is not None else None
1052 return f"{code.co_filename}:{code.co_firstlineno}" if code is not None else ""
1055def _is_ours(filename: str) -> bool:
1056 return str(Path(filename).resolve()).startswith(_OURS)
1059def user_frame(exc: BaseException) -> str:
1060 """`file:line in name` for the innermost frame that is the caller's code.
1062 Innermost *user* frame, not innermost frame: a body that calls `run()` with
1063 a callable ends its traceback inside footman, and naming that would answer
1064 a question nobody asked. Empty when every frame is footman's own.
1065 """
1066 import traceback
1068 for frame in reversed(traceback.extract_tb(exc.__traceback__)):
1069 if not _is_ours(frame.filename):
1070 return f"{frame.filename}:{frame.lineno} in {frame.name}"
1071 return ""
1074def stack_wanted(verbose: bool, is_terminal: bool) -> bool:
1075 """Whether an unexpected exception should show its whole stack.
1077 Under `-v`, or whenever the failure is headed somewhere nobody is
1078 watching: a log, a redirect, CI, cron. There is no second run *there* to
1079 add `-v` to — the artifact is what you get — and the progress estimate
1080 already reads the same signal to take its one-shot path.
1082 Pure, and given both answers rather than finding them, so the one rule can
1083 live here (with the rest of the phrasing) while its callers sit on either
1084 side of the import that would otherwise make this circular.
1085 """
1086 return verbose or not is_terminal
1089def user_traceback(exc: BaseException) -> str:
1090 """The formatted traceback, with footman's own frames taken out.
1092 Every one of them, not only the leading run. A step's body is reached
1093 through the pump, so the framework sits in the *middle* of that stack, and
1094 leaving it there puts `_pump` and `__call__` between two lines the reader
1095 wrote. Nothing in those frames is ever theirs to fix, and the receipt above
1096 already says the work was a step.
1098 The cost is honest to name: two adjacent frames in the result were not
1099 adjacent in the call. What is bought is that every line printed is one
1100 somebody can act on.
1101 """
1102 import traceback
1104 kept = [
1105 frame
1106 for frame in traceback.extract_tb(exc.__traceback__)
1107 if not _is_ours(frame.filename)
1108 ]
1109 tail = "".join(traceback.format_exception_only(type(exc), exc))
1110 if not kept: # nothing but framework: the message is the whole story
1111 return tail
1112 body = "".join(traceback.StackSummary.from_list(kept).format())
1113 return f"Traceback (most recent call last):\n{body}{tail}"
1116def global_default_suffix(name: str, *, code: bool = False, live: bool = True) -> str:
1117 """The `; default: …` tail a global's help line carries — one composition
1118 for `--help` and the docs table, so the two spellings cannot drift.
1119 Empty when there is nothing to print: no default at all, or a bare
1120 reading with no spelling of its own. *code* wraps the value in backticks
1121 for a markdown cell. *live* resolves a computed default on this machine
1122 — right at a terminal, wrong on a page that outlives it: the docs
1123 exporter passes False and gets the symbolic phrase instead, so the
1124 published reference stops baking in the build runner's core count."""
1125 from footman import _split
1127 shown, computed = _split.global_default(name, resolved=live)
1128 if not shown:
1129 return ""
1130 if computed and not live:
1131 # The phrase is prose, never a copyable value: no backticks, and
1132 # "(computed)" would be redundant under a description of how.
1133 return f"; default: {shown}"
1134 value = f"`{shown}`" if code else str(shown)
1135 return f"; default: {value}{' (computed)' if computed else ''}"