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

1"""Render a task tree as markdown: one page, or a linked site of pages. 

2 

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. 

6 

7Two flavors: 

8 

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. 

14 

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

20 

21from __future__ import annotations 

22 

23import json 

24from typing import Any 

25 

26from footman import _describe 

27 

28__all__ = [ 

29 "branded", 

30 "config_table", 

31 "globals_table", 

32 "notes_table", 

33 "render_page", 

34 "render_site", 

35] 

36 

37 

38def branded(text: str, *, prog: str = "", config: str = "", env: str = "") -> str: 

39 """Fill the brand placeholders in generated help text. 

40 

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. 

45 

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 

55 

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 ) 

61 

62 

63def config_table(*, prog: str = "", config: str = "", env: str = "") -> str: 

64 """This runner's config keys as a markdown pipe table. 

65 

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 

74 

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" 

101 

102 

103def notes_table(*, prog: str = "", config: str = "") -> str: 

104 """The note kinds as a markdown pipe table. 

105 

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 

113 

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" 

135 

136 

137def globals_table(*, prog: str = "fm") -> str: 

138 """The runner's global options as a markdown pipe table. 

139 

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 

147 

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" 

169 

170 

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" 

186 

187 

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 

206 

207 

208# --- resolution --------------------------------------------------------------- 

209 

210 

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 

227 

228 

229# --- one task ----------------------------------------------------------------- 

230 

231 

232def _slug(path: list[str]) -> str: 

233 return "-".join(path) 

234 

235 

236def _cell(text: str) -> str: 

237 """Make *text* safe inside a pipe-table cell.""" 

238 return text.replace("|", "\\|").replace("\n", " ").strip() 

239 

240 

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

244 

245 

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) 

262 

263 

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

272 

273 

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 

296 

297 

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']}*", ""] 

318 

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, "```", ""] 

322 

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

333 

334 parts += _returns_section(task) 

335 

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 

349 

350 

351# --- one group, page mode ----------------------------------------------------- 

352 

353 

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 

366 

367 

368# --- site mode ---------------------------------------------------------------- 

369 

370 

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" 

396 

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)