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

1"""Phrase manifest nodes for humans: labels, usage lines, examples. 

2 

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

8 

9from __future__ import annotations 

10 

11import dataclasses 

12import datetime 

13import decimal 

14import enum 

15import json 

16import uuid 

17from pathlib import PurePath 

18from typing import Any 

19 

20TYPE_WORD = { 

21 "bool": "true/false", 

22 "int": "an integer", 

23 "float": "a number", 

24 "path": "a path", 

25 "str": "text", 

26} 

27 

28 

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. 

35 

36 

37def wants_color(stream: Any, mode: str = "auto") -> bool: 

38 """Whether to paint output for *stream* under a resolved colour *mode*. 

39 

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 

53 

54 return tty and "NO_COLOR" not in _os.environ and _os.environ.get("TERM") != "dumb" 

55 

56 

57def bold(text: str, on: bool) -> str: 

58 return f"\033[1m{text}\033[0m" if on else text 

59 

60 

61def dim(text: str, on: bool) -> str: 

62 return f"\033[2m{text}\033[0m" if on else text 

63 

64 

65def cyan(text: str, on: bool) -> str: 

66 return f"\033[36m{text}\033[0m" if on else text 

67 

68 

69def bold_cyan(text: str, on: bool) -> str: 

70 return f"\033[1;36m{text}\033[0m" if on else text 

71 

72 

73def red(text: str, on: bool) -> str: 

74 return f"\033[31m{text}\033[0m" if on else text 

75 

76 

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" 

88 

89 

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

104 

105 

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

114 

115 

116def param_detail(p: dict) -> str: 

117 doc, mechanics = param_detail_parts(p) 

118 return "; ".join(bit for bit in (doc, mechanics) if bit) 

119 

120 

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) 

125 

126 

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) 

145 

146 

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

152 

153 

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} 

166 

167 

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) 

171 

172 

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 

180 

181 

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 

190 

191 

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. 

195 

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 

212 

213 

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

217 

218 

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

230 

231 

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

237 

238 

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

243 

244 

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

265 

266 

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