Coverage for src/footman/markdown.py: 98%
166 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"""Render a task tree as markdown: one page, or a linked site of pages.
3Pure functions over manifest tree nodes — the same dicts `--json --list`
4emits — phrased through `footman._describe`, the module the `--help`
5renderer uses, so pages and help can never drift.
7Two flavors:
9- `plain` (the default) — CommonMark and pipe tables only. Safe verbatim
10 through `pymdownx.snippets` includes and pandoc → PDF/HTML.
11- `material` — opts into the extensions a zensical / mkdocs-material site
12 already enables: attr_list anchors on headings for stable deep links, and
13 an `!!! example` admonition for the synthesized invocation.
15`render_page` returns one document (headings start at *heading*, so a page
16can nest under a host site's own structure). `render_site` returns
17`{relative_path: content}` — one file per task, an `index.md` per group with
18relative links — ready to write into a docs tree.
19"""
21from __future__ import annotations
23import json
24from typing import Any
26from footman import _describe
28__all__ = [
29 "branded",
30 "config_table",
31 "globals_table",
32 "notes_table",
33 "render_page",
34 "render_site",
35]
38def branded(text: str, *, prog: str = "", config: str = "", env: str = "") -> str:
39 """Fill the brand placeholders in generated help text.
41 `{prog}` is the command someone types, `{config}` the `pyproject.toml`
42 table and standalone filename this runner reads, `{env}` its
43 environment-variable prefix. Empty arguments resolve from the
44 configured brand, so the ordinary call needs none of them.
46 This exists because a branded CLI documenting *footman's* table name
47 is not a cosmetic slip: a runner reads `[tool.<its own stem>]` and
48 nothing else, so telling an `acme` user to write `[tool.footman]`
49 documents a table `acme` never opens — settings that silently do
50 nothing. Entry-point names (`footman.tasks`, `footman.builtin`) are
51 identifiers every brand's providers advertise under, so they carry no
52 placeholder and must stay literal.
53 """
54 from footman import _paths
56 return (
57 text.replace("{prog}", prog or _paths.prog())
58 .replace("{config}", config or _paths.config_table())
59 .replace("{env}", env or _paths.env_prefix())
60 )
63def config_table(*, prog: str = "", config: str = "", env: str = "") -> str:
64 """This runner's config keys as a markdown pipe table.
66 Rendered from `_config.KEYS`, the same list the runner recognises, so a
67 docs page that regenerates this on each build can never describe a key
68 set the runner doesn't have — nor miss one, which is how `cwd` stayed
69 undocumented for four releases. The brand's own words fill the
70 placeholders (`branded`), so a branded CLI's table names the table it
71 actually reads.
72 """
73 from footman import _config
75 rows = [
76 (
77 f"`{name}`",
78 values,
79 default,
80 _cell(branded(help_text, prog=prog, config=config, env=env)),
81 )
82 for name, values, default, help_text in _config.KEYS
83 ]
84 widths = [max(len(row[i]) for row in rows) for i in range(3)]
85 head = ("key", "values", "default")
86 # Annotated: the header rows are all literals, so inference narrows to
87 # `list[LiteralString]` and the `+=` of real rows below stops type-checking.
88 lines: list[str] = [
89 "| "
90 + " | ".join(h.ljust(w) for h, w in zip(head, widths, strict=True))
91 + " | meaning |",
92 "| " + " | ".join("-" * w for w in widths) + " | ------- |",
93 ]
94 lines += [
95 "| "
96 + " | ".join(c.ljust(w) for c, w in zip(row[:3], widths, strict=True))
97 + f" | {row[3]} |"
98 for row in rows
99 ]
100 return "\n".join(lines) + "\n"
103def notes_table(*, prog: str = "", config: str = "") -> str:
104 """The note kinds as a markdown pipe table.
106 Rendered from `_notes.KINDS`, the same registry the runtime resolves
107 levels from and the config validator refuses unknown kinds against, so
108 a docs page that regenerates this on each build can never list a kind
109 footman lacks — nor miss one, which is what lets a policy author trust
110 the page.
111 """
112 from footman import _notes
114 rows = []
115 for family, instance, default, help_text in _notes.KINDS:
116 kind = f"`{family}:<{instance}>`" if instance else f"`{family}`"
117 rows.append(
118 (kind, f"`{default}`", _cell(branded(help_text, prog=prog, config=config)))
119 )
120 widths = [max(len(row[i]) for row in rows) for i in range(2)]
121 head = ("kind", "default")
122 lines: list[str] = [
123 "| "
124 + " | ".join(h.ljust(w) for h, w in zip(head, widths, strict=True))
125 + " | fires when |",
126 "| " + " | ".join("-" * w for w in widths) + " | ---------- |",
127 ]
128 lines += [
129 "| "
130 + " | ".join(c.ljust(w) for c, w in zip(row[:2], widths, strict=True))
131 + f" | {row[2]} |"
132 for row in rows
133 ]
134 return "\n".join(lines) + "\n"
137def globals_table(*, prog: str = "fm") -> str:
138 """The runner's global options as a markdown pipe table.
140 Rendered straight from the CLI grammar (`_split.GLOBALS`) — the same
141 rows, in the same order, with the same words `--help` prints — so a
142 docs page that regenerates this on each build can never drift from the
143 runner. *prog* fills the `{prog}` placeholders, so a branded CLI's docs
144 speak its own name.
145 """
146 from footman import _split
148 rows = []
149 for name, alias, _kind, hint, _default, help_text in _split.GLOBALS:
150 # `=`-attached, exactly as `--help` prints it: a value is always
151 # attached in this grammar, so notation that shows a space teaches
152 # a command line the runner refuses.
153 main = f"`{name}={hint}`" if hint else f"`{name}`"
154 label = f"`{alias}`, {main}" if alias else main
155 effect = help_text.replace("{prog}", prog)
156 # The default comes from the grammar too, so the page cannot say one
157 # thing while `--help` says another — the drift that hand-written
158 # "(default: …)" prose in a help string always ended in. The suffix
159 # is composed once, shared with `--help` itself.
160 effect += _describe.global_default_suffix(name, code=True, live=False)
161 rows.append((label, _cell(effect)))
162 width = max(len(label) for label, _ in rows)
163 lines = [
164 f"| {'option':<{width}} | effect |",
165 f"| {'-' * width} | ------ |",
166 ]
167 lines += [f"| {label:<{width}} | {effect} |" for label, effect in rows]
168 return "\n".join(lines) + "\n"
171def render_page(
172 tree: dict[str, Any],
173 *,
174 path: tuple[str, ...] = (),
175 heading: int = 1,
176 flavor: str = "plain",
177 prog: str = "fm",
178) -> str:
179 """One markdown document for the node at *path* (empty = whole tree)."""
180 kind, node = _resolve(tree, path)
181 if kind == "task":
182 parts = _task_page(list(path), node, heading, flavor, prog)
183 else:
184 parts = _group_page(list(path), node, heading, flavor, prog)
185 return "\n".join(parts).rstrip() + "\n"
188def render_site(
189 tree: dict[str, Any],
190 *,
191 path: tuple[str, ...] = (),
192 flavor: str = "plain",
193 prog: str = "fm",
194) -> dict[str, str]:
195 """A linked set of files for the node at *path*: `index.md` per group
196 (name, help, a table of children with relative links), one file per task.
197 Keys are POSIX-relative paths."""
198 kind, node = _resolve(tree, path)
199 if kind == "task":
200 name = path[-1]
201 page = render_page(tree, path=path, heading=1, flavor=flavor, prog=prog)
202 return {f"{name}.md": page}
203 files: dict[str, str] = {}
204 _site_group(list(path), node, "", files, flavor, prog)
205 return files
208# --- resolution ---------------------------------------------------------------
211def _resolve(tree: dict[str, Any], path: tuple[str, ...]) -> tuple[str, dict[str, Any]]:
212 """Walk *path* to its node; ("task"|"group", node). Taught ValueError."""
213 node = tree
214 for i, name in enumerate(path):
215 if name in node["groups"]:
216 node = node["groups"][name]
217 elif i == len(path) - 1 and name in node["tasks"]:
218 return "task", node["tasks"][name]
219 else:
220 known = list(node["groups"]) + list(node["tasks"])
221 where = ".".join(path[:i]) or "the root"
222 raise ValueError(
223 f"no task or group named {name!r} under {where} "
224 f"(know: {', '.join(known) or 'nothing'})"
225 )
226 return "group", node
229# --- one task -----------------------------------------------------------------
232def _slug(path: list[str]) -> str:
233 return "-".join(path)
236def _cell(text: str) -> str:
237 """Make *text* safe inside a pipe-table cell."""
238 return text.replace("|", "\\|").replace("\n", " ").strip()
241def _h(level: int, text: str, path: list[str], flavor: str) -> str:
242 anchor = f" {{ #{_slug(path)} }}" if flavor == "material" and path else ""
243 return f"{'#' * min(level, 6)} {text}{anchor}"
246def _type_cell(p: dict[str, Any]) -> str:
247 if p["kind"] == "flag":
248 return "flag"
249 if p.get("mapping"): 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 return "KEY=VALUE"
251 bits: list[str] = []
252 choices = p.get("choices")
253 if choices:
254 bits.append(" \\| ".join(f"`{c}`" for c in choices))
255 elif p.get("types"):
256 bits.append(" \\| ".join(p["types"]))
257 if p.get("multiple"): # a mapping returned early above 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 bits.append("repeatable")
259 if p["kind"] == "variadic":
260 bits.append("variadic")
261 return ", ".join(bits)
264def _default_cell(p: dict[str, Any]) -> str:
265 # A positional argument is required by kind (a default would have made it
266 # an option — the grammar's load-bearing rule); flags/options say so.
267 if p.get("required") or p["kind"] == "positional":
268 return "*required*"
269 if "default" in p and p["default"] is not None:
270 return f"`{_cell(json.dumps(p['default']))}`"
271 return ""
274def _returns_section(task: dict[str, Any]) -> list[str]:
275 """The output contract on a task page: the `Returns:` prose (or the
276 declared shape's phrase), and a field table when the shape has fields."""
277 returned = task.get("returned")
278 doc = task.get("returned_doc", "")
279 if returned is None and not doc:
280 return []
281 phrase = _describe.returns_phrase(returned) if returned is not None else ""
282 if doc and phrase:
283 head = f"**Returns:** {_cell(doc)} — {_cell(phrase)}"
284 else:
285 head = f"**Returns:** {_cell(doc or phrase)}"
286 parts = [head, ""]
287 if returned is not None and returned.get("fields"):
288 parts += ["| Field | Type |", "| --- | --- |"]
289 for name, fspec in returned["fields"].items():
290 optional = "" if fspec.get("required", True) else " *(optional)*"
291 parts.append(
292 f"| `{name}` | {_cell(_describe.returns_phrase(fspec))}{optional} |"
293 )
294 parts.append("")
295 return parts
298def _task_page(
299 path: list[str], task: dict[str, Any], level: int, flavor: str, prog: str
300) -> list[str]:
301 title = ".".join(path) or prog
302 parts = [_h(level, title, path, flavor), ""]
303 if task["help"]:
304 parts += [task["help"], ""]
305 if task.get("long"):
306 parts += [task["long"], ""]
307 if task.get("hidden"):
308 # Documented, and marked: the docs are where you look a machine-facing
309 # task up *because* the listings never offer it.
310 parts += [
311 "*Hidden: this task is not listed by "
312 f"`{prog} --list`, `--tree` or completion — it runs like any other "
313 "when named.*",
314 "",
315 ]
316 if task.get("disabled"):
317 parts += [f"*Unavailable here: {task['disabled']}*", ""]
319 fragments = [f for p in task["params"] if (f := _describe.usage_fragment(p))]
320 usage = " ".join([prog, ".".join(path), *fragments])
321 parts += ["```text", usage, "```", ""]
323 if task["params"]:
324 parts += [
325 "| Parameter | Type | Default | Description |",
326 "| --- | --- | --- | --- |",
327 ]
328 for p in task["params"]:
329 label = f"`{_describe.param_label(p)}`"
330 doc = _cell(p.get("doc", ""))
331 parts.append(f"| {label} | {_type_cell(p)} | {_default_cell(p)} | {doc} |")
332 parts.append("")
334 parts += _returns_section(task)
336 invocation = _describe.example(path, task, prog)
337 if flavor == "material":
338 parts += [
339 "!!! example",
340 "",
341 " ```console",
342 f" $ {invocation}",
343 " ```",
344 "",
345 ]
346 else:
347 parts += [f"**Example:** `{invocation}`", ""]
348 return parts
351# --- one group, page mode -----------------------------------------------------
354def _group_page(
355 path: list[str], node: dict[str, Any], level: int, flavor: str, prog: str
356) -> list[str]:
357 title = ".".join(path) if path else f"{prog} tasks"
358 parts = [_h(level, title, path, flavor), ""]
359 if node.get("help"):
360 parts += [node["help"], ""]
361 for name, task in node["tasks"].items():
362 parts += _task_page([*path, name], task, level + 1, flavor, prog)
363 for name, sub in node["groups"].items():
364 parts += _group_page([*path, name], sub, level + 1, flavor, prog)
365 return parts
368# --- site mode ----------------------------------------------------------------
371def _site_group(
372 path: list[str],
373 node: dict[str, Any],
374 prefix: str,
375 files: dict[str, str],
376 flavor: str,
377 prog: str,
378) -> None:
379 title = ".".join(path) if path else f"{prog} tasks"
380 parts = [_h(1, title, path, flavor), ""]
381 if node.get("help"):
382 parts += [node["help"], ""]
383 rows = [
384 (f"[`{name}`]({name}.md)", _describe.task_line(task))
385 for name, task in node["tasks"].items()
386 ]
387 rows += [
388 (f"[`{name}`]({name}/index.md)", sub.get("help", ""))
389 for name, sub in node["groups"].items()
390 ]
391 if rows: 391 ↛ 395line 391 didn't jump to line 395 because the condition on line 391 was always true
392 parts += ["| Task | Description |", "| --- | --- |"]
393 parts += [f"| {link} | {_cell(text)} |" for link, text in rows]
394 parts.append("")
395 files[f"{prefix}index.md"] = "\n".join(parts).rstrip() + "\n"
397 for name, task in node["tasks"].items():
398 page = _task_page([*path, name], task, 1, flavor, prog)
399 files[f"{prefix}{name}.md"] = "\n".join(page).rstrip() + "\n"
400 for name, sub in node["groups"].items():
401 _site_group([*path, name], sub, f"{prefix}{name}/", files, flavor, prog)