Coverage for src/footman/_describe.py: 92%
161 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""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 uuid
17from pathlib import PurePath
18from typing import Any
20TYPE_WORD = {
21 "bool": "true/false",
22 "int": "an integer",
23 "float": "a number",
24 "path": "a path",
25 "str": "text",
26}
29# --- the palette --------------------------------------------------------------
30# One visual language for the whole CLI: bold for names and headers, dim for
31# mechanics and secondary text, cyan for footman's own numbers and accents,
32# green/red for verdicts. Every helper is a no-op when *on* is False, and
33# every surface gates *on* by its own stream's tty-ness — piped output stays
34# byte-clean.
37def wants_color(stream: Any, mode: str = "auto") -> bool:
38 """Whether to paint output for *stream* under a resolved colour *mode*.
40 `"always"`/`"never"` are the explicit tri-state answers (`--color=…`, config,
41 `FORCE_COLOR`/`NO_COLOR`); `"auto"` (the default) falls back to the stream's
42 own tty-ness, honouring `NO_COLOR` and a dumb terminal.
43 """
44 if mode == "never":
45 return False
46 if mode == "always":
47 return True
48 try:
49 tty = bool(stream.isatty())
50 except Exception:
51 tty = False
52 import os as _os
54 return tty and "NO_COLOR" not in _os.environ and _os.environ.get("TERM") != "dumb"
57def bold(text: str, on: bool) -> str:
58 return f"\033[1m{text}\033[0m" if on else text
61def dim(text: str, on: bool) -> str:
62 return f"\033[2m{text}\033[0m" if on else text
65def cyan(text: str, on: bool) -> str:
66 return f"\033[36m{text}\033[0m" if on else text
69def bold_cyan(text: str, on: bool) -> str:
70 return f"\033[1;36m{text}\033[0m" if on else text
73def red(text: str, on: bool) -> str:
74 return f"\033[31m{text}\033[0m" if on else text
77def value_hint(p: dict) -> str:
78 """The value placeholder shown for an option/argument in help output."""
79 if p.get("mapping"): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 return "KEY=VALUE"
81 choices = p.get("choices")
82 if choices:
83 return "{" + "|".join(choices) + "}"
84 types = p.get("types")
85 if types:
86 return "|".join(t.upper() for t in types)
87 return "VALUE"
90def usage_fragment(p: dict) -> str:
91 kind = p["kind"]
92 required = p.get("required")
93 if kind == "flag":
94 return f"--{p['name']}" if required else f"[--{p['name']}]"
95 if kind == "option":
96 core = f"--{p['name']} {value_hint(p)}"
97 if p.get("multiple") or p.get("mapping"): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 core += " ..."
99 return core if required else f"[{core}]"
100 if kind == "variadic":
101 return f"[<{p['name']}> ...]"
102 suffix = "..." if p.get("multiple") else ""
103 return f"<{p['name']}>{suffix}"
106def param_label(p: dict) -> str:
107 kind = p["kind"]
108 if kind == "flag":
109 return f"--{p['name']}"
110 if kind == "option":
111 return f"--{p['name']} {value_hint(p)}"
112 suffix = "..." if kind == "variadic" or p.get("multiple") else ""
113 return f"<{p['name']}>{suffix}"
116def param_detail(p: dict) -> str:
117 doc, mechanics = param_detail_parts(p)
118 return "; ".join(bit for bit in (doc, mechanics) if bit)
121def param_detail_parts(p: dict) -> tuple[str, str]:
122 """(author's doc, the mechanical suffix) — split so help can dim the
123 mechanics under the author's words."""
124 return p.get("doc", ""), _mechanics(p)
127def _mechanics(p: dict) -> str:
128 bits: list[str] = []
129 if p["kind"] == "flag":
130 bits.append(f"flag (--no-{p['name']} to disable)")
131 choices = p.get("choices")
132 if choices:
133 bits.append("one of " + "|".join(choices))
134 elif p.get("types"):
135 bits.append(" or ".join(TYPE_WORD.get(str(t), str(t)) for t in p["types"]))
136 if p.get("mapping"): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 bits.append("KEY=VALUE pairs (repeat appends)")
138 if p.get("multiple") or p.get("mapping"): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 bits.append("repeatable" if p.get("nosplit") else "repeatable/comma-split")
140 if p["kind"] == "variadic":
141 bits.append("extra arguments (also receives everything after --)")
142 if p.get("required"): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 bits.append("required")
144 return "; ".join(bits)
147def sample_value(p: dict) -> str:
148 """A realistic value for a param in a synthesised example: its first choice
149 when it has one, else an `<name>` placeholder."""
150 choices = p.get("choices")
151 return choices[0] if choices else f"<{p['name']}>"
154# CLI lines (usage, examples) are token lists — (kind, text) — so every
155# renderer paints the same structure: `prog` bold, `group` bold cyan (as in
156# the tree), `task` bold, `req`/`value` cyan, `opt` dim, `flag` plain.
157_CLI_PAINT = {
158 "prog": bold,
159 "group": bold_cyan,
160 "task": bold,
161 "req": cyan,
162 "value": cyan,
163 "opt": dim,
164 "flag": lambda text, on: text,
165}
168def paint_cli(parts: list[tuple[str, str]], on: bool) -> str:
169 """The one way to print a command line, syntax-lit by token kind."""
170 return " ".join(_CLI_PAINT.get(kind, bold)(text, on) for kind, text in parts)
173def invocation_parts(prog: str, path: list[str]) -> list[tuple[str, str]]:
174 """`prog group… task` as tokens — the head of every usage and example."""
175 parts: list[tuple[str, str]] = [("prog", prog)]
176 parts += [("group", name) for name in path[:-1]]
177 if path: 177 ↛ 179line 177 didn't jump to line 179 because the condition on line 177 was always true
178 parts.append(("task", path[-1]))
179 return parts
182def usage_parts(prog: str, path: list[str], task: dict) -> list[tuple[str, str]]:
183 parts = invocation_parts(prog, path)
184 for p in task["params"]:
185 fragment = usage_fragment(p)
186 if fragment: 186 ↛ 184line 186 didn't jump to line 184 because the condition on line 186 was always true
187 kind = "opt" if fragment.startswith("[") else "req"
188 parts.append((kind, fragment))
189 return parts
192def example_parts(path: list[str], task: dict, prog: str) -> list[tuple[str, str]]:
193 """A realistic invocation synthesised straight from the signature — required
194 positionals and options with sample values, plus one representative flag.
196 Derived, never written, so it can't drift from the task's actual parameters.
197 Optional options are skipped as noise; the shape teaches the invocation.
198 """
199 parts = invocation_parts(prog, path)
200 flag_shown = False
201 for p in task["params"]:
202 kind = p["kind"]
203 if kind in ("argument", "variadic"):
204 parts.append(("value", sample_value(p)))
205 elif kind == "option" and p.get("required"):
206 parts.append(("flag", f"--{p['name']}"))
207 parts.append(("value", sample_value(p)))
208 elif kind == "flag" and (p.get("required") or not flag_shown):
209 parts.append(("flag", f"--{p['name']}"))
210 flag_shown = True
211 return parts
214def example(path: list[str], task: dict, prog: str) -> str:
215 """The example invocation as plain text (the markdown exporter's form)."""
216 return " ".join(text for _, text in example_parts(path, task, prog))
219def task_line(task: dict) -> str:
220 """A task's one-line description, plus how it ends when that's notable:
221 availability if disabled, the Ctrl-C note if it runs until stopped."""
222 notes = []
223 if task.get("infinite"):
224 notes.append("(runs until Ctrl-C)")
225 if task.get("disabled"):
226 notes.append(f"(unavailable: {task['disabled']})")
227 if not notes:
228 return task["help"]
229 return f"{task['help']} {' '.join(notes)}".strip()
232def iter_tasks(node: dict, prefix: str = ""):
233 for name, task in node["tasks"].items():
234 yield f"{prefix}{name}", task_line(task)
235 for name, sub in node["groups"].items():
236 yield from iter_tasks(sub, f"{prefix}{name} ")
239def iter_group_paths(node: dict, prefix: str = ""):
240 for name, sub in node["groups"].items():
241 yield f"{prefix}{name}"
242 yield from iter_group_paths(sub, f"{prefix}{name} ")
245def json_default(value: object) -> object:
246 """JSON forms for the types footman coerces *in* — Path, Enum, datetime,
247 UUID, Decimal, dataclasses, sets — so a task may return what it accepts.
248 Anything else raises TypeError; the caller turns that into a
249 `returned_error` note rather than a broken envelope."""
250 if isinstance(value, PurePath):
251 return str(value)
252 if isinstance(value, enum.Enum):
253 return value.value
254 if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
255 return value.isoformat()
256 if isinstance(value, uuid.UUID):
257 return str(value)
258 if isinstance(value, decimal.Decimal):
259 return str(value) # str, not float: Decimal exists to keep precision
260 if dataclasses.is_dataclass(value) and not isinstance(value, type):
261 return dataclasses.asdict(value)
262 if isinstance(value, (set, frozenset)):
263 return sorted(value, key=repr) # deterministic order for golden tests
264 raise TypeError(f"{type(value).__name__} is not JSON-serialisable")
267def jsonable(value: Any) -> tuple[bool, Any]:
268 """(True, encoded) when *value* survives the JSON coercion mirror —
269 used to bake parameter defaults into the manifest; (False, None) when it
270 doesn't, in which case the key is simply omitted."""
271 try:
272 return True, json.loads(json.dumps(value, default=json_default))
273 except (TypeError, ValueError):
274 return False, None