Coverage for src/footman/_colorprobe.py: 86%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Probe a tool's colour control by running it — the engine behind 

2`fm footman tools color`. 

3 

4footman forces colour into the tools it spawns (see `context.color_env`): by the 

5environment for the modern set, by the tool's own flag for the few that ignore 

6it. Which is which is a fact about each tool, and the honest way to learn it is 

7to *run* the tool and look at the bytes. This module does that: for each tool it 

8forces colour on and off, first by environment then by flag, and records whether 

9each direction obeys the environment (`env`), needs the flag (`flag`), or can be 

10forced neither way (`none`). 

11 

12The result generates `_colordata.py` — read by `tools.py` for its forcing table 

13and by the docs for the support table. A maintainer runs it against the 

14provisioned binaries; nothing here is imported on a normal `fm` run. 

15 

16A tool only colourises when it has something to colour, so each needs a 

17hardcoded *trigger*: a command (and any fixture files) that produces colourable 

18output. Figured out once per tool; one without a trigger reports `unprobed`. 

19""" 

20 

21from __future__ import annotations 

22 

23import contextlib 

24import os 

25import re 

26import subprocess 

27import tempfile 

28from collections.abc import Iterator 

29from dataclasses import dataclass, field 

30from pathlib import Path 

31 

32from footman._toolspec import ToolSpec 

33 

34_SGR = re.compile("\x1b\\[") # a CSI escape — how "it emitted colour" is seen 

35 

36# Forcing environments, mirroring `context.color_env` (presence/absence): on sets 

37# the force vars; off is NO_COLOR with every force var *absent*. 

38_ON_ENV = {"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1", "CLICOLOR": "1"} 

39_OFF_ENV = {"NO_COLOR": "1"} 

40_COLOR_VARS = ("FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "NO_COLOR") 

41 

42 

43@dataclass(frozen=True) 

44class Trigger: 

45 """How to make a tool emit colour: its argv, and the fixture it needs.""" 

46 

47 args: tuple[str, ...] 

48 files: dict[str, str] = field(default_factory=dict) # filename -> content 

49 git: bool = False # init a repo and commit the files first (for git itself) 

50 

51 

52# One command per tool that produces colourable output — figured out once each, 

53# from the tool's own verbs (see its stub). Every curated tool has one, so none 

54# is left `unprobed`; a tool that then shows no colour by any means is a true 

55# `none`. Keyed by driver key. `PY_TYPE`/`SPELL`/`HTML`/`MD` are shared fixtures. 

56_PY_TYPE = "x: int = 'a'\n" # a type error, for the type checkers 

57_SPELL = "helllo wrold\n" 

58TRIGGERS: dict[str, Trigger] = { 

59 "ruff": Trigger(("check", "bad.py"), {"bad.py": "import os\n"}), 

60 "ruff_format": Trigger(("format", "--diff", "bad.py"), {"bad.py": "x=1\n"}), 

61 "basedpyright": Trigger(("bad.py",), {"bad.py": _PY_TYPE}), 

62 "mypy": Trigger(("bad.py",), {"bad.py": _PY_TYPE}), 

63 "ty": Trigger(("check", "bad.py"), {"bad.py": _PY_TYPE}), 

64 "pytest": Trigger(("t.py",), {"t.py": "def test():\n assert True\n"}), 

65 "cspell": Trigger(("lint", "bad.txt"), {"bad.txt": _SPELL}), 

66 "uv": Trigger(("python", "list")), 

67 "gh": Trigger(("--help",)), 

68 "bun": Trigger(("--help",)), 

69 "prek": Trigger(("--help",)), 

70 "mkdocs": Trigger(("--help",)), # in-process for footman; probed as subprocess 

71 "twine": Trigger(("check", "s.py"), {"s.py": "print(1)\n"}), 

72 "cmake": Trigger(("-S", ".", "-B", "b")), # configuring an empty dir → error 

73 "ninja": Trigger((), {"build.ninja": "rule f\n command = false\nbuild x: f\n"}), 

74 "build": Trigger( 

75 ("--sdist",), 

76 {"pyproject.toml": '[project]\nname="x"\nversion="1"\n'}, 

77 ), 

78 "git": Trigger(("log", "--oneline", "-1"), {"a.txt": "hi\n"}, git=True), 

79 "git_cliff": Trigger((), {"a.txt": "hi\n"}, git=True), 

80 "git_changelog": Trigger((".",), {"a.txt": "hi\n"}, git=True), 

81 "djlint": Trigger(("bad.html",), {"bad.html": "<div ></div>\n"}), 

82 "markdownlint": Trigger(("bad.md",), {"bad.md": "#x\n"}), 

83 "eclint": Trigger(("-h",)), 

84 "coverage": Trigger(("report",)), 

85 "python": Trigger(("--version",)), # the interpreter emits no colour 

86 "zensical": Trigger(("--help",)), 

87} 

88 

89# A tool's own colour switch, when the stub's `color_flags()` can't surface it: 

90# git spells it as a pre-verb config; cspell/mypy use plain boolean flags, not a 

91# `--color=always/never` choice. `(on, off, pre_verb)`; auto-detected 

92# `--color=always` switches still come from the stub (see `flag_candidate`). 

93_CURATED: dict[str, tuple[tuple[str, ...], tuple[str, ...], bool]] = { 

94 "git": (("-c", "color.ui=always"), ("-c", "color.ui=never"), True), 

95 "cspell": (("--color",), ("--no-color",), False), 

96 "mypy": (("--color-output",), ("--no-color-output",), False), 

97} 

98 

99# Pass-through wrappers: their own CLI output doesn't colour, and their job is to 

100# run *another* program (a container) whose colour is the wrapped program's 

101# business plus whether the caller passed a tty (`docker run -t`). footman 

102# neither forces nor suppresses it — it faithfully relays whatever comes through. 

103# So `env`/`flag`/`none` don't apply; the verdict is `n/a`. 

104_PASSTHROUGH = frozenset({"docker"}) 

105 

106 

107@dataclass(frozen=True) 

108class ColourFlag: 

109 """The tokens that force a tool's colour on/off, and where they go.""" 

110 

111 on: tuple[str, ...] = () 

112 off: tuple[str, ...] = () 

113 pre_verb: bool = False 

114 

115 

116def flag_candidate(key: str, spec: ToolSpec) -> ColourFlag | None: 

117 """The colour switch to try for a tool: a curated quirk (git), else a 

118 `--color=always/never` detected from its stub (`ToolSpec.color_flags`).""" 

119 if key in _CURATED: 

120 on, off, pre = _CURATED[key] 

121 return ColourFlag(on, off, pre) 

122 detected = spec.color_flags() 

123 for _verb, (flag, on_val, off_val) in sorted(detected.items()): 123 ↛ 128line 123 didn't jump to line 128 because the loop on line 123 didn't complete

124 on = (f"{flag}={on_val}",) if on_val else () 

125 off = (f"{flag}={off_val}",) if off_val else () 

126 if on or off: 126 ↛ 123line 126 didn't jump to line 123 because the condition on line 126 was always true

127 return ColourFlag(on, off, pre_verb=False) 

128 return None 

129 

130 

131@dataclass(frozen=True) 

132class Verdict: 

133 """One tool's probed colour control, each direction categorised.""" 

134 

135 on: str # "env" | "flag" | "none" | "unprobed" 

136 off: str 

137 flag: ColourFlag | None = None # the switch, when a direction needs one 

138 

139 

140@contextlib.contextmanager 

141def _fixture(binary: str, trigger: Trigger) -> Iterator[Path]: 

142 """A throwaway directory with the trigger's files (and a git repo if asked).""" 

143 with tempfile.TemporaryDirectory(prefix="fm-color-") as tmp: 

144 cwd = Path(tmp) 

145 for name, content in trigger.files.items(): 

146 (cwd / name).write_text(content, encoding="utf-8") 

147 if trigger.git: 

148 quiet = {"cwd": cwd, "capture_output": True} 

149 subprocess.run([binary, "init", "-q"], **quiet) 

150 subprocess.run([binary, "add", "-A"], **quiet) 

151 subprocess.run( 

152 [ 

153 binary, 

154 "-c", 

155 "user.email=t@t.t", 

156 "-c", 

157 "user.name=t", 

158 "commit", 

159 "-qm", 

160 "x", 

161 ], 

162 **quiet, 

163 ) 

164 yield cwd 

165 

166 

167def _argv( 

168 binary: str, trigger: Trigger, pre: tuple[str, ...], post: tuple[str, ...] 

169) -> list[str]: 

170 """`binary [pre-verb flags] <trigger args> [trailing flags]`.""" 

171 return [binary, *pre, *trigger.args, *post] 

172 

173 

174def _capture(argv: list[str], cwd: Path, env_add: dict[str, str]) -> str: 

175 """Run *argv* over a pipe with a clean-plus-*env_add* environment; return 

176 its combined stdout+stderr (empty on failure to launch). 

177 

178 A real `TERM` is set when the ambient one is empty or `dumb` — many tools 

179 (mypy, …) refuse colour without one even under `FORCE_COLOR`, and the probe 

180 must judge them as they behave from a genuine terminal, not a bare CI env.""" 

181 env = {k: v for k, v in os.environ.items() if k not in _COLOR_VARS} 

182 if env.get("TERM", "") in ("", "dumb"): 182 ↛ 184line 182 didn't jump to line 184 because the condition on line 182 was always true

183 env["TERM"] = "xterm-256color" 

184 env.update(env_add) 

185 try: 

186 done = subprocess.run( 

187 argv, 

188 cwd=cwd, 

189 env=env, 

190 capture_output=True, 

191 text=True, 

192 encoding="utf-8", 

193 errors="replace", 

194 timeout=60, 

195 ) 

196 except (OSError, subprocess.SubprocessError): 

197 return "" 

198 return done.stdout + done.stderr 

199 

200 

201def probe(key: str, binary: str, spec: ToolSpec) -> Verdict: 

202 """Categorise one tool by running its trigger with colour forced each way.""" 

203 if key in _PASSTHROUGH: # a wrapper; colour isn't footman's to control 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 return Verdict("n/a", "n/a") 

205 trigger = TRIGGERS.get(key) 

206 if trigger is None: 

207 return Verdict("unprobed", "unprobed") 

208 flag = flag_candidate(key, spec) 

209 pre_on = flag.on if (flag and flag.pre_verb) else () 

210 post_on = flag.on if (flag and not flag.pre_verb) else () 

211 pre_off = flag.off if (flag and flag.pre_verb) else () 

212 post_off = flag.off if (flag and not flag.pre_verb) else () 

213 

214 def coloured(env_add: dict[str, str], pre=(), post=()) -> bool: 

215 return bool( 

216 _SGR.search(_capture(_argv(binary, trigger, pre, post), cwd, env_add)) 

217 ) 

218 

219 with _fixture(binary, trigger) as cwd: 

220 # Force colour maximally (environment and flag). No colour then means 

221 # the tool can't be forced: `none` if it produced output to colour, 

222 # `unprobed` if the trigger produced nothing to judge. 

223 maxed = _capture(_argv(binary, trigger, pre_on, post_on), cwd, _ON_ENV) 

224 if not _SGR.search(maxed): 224 ↛ 229line 224 didn't jump to line 229 because the condition on line 224 was never true

225 # It produced output but no colour by any means → `none`; footman 

226 # can't make it colour over its pipe, so there is nothing to suppress 

227 # either (`off` is `n/a`, not a spurious `env`). No output at all → 

228 # the trigger judged nothing (`unprobed`). 

229 return ( 

230 Verdict("none", "n/a") 

231 if maxed.strip() 

232 else Verdict("unprobed", "unprobed") 

233 ) 

234 

235 if coloured(_ON_ENV): 

236 on = "env" 

237 elif flag and (pre_on or post_on) and coloured({}, pre_on, post_on): 237 ↛ 240line 237 didn't jump to line 240 because the condition on line 237 was always true

238 on = "flag" 

239 else: 

240 on = "none" 

241 

242 # A tool footman can't force *on* has nothing to turn *off* over a pipe — 

243 # a monochrome result there is the pipe, not the tool respecting NO_COLOR. 

244 if on == "none": 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 off = "n/a" 

246 elif not coloured(_OFF_ENV): # footman's off signal keeps it clean 246 ↛ 248line 246 didn't jump to line 248 because the condition on line 246 was always true

247 off = "env" 

248 elif flag and (pre_off or post_off) and not coloured({}, pre_off, post_off): 

249 off = "flag" 

250 else: 

251 off = "none" 

252 

253 needs_flag = "flag" in (on, off) 

254 return Verdict(on, off, flag if needs_flag else None) 

255 

256 

257def probe_all( 

258 installed: list[tuple[str, str, str, ToolSpec]], 

259) -> dict[str, tuple[str, Verdict]]: 

260 """Probe each `(key, argv0, binary, spec)`; return `{key: (argv0, verdict)}`.""" 

261 return { 

262 key: (argv0, probe(key, binary, spec)) for key, argv0, binary, spec in installed 

263 } 

264 

265 

266_HEADER = """\ 

267# Generated by `fm footman tools color` — do not edit by hand. 

268# 

269# Each curated tool's probed colour control: how footman forces it on and off. 

270# `tools.py` reads this for its forcing table; the docs read it for the support 

271# table. A tool obeys `env` (FORCE_COLOR/NO_COLOR), needs its own `flag`, or can 

272# force neither way (`none`); `unprobed` had no trigger. 

273# 

274# key -> (argv0, on, off, flag_on, flag_off, pre_verb) 

275_Row = tuple[str, str, str, tuple[str, ...], tuple[str, ...], bool] 

276 

277COLOUR: dict[str, _Row] = { 

278""" 

279 

280 

281def render(results: dict[str, tuple[str, Verdict]]) -> str: 

282 """The text of `_colordata.py` for a batch of probe results.""" 

283 lines = [_HEADER] 

284 for key in sorted(results): 

285 argv0, v = results[key] 

286 f_on = v.flag.on if v.flag else () 

287 f_off = v.flag.off if v.flag else () 

288 pre = v.flag.pre_verb if v.flag else False 

289 lines.append( 

290 f" {key!r}: ({argv0!r}, {v.on!r}, {v.off!r}, " 

291 f"{f_on!r}, {f_off!r}, {pre!r})," 

292 ) 

293 lines.append("}\n") 

294 return "\n".join(lines)