Coverage for src/footman/markdown.py: 97%

122 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +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__ = ["globals_table", "render_page", "render_site"] 

29 

30 

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

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

33 

34 Rendered straight from the CLI grammar (`split.GLOBALS`) — the same 

35 rows, in the same order, with the same words `--help` prints — so a 

36 docs page that regenerates this on each build can never drift from the 

37 runner. *prog* fills the `{prog}` placeholders, so a branded CLI's docs 

38 speak its own name. 

39 """ 

40 from footman import split 

41 

42 rows = [] 

43 for name, alias, _kind, hint, help_text in split.GLOBALS: 

44 main = f"`{name} {hint}`" if hint else f"`{name}`" 

45 label = f"`{alias}`, {main}" if alias else main 

46 rows.append((label, _cell(help_text.replace("{prog}", prog)))) 

47 width = max(len(label) for label, _ in rows) 

48 lines = [ 

49 f"| {'option':<{width}} | effect |", 

50 f"| {'-' * width} | ------ |", 

51 ] 

52 lines += [f"| {label:<{width}} | {effect} |" for label, effect in rows] 

53 return "\n".join(lines) + "\n" 

54 

55 

56def render_page( 

57 tree: dict[str, Any], 

58 *, 

59 path: tuple[str, ...] = (), 

60 heading: int = 1, 

61 flavor: str = "plain", 

62 prog: str = "fm", 

63) -> str: 

64 """One markdown document for the node at *path* (empty = whole tree).""" 

65 kind, node = _resolve(tree, path) 

66 if kind == "task": 

67 parts = _task_page(list(path), node, heading, flavor, prog) 

68 else: 

69 parts = _group_page(list(path), node, heading, flavor, prog) 

70 return "\n".join(parts).rstrip() + "\n" 

71 

72 

73def render_site( 

74 tree: dict[str, Any], 

75 *, 

76 path: tuple[str, ...] = (), 

77 flavor: str = "plain", 

78 prog: str = "fm", 

79) -> dict[str, str]: 

80 """A linked set of files for the node at *path*: `index.md` per group 

81 (name, help, a table of children with relative links), one file per task. 

82 Keys are POSIX-relative paths.""" 

83 kind, node = _resolve(tree, path) 

84 if kind == "task": 

85 name = path[-1] 

86 page = render_page(tree, path=path, heading=1, flavor=flavor, prog=prog) 

87 return {f"{name}.md": page} 

88 files: dict[str, str] = {} 

89 _site_group(list(path), node, "", files, flavor, prog) 

90 return files 

91 

92 

93# --- resolution --------------------------------------------------------------- 

94 

95 

96def _resolve(tree: dict, path: tuple[str, ...]) -> tuple[str, dict]: 

97 """Walk *path* to its node; ("task"|"group", node). Taught ValueError.""" 

98 node = tree 

99 for i, name in enumerate(path): 

100 if name in node["groups"]: 

101 node = node["groups"][name] 

102 elif i == len(path) - 1 and name in node["tasks"]: 

103 return "task", node["tasks"][name] 

104 else: 

105 known = list(node["groups"]) + list(node["tasks"]) 

106 where = ".".join(path[:i]) or "the root" 

107 raise ValueError( 

108 f"no task or group named {name!r} under {where} " 

109 f"(know: {', '.join(known) or 'nothing'})" 

110 ) 

111 return "group", node 

112 

113 

114# --- one task ----------------------------------------------------------------- 

115 

116 

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

118 return "-".join(path) 

119 

120 

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

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

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

124 

125 

126def _h(level: int, text: str, path: list[str], flavor: str) -> str: 

127 anchor = f" {{ #{_slug(path)} }}" if flavor == "material" and path else "" 

128 return f"{'#' * min(level, 6)} {text}{anchor}" 

129 

130 

131def _type_cell(p: dict) -> str: 

132 if p["kind"] == "flag": 

133 return "flag" 

134 if p.get("mapping"): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 return "KEY=VALUE" 

136 bits: list[str] = [] 

137 choices = p.get("choices") 

138 if choices: 

139 bits.append(" \\| ".join(f"`{c}`" for c in choices)) 

140 elif p.get("types"): 

141 bits.append(" \\| ".join(p["types"])) 

142 if p.get("multiple") or p.get("mapping"): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 bits.append("repeatable") 

144 if p["kind"] == "variadic": 

145 bits.append("variadic") 

146 return ", ".join(bits) 

147 

148 

149def _default_cell(p: dict) -> str: 

150 # A positional argument is required by kind (a default would have made it 

151 # an option — the grammar's load-bearing rule); flags/options say so. 

152 if p.get("required") or p["kind"] == "argument": 

153 return "*required*" 

154 if "default" in p and p["default"] is not None: 

155 return f"`{_cell(json.dumps(p['default']))}`" 

156 return "" 

157 

158 

159def _task_page( 

160 path: list[str], task: dict, level: int, flavor: str, prog: str 

161) -> list[str]: 

162 title = " ".join(path) or prog 

163 parts = [_h(level, title, path, flavor), ""] 

164 if task["help"]: 164 ↛ 166line 164 didn't jump to line 166 because the condition on line 164 was always true

165 parts += [task["help"], ""] 

166 if task.get("long"): 

167 parts += [task["long"], ""] 

168 if task.get("disabled"): 

169 parts += [f"*Unavailable here: {task['disabled']}*", ""] 

170 

171 fragments = [f for p in task["params"] if (f := _describe.usage_fragment(p))] 

172 usage = " ".join([prog, *path, *fragments]) 

173 parts += ["```text", usage, "```", ""] 

174 

175 if task["params"]: 

176 parts += [ 

177 "| Parameter | Type | Default | Description |", 

178 "| --- | --- | --- | --- |", 

179 ] 

180 for p in task["params"]: 

181 label = f"`{_describe.param_label(p)}`" 

182 doc = _cell(p.get("doc", "")) 

183 parts.append(f"| {label} | {_type_cell(p)} | {_default_cell(p)} | {doc} |") 

184 parts.append("") 

185 

186 invocation = _describe.example(path, task, prog) 

187 if flavor == "material": 

188 parts += [ 

189 "!!! example", 

190 "", 

191 " ```console", 

192 f" $ {invocation}", 

193 " ```", 

194 "", 

195 ] 

196 else: 

197 parts += [f"**Example:** `{invocation}`", ""] 

198 return parts 

199 

200 

201# --- one group, page mode ----------------------------------------------------- 

202 

203 

204def _group_page( 

205 path: list[str], node: dict, level: int, flavor: str, prog: str 

206) -> list[str]: 

207 title = " ".join(path) if path else f"{prog} tasks" 

208 parts = [_h(level, title, path, flavor), ""] 

209 if node.get("help"): 

210 parts += [node["help"], ""] 

211 for name, task in node["tasks"].items(): 

212 parts += _task_page([*path, name], task, level + 1, flavor, prog) 

213 for name, sub in node["groups"].items(): 

214 parts += _group_page([*path, name], sub, level + 1, flavor, prog) 

215 return parts 

216 

217 

218# --- site mode ---------------------------------------------------------------- 

219 

220 

221def _site_group( 

222 path: list[str], 

223 node: dict, 

224 prefix: str, 

225 files: dict[str, str], 

226 flavor: str, 

227 prog: str, 

228) -> None: 

229 title = " ".join(path) if path else f"{prog} tasks" 

230 parts = [_h(1, title, path, flavor), ""] 

231 if node.get("help"): 

232 parts += [node["help"], ""] 

233 rows = [ 

234 (f"[`{name}`]({name}.md)", _describe.task_line(task)) 

235 for name, task in node["tasks"].items() 

236 ] 

237 rows += [ 

238 (f"[`{name}`]({name}/index.md)", sub.get("help", "")) 

239 for name, sub in node["groups"].items() 

240 ] 

241 if rows: 241 ↛ 245line 241 didn't jump to line 245 because the condition on line 241 was always true

242 parts += ["| Task | Description |", "| --- | --- |"] 

243 parts += [f"| {link} | {_cell(text)} |" for link, text in rows] 

244 parts.append("") 

245 files[f"{prefix}index.md"] = "\n".join(parts).rstrip() + "\n" 

246 

247 for name, task in node["tasks"].items(): 

248 page = _task_page([*path, name], task, 1, flavor, prog) 

249 files[f"{prefix}{name}.md"] = "\n".join(page).rstrip() + "\n" 

250 for name, sub in node["groups"].items(): 

251 _site_group([*path, name], sub, f"{prefix}{name}/", files, flavor, prog)