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
« 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`.
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`).
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.
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"""
21from __future__ import annotations
23import contextlib
24import os
25import re
26import subprocess
27import tempfile
28from collections.abc import Iterator
29from dataclasses import dataclass, field
30from pathlib import Path
32from footman._toolspec import ToolSpec
34_SGR = re.compile("\x1b\\[") # a CSI escape — how "it emitted colour" is seen
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")
43@dataclass(frozen=True)
44class Trigger:
45 """How to make a tool emit colour: its argv, and the fixture it needs."""
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)
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}
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}
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"})
107@dataclass(frozen=True)
108class ColourFlag:
109 """The tokens that force a tool's colour on/off, and where they go."""
111 on: tuple[str, ...] = ()
112 off: tuple[str, ...] = ()
113 pre_verb: bool = False
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
131@dataclass(frozen=True)
132class Verdict:
133 """One tool's probed colour control, each direction categorised."""
135 on: str # "env" | "flag" | "none" | "unprobed"
136 off: str
137 flag: ColourFlag | None = None # the switch, when a direction needs one
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
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]
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).
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
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 ()
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 )
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 )
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"
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"
253 needs_flag = "flag" in (on, off)
254 return Verdict(on, off, flag if needs_flag else None)
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 }
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]
277COLOUR: dict[str, _Row] = {
278"""
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)