Skip to content

Commit d97b794

Browse files
committed
docs: color CLI on Python 3.14+
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 2fd10ba commit d97b794

4 files changed

Lines changed: 180 additions & 4 deletions

File tree

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version: 2
88
build:
99
os: ubuntu-24.04
1010
tools:
11-
python: "3.13"
11+
python: "3.14"
1212
commands:
1313
- git fetch --unshallow
1414
- asdf plugin add uv

docs/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
5757
# ones.
5858
extensions = [
59-
"conftabs",
59+
"conftabs", # in /ext
60+
"progout", # in /ext
6061
"myst_parser",
6162
"sphinx-jsonschema",
6263
"sphinx.ext.autodoc",
@@ -68,7 +69,6 @@
6869
"sphinx_copybutton",
6970
"sphinx_inline_tabs",
7071
"sphinx_tippy",
71-
"sphinxcontrib.programoutput",
7272
]
7373

7474
# Add any paths that contain templates here, relative to this directory.

docs/ext/progout.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
import subprocess
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING, Any, ClassVar
8+
9+
from docutils import nodes
10+
from docutils.parsers.rst import Directive
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Generator
14+
15+
16+
ROOT = Path(__file__).parent.parent.parent.resolve()
17+
18+
19+
ANSI_ESCAPE = re.compile(r"\x1b\[([0-9;]*)m")
20+
21+
ANSI_COLORS = {
22+
"30": "#000000", # black
23+
"31": "#dc3545", # red
24+
"32": "#28a745", # green
25+
"33": "#ffc107", # yellow
26+
"34": "#007bff", # blue
27+
"35": "#6f42c1", # magenta
28+
"36": "#17a2b8", # cyan
29+
"37": "#f8f9fa", # white
30+
"90": "#6c757d", # bright black (gray)
31+
"91": "#ff6b6b", # bright red
32+
"92": "#51cf66", # bright green
33+
"93": "#ffd43b", # bright yellow
34+
"94": "#4d7fff", # bright blue
35+
"95": "#da77f2", # bright magenta
36+
"96": "#15aabf", # bright cyan
37+
"97": "#ffffff", # bright white
38+
}
39+
40+
41+
class AnsiToHtmlConverter:
42+
"""Convert ANSI escape codes to HTML with state tracking."""
43+
44+
def __init__(self) -> None:
45+
self.open_spans = 0
46+
47+
def process_codes(self, codes_str: str) -> Generator[str, None, None]:
48+
"""Process ANSI codes and yield corresponding HTML."""
49+
codes = codes_str.split(";") if codes_str else ["0"]
50+
51+
for code in codes:
52+
if code == "0":
53+
# Reset - close all open spans
54+
if self.open_spans > 0:
55+
yield "</span>" * self.open_spans
56+
self.open_spans = 0
57+
elif code == "1":
58+
# Bold
59+
yield '<span style="font-weight: bold;">'
60+
self.open_spans += 1
61+
elif code in ANSI_COLORS:
62+
# Foreground color
63+
color = ANSI_COLORS[code]
64+
yield f'<span style="color: {color};">'
65+
self.open_spans += 1
66+
67+
def convert(self, text: str) -> str:
68+
"""Convert ANSI escape codes in text to HTML spans."""
69+
last_end = 0
70+
result: list[str] = []
71+
for match in ANSI_ESCAPE.finditer(text):
72+
# Add text before the match
73+
result.append(text[last_end : match.start()])
74+
# Process the ANSI code
75+
result.extend(self.process_codes(match.group(1)))
76+
last_end = match.end()
77+
78+
# Add remaining text
79+
result.append(text[last_end:])
80+
81+
# Close any remaining open spans at the end
82+
if self.open_spans > 0:
83+
result.append("</span>" * self.open_spans)
84+
self.open_spans = 0
85+
86+
return "".join(result)
87+
88+
89+
class ShowCliDirective(Directive):
90+
"""Include the output of a CLI command in the documentation."""
91+
92+
has_content = False
93+
required_arguments = 1
94+
optional_arguments = 0
95+
final_argument_whitespace = True
96+
option_spec: ClassVar = {"cwd": str}
97+
98+
def run(self) -> list[Any]:
99+
"""Execute the command and return its output as a code block."""
100+
command = self.arguments[0]
101+
102+
# Get the directory of the current file being processed
103+
source_file = Path(self.state.document.current_source)
104+
current_dir = source_file.parent
105+
106+
# Get the cwd option, defaulting to the current file's directory
107+
cwd_option = self.options.get("cwd")
108+
cwd = (current_dir / cwd_option).resolve() if cwd_option else current_dir
109+
env = os.environ
110+
env["PYTHON_COLORS"] = "1"
111+
env["FORCE_COLOR"] = "1"
112+
env.pop("NO_COLOR", None)
113+
114+
try:
115+
# Run the command and capture output
116+
result = subprocess.run(
117+
command.split(),
118+
capture_output=True,
119+
check=True,
120+
text=True,
121+
cwd=str(cwd),
122+
env=env,
123+
)
124+
output = result.stdout or result.stderr
125+
except Exception as e: # noqa: BLE001
126+
return [
127+
nodes.error(
128+
None,
129+
nodes.paragraph(text=f"Error running command: {command}\n{e}"),
130+
)
131+
]
132+
133+
# Check if the builder is HTML
134+
builder_name = self.state.document.settings.env.app.builder.name
135+
is_html = builder_name == "html"
136+
137+
# Strip ANSI codes if not building HTML
138+
if not is_html:
139+
display_output = ANSI_ESCAPE.sub("", output)
140+
if self.name == "command-output":
141+
display_output = f"$ {command}\n{display_output}"
142+
literal_block = nodes.literal_block(display_output)
143+
literal_block["language"] = "text"
144+
return [literal_block]
145+
146+
# Convert ANSI codes to HTML
147+
html_output = AnsiToHtmlConverter().convert(output)
148+
149+
# Add the run block if this was `command-output`
150+
if self.name == "command-output":
151+
color = ANSI_COLORS["90"]
152+
html_output = f'<span style="color: {color};">$</span> <span style="font-weight: bold;">{command}</span>\n{html_output}'
153+
154+
# Create a raw HTML node with the colored output
155+
raw_html = nodes.raw("", html_output, format="html")
156+
literal_block = nodes.literal_block(output, raw_html)
157+
literal_block["language"] = "text"
158+
159+
# Return as a container with pre styling
160+
container = nodes.container()
161+
container += nodes.raw(
162+
"",
163+
f'<div class="highlight-text notranslate"><div class="highlight"><pre>{html_output}</pre></div></div>',
164+
format="html",
165+
)
166+
return [container]
167+
168+
169+
def setup(app: Any) -> dict[str, Any]:
170+
app.add_directive("program-output", ShowCliDirective)
171+
app.add_directive("command-output", ShowCliDirective)
172+
173+
return {
174+
"version": "0.1",
175+
"parallel_read_safe": True,
176+
"parallel_write_safe": True,
177+
}

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ docs = [
146146
"sphinx-inline-tabs",
147147
"sphinx-jsonschema",
148148
"sphinx-tippy",
149-
"sphinxcontrib-programoutput",
150149
]
151150

152151

0 commit comments

Comments
 (0)