Skip to content

Commit 1eb4418

Browse files
committed
Fix the MkDocs plugin and Sphinx directives
1 parent 61d6715 commit 1eb4418

7 files changed

Lines changed: 178 additions & 15 deletions

File tree

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- Fix the MkDocs plugin and Sphinx directives leaving Rich-based CLIs (such as `rich-click`) colorless in rendered documentation.
9+
810
## [`7.20.0` (2026-06-17)](https://github.com/kdeldycke/click-extra/compare/v7.19.0...v7.20.0)
911

1012
- Add `MultiChoice` to `click_extra.types`: a Click `ParamType` for comma-separated multi-pick from a fixed set of values, the pick-many counterpart to `click.Choice`.

click_extra/colorize.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727

2828
import os
2929
import re
30+
from collections.abc import Iterator
3031
from configparser import RawConfigParser
32+
from contextlib import contextmanager
3133
from dataclasses import dataclass, field, fields
3234
from enum import Enum
3335
from functools import lru_cache
@@ -120,6 +122,45 @@ def _nearest_256(r: int, g: int, b: int) -> int:
120122
"""
121123

122124

125+
@contextmanager
126+
def forced_color() -> Iterator[None]:
127+
"""Force ANSI color while Click Extra captures CLI text for documentation.
128+
129+
Click Extra renders CLI help and output into docs from both the MkDocs plugin
130+
(:mod:`click_extra.mkdocs`) and the Sphinx directives
131+
(:mod:`click_extra.sphinx.click`). During a build that output is a pipe, not a TTY,
132+
so the underlying renderers strip their escape codes. Two independent color systems
133+
have to be defeated:
134+
135+
- Click's, gated by ``should_strip_ansi`` / ``ctx.color`` (what ``click.echo`` and
136+
the Click and Click Extra help formatters consult). Sphinx's runner additionally
137+
flips this one with ``click.testing.CliRunner(color=True)``.
138+
- Rich's, gated by ``rich.console.Console.is_terminal``, which ignores the above and
139+
reads ``FORCE_COLOR`` (https://force-color.org). This is the system ``rich-click``
140+
uses, and ``color=True`` never reaches it.
141+
142+
``FORCE_COLOR`` is the only signal common to both systems (Rich reads it directly;
143+
Click Extra recognizes it through :data:`color_envvars`), so it is the lever we set
144+
here. We also clear the color-disabling variables Click Extra recognizes
145+
(``NO_COLOR``, ``LLM``, …) so an opt-out in the build environment cannot suppress the
146+
rendering. The previous environment is restored on exit, so the override never leaks
147+
beyond a single capture.
148+
"""
149+
disabling = [var for var, enables in color_envvars.items() if not enables]
150+
saved = {var: os.environ.get(var) for var in ("FORCE_COLOR", *disabling)}
151+
os.environ["FORCE_COLOR"] = "1"
152+
for var in disabling:
153+
os.environ.pop(var, None)
154+
try:
155+
yield
156+
finally:
157+
for var, value in saved.items():
158+
if value is None:
159+
os.environ.pop(var, None)
160+
else:
161+
os.environ[var] = value
162+
163+
123164
class ColorOption(ExtraOption):
124165
"""A pre-configured option that is adding a ``--color``/``--no-color`` (aliased by
125166
``--ansi``/``--no-ansi``) to keep or strip colors and ANSI codes from CLI output.

click_extra/mkdocs.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
import pymdownx.highlight
5454

55+
from .colorize import forced_color
5556
from .pygments import AnsiHtmlFormatter
5657

5758
ANSI_OUTPUT_FENCE = "```ansi-output"
@@ -82,9 +83,15 @@ def _ansi_lines(gen: Iterator[str]) -> Iterator[str]:
8283
def _patch_mkdocs_click() -> None:
8384
"""Patch ``mkdocs-click`` code blocks to use the ``ansi-output`` lexer.
8485
85-
Wraps ``_make_usage`` and ``_make_plain_options`` so that their fenced
86-
code blocks use the ANSI-aware lexer instead of plain ``text``. The patch
87-
is idempotent: calling it twice has no additional effect.
86+
Wraps ``_make_usage`` and ``_make_plain_options`` so that their fenced code blocks
87+
use the ANSI-aware lexer instead of plain ``text``, and so that the help they
88+
capture is rendered with color forced on. ``mkdocs-click`` builds help from
89+
``ctx.make_formatter()`` rather than executing the command, so the environment
90+
override (:func:`~click_extra.colorize.forced_color`) is the only lever available
91+
here, and the capture is materialized inside it: the wrapped generators read their
92+
formatter eagerly so the escape codes are produced while the override is active, not
93+
lazily once the caller iterates and the environment has been restored. The patch is
94+
idempotent: calling it twice has no additional effect.
8895
"""
8996
try:
9097
from mkdocs_click import _docs
@@ -99,11 +106,15 @@ def _patch_mkdocs_click() -> None:
99106

100107
@wraps(orig_usage)
101108
def _ansi_usage(*args, **kwargs):
102-
return _ansi_lines(orig_usage(*args, **kwargs))
109+
with forced_color():
110+
lines = list(orig_usage(*args, **kwargs))
111+
return _ansi_lines(iter(lines))
103112

104113
@wraps(orig_plain)
105114
def _ansi_plain_options(*args, **kwargs):
106-
return _ansi_lines(orig_plain(*args, **kwargs))
115+
with forced_color():
116+
lines = list(orig_plain(*args, **kwargs))
117+
return _ansi_lines(iter(lines))
107118

108119
_docs._make_usage = _ansi_usage
109120
_docs._make_plain_options = _ansi_plain_options

click_extra/sphinx/click.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from sphinx.directives.code import CodeBlock
5151
from sphinx.util import logging
5252

53+
from ..colorize import forced_color
5354
from ._base import StatelessDomain, compile_directive, make_cleanup
5455

5556
TYPE_CHECKING = False
@@ -131,7 +132,13 @@ class ClickRunner(CliRunner):
131132
"""A sub-class of :class:`click.testing.CliRunner` for Sphinx directive execution.
132133
133134
Produces unfiltered ANSI codes so that the ``Directive`` sub-classes below can
134-
render colors in the HTML output.
135+
render colors in the HTML output. Because Click Extra executes the documented
136+
command here, :meth:`invoke` forces color across both color systems a CLI might use:
137+
``color=True`` covers Click's (``should_strip_ansi``), and
138+
:func:`~click_extra.colorize.forced_color` sets ``FORCE_COLOR`` for Rich's (which
139+
``rich-click`` uses and ``color=True`` never reaches). The MkDocs plugin shares the
140+
latter lever but cannot pass ``color=True``, since it patches a renderer it never
141+
executes.
135142
"""
136143

137144
def __init__(self):
@@ -197,15 +204,22 @@ def invoke( # type: ignore[override]
197204
if terminate_input:
198205
input += "\x04"
199206

200-
result = super().invoke(
201-
cli=cli,
202-
args=args,
203-
input=input,
204-
env=env,
205-
prog_name=prog_name,
206-
color=True,
207-
**extra,
208-
)
207+
# ``color=True`` keeps ANSI in Click's color system: it flips
208+
# ``should_strip_ansi``, which CliRunner otherwise leaves stripping on its
209+
# non-TTY buffer. But rich-click renders through Rich's Console, a separate
210+
# system that ignores ``should_strip_ansi`` and only honors ``FORCE_COLOR``, so
211+
# ``forced_color()`` sets that too. Together they cover both color systems a
212+
# documented CLI might use.
213+
with forced_color():
214+
result = super().invoke(
215+
cli=cli,
216+
args=args,
217+
input=input,
218+
env=env,
219+
prog_name=prog_name,
220+
color=True,
221+
**extra,
222+
)
209223
# TODO: Maybe we can intercept the exception here either make it:
210224
# - part of the output in the rendered Sphinx code block, or
211225
# - re-raise it so Sphinx can display it properly in its logs.

tests/mkdocs/test_mkdocs.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
import os
2021
from textwrap import dedent
2122

2223
import click
@@ -240,6 +241,44 @@ def test_patch_mkdocs_click_idempotent():
240241
assert _docs._make_plain_options is plain_fn
241242

242243

244+
@pytest.mark.usefixtures("_clean_mkdocs_click")
245+
def test_patch_mkdocs_click_forces_color_during_capture(monkeypatch):
246+
"""The patched generators capture help with color forced, even under ``NO_COLOR``.
247+
248+
Swapping the lexer to ``ansi-output`` is pointless if the captured help carries no
249+
escape codes, which is exactly what happens when the build's stdout is a pipe. The
250+
wrapper must therefore materialize the help *while* the color override is active.
251+
Spying on ``make_formatter`` (where Rich and Click read the environment to decide on
252+
color) proves ``FORCE_COLOR`` is set and the disabling vars are cleared at capture
253+
time, and that the surrounding environment is restored once the capture completes.
254+
"""
255+
from mkdocs_click import _docs
256+
257+
monkeypatch.setenv("NO_COLOR", "1")
258+
monkeypatch.delenv("FORCE_COLOR", raising=False)
259+
260+
seen = {}
261+
real_make_formatter = click.Context.make_formatter
262+
263+
def spy_make_formatter(self):
264+
seen.setdefault("FORCE_COLOR", os.environ.get("FORCE_COLOR"))
265+
seen.setdefault("NO_COLOR", os.environ.get("NO_COLOR"))
266+
return real_make_formatter(self)
267+
268+
monkeypatch.setattr(click.Context, "make_formatter", spy_make_formatter)
269+
270+
_patch_mkdocs_click()
271+
ctx = click.Context(_hello_cmd, info_name="hello")
272+
list(_docs._make_usage(ctx))
273+
274+
# FORCE_COLOR was set (and NO_COLOR cleared) while the formatter was built...
275+
assert seen["FORCE_COLOR"] == "1"
276+
assert seen["NO_COLOR"] is None
277+
# ...and the build environment is restored once the capture completes.
278+
assert os.environ.get("FORCE_COLOR") is None
279+
assert os.environ["NO_COLOR"] == "1"
280+
281+
243282
@pytest.mark.usefixtures("_clean_pymdownx", "_clean_mkdocs_click")
244283
def test_on_config_patches_mkdocs_click():
245284
"""``on_config`` patches mkdocs-click alongside pymdownx.highlight."""

tests/sphinx/test_sphinx_click.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,3 +964,37 @@ def error_command(fail):
964964
+ "Something went wrong!\n"
965965
+ "</pre></div>"
966966
) in html_output
967+
968+
969+
def test_clickrunner_forces_color(monkeypatch):
970+
"""``ClickRunner`` forces ``FORCE_COLOR`` so Rich-based CLIs colorize under ``NO_COLOR``.
971+
972+
The runner already passes ``color=True`` (Click's color system). But rich-click
973+
renders help through Rich's ``Console``, gated on ``FORCE_COLOR``, which ``color=True``
974+
never reaches. The runner therefore also forces ``FORCE_COLOR`` (clearing the
975+
disabling vars) around the executed command, then restores the environment.
976+
"""
977+
import os
978+
979+
import click
980+
981+
from click_extra.sphinx.click import ClickRunner
982+
983+
monkeypatch.setenv("NO_COLOR", "1")
984+
monkeypatch.delenv("FORCE_COLOR", raising=False)
985+
986+
seen = {}
987+
988+
@click.command()
989+
def probe():
990+
seen["FORCE_COLOR"] = os.environ.get("FORCE_COLOR")
991+
seen["NO_COLOR"] = os.environ.get("NO_COLOR")
992+
993+
ClickRunner().invoke(probe, [])
994+
995+
# Color was forced through Rich's system while the command ran...
996+
assert seen["FORCE_COLOR"] == "1"
997+
assert seen["NO_COLOR"] is None
998+
# ...and the build environment is restored afterwards.
999+
assert os.environ.get("FORCE_COLOR") is None
1000+
assert os.environ["NO_COLOR"] == "1"

tests/test_colorize.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from click_extra.colorize import (
5656
HelpKeywords,
5757
color_envvars,
58+
forced_color,
5859
highlight,
5960
)
6061
from click_extra.pytest import (
@@ -2553,3 +2554,24 @@ def standalone_help():
25532554
)
25542555
assert result.exit_code == 0
25552556
assert not result.stderr
2557+
2558+
2559+
def test_forced_color_sets_and_restores_env(monkeypatch):
2560+
"""``forced_color`` forces ``FORCE_COLOR`` and clears Click Extra's disabling vars.
2561+
2562+
Inside the context the capture sees ``FORCE_COLOR=1`` with every flag that would
2563+
disable color (``NO_COLOR``, ``LLM``, …) removed; on exit the prior environment,
2564+
including any pre-existing values, is restored untouched.
2565+
"""
2566+
monkeypatch.setenv("NO_COLOR", "1")
2567+
monkeypatch.setenv("LLM", "1")
2568+
monkeypatch.delenv("FORCE_COLOR", raising=False)
2569+
2570+
with forced_color():
2571+
assert os.environ["FORCE_COLOR"] == "1"
2572+
assert "NO_COLOR" not in os.environ
2573+
assert "LLM" not in os.environ
2574+
2575+
assert "FORCE_COLOR" not in os.environ
2576+
assert os.environ["NO_COLOR"] == "1"
2577+
assert os.environ["LLM"] == "1"

0 commit comments

Comments
 (0)