Skip to content

Commit 3f13618

Browse files
authored
Merge pull request #74 from henryiii/henryiii/feat/ansi
feat: support ansi via erbsland-sphinx-ansi
2 parents 6c76f3a + ad556e1 commit 3f13618

File tree

5 files changed

+125
-20
lines changed

5 files changed

+125
-20
lines changed

CHANGES.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
0.19 (unreleased)
66
=================
77

8-
- Nothing changed yet.
8+
- Reintroduce ANSI output integration through the
9+
``programoutput_use_ansi`` configuration value. When enabled,
10+
command output is emitted as an ANSI-aware literal block for
11+
processing by the `erbsland.sphinx.ansi
12+
<https://pypi.org/project/erbsland-sphinx-ansi/>`_ extension. Note
13+
that this extension and thus ANSI support is only available on
14+
Python 3.10 and newer.
915

1016

1117
0.18 (2024-12-06)

docs/index.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ Reference
239239
.. versionchanged:: 0.18
240240
Add the ``language`` option.
241241

242+
.. versionchanged:: 0.19
243+
Reintroduce ANSI support via :confval:`programoutput_use_ansi`
244+
using the ``erbsland.sphinx.ansi`` extension.
245+
242246
.. directive:: command-output
243247

244248
Same as :dir:`program-output`, but with enabled ``prompt`` option.
@@ -265,6 +269,18 @@ This extension understands the following configuration options:
265269
been applied.
266270
* ``returncode`` is the return code of the command as integer.
267271

272+
.. confval:: programoutput_use_ansi
273+
274+
A boolean that defaults to ``False``. If set to ``True``, generated output
275+
blocks are emitted as ``erbsland.sphinx.ansi.parser.ANSILiteralBlock`` so
276+
ANSI escape sequences can be rendered by ``erbsland.sphinx.ansi``.
277+
278+
This requires both the ``erbsland-sphinx-ansi`` package to be installed and
279+
``erbsland.sphinx.ansi`` to be enabled in your Sphinx ``extensions`` list.
280+
If this integration is unavailable (package missing or extension not
281+
enabled), a warning is logged and ANSI escape sequences are stripped from
282+
the output block.
283+
268284
Support
269285
=======
270286

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def read_version_number():
5353
# method is invoked. So we now have to test side effects.
5454
# That's OK, and the same side effect test works on older
5555
# versions as well.
56+
"erbsland-sphinx-ansi; python_version >= '3.10'",
5657
]
5758

5859
setup(

src/sphinxcontrib/programoutput/__init__.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,22 @@
3232
3333
.. moduleauthor:: Sebastian Wiesner <lunaryorn@gmail.com>
3434
"""
35-
36-
from __future__ import print_function, division, absolute_import
37-
38-
import sys
3935
import os
36+
import re
4037
import shlex
41-
from subprocess import Popen, PIPE, STDOUT
42-
from collections import defaultdict, namedtuple
38+
import sys
39+
from collections import defaultdict
40+
from collections import namedtuple
41+
from subprocess import PIPE
42+
from subprocess import STDOUT
43+
from subprocess import Popen
4344

4445
from docutils import nodes
4546
from docutils.parsers import rst
46-
from docutils.parsers.rst.directives import flag, unchanged, nonnegative_int
47+
from docutils.parsers.rst.directives import flag
48+
from docutils.parsers.rst.directives import nonnegative_int
49+
from docutils.parsers.rst.directives import unchanged
4750
from docutils.statemachine import StringList
48-
4951
from sphinx.util import logging as sphinx_logging
5052

5153
__version__ = '0.19.dev0'
@@ -84,6 +86,39 @@ def _slice(value):
8486
return tuple((parts + [None] * 2)[:2])
8587

8688

89+
_ANSI_FORMAT_SEQUENCE = re.compile(r'\x1b\[[^m]+m')
90+
91+
92+
def _strip_ansi_formatting(text):
93+
return _ANSI_FORMAT_SEQUENCE.sub('', text)
94+
95+
96+
def _create_output_node(output, use_ansi, app=None):
97+
if not use_ansi:
98+
return nodes.literal_block(output, output)
99+
100+
if app is not None and 'erbsland.sphinx.ansi' not in app.extensions:
101+
logger.warning(
102+
"programoutput_use_ansi is enabled, but 'erbsland.sphinx.ansi' "
103+
"is not enabled. Stripping ANSI escape codes instead."
104+
)
105+
stripped_output = _strip_ansi_formatting(output)
106+
return nodes.literal_block(stripped_output, stripped_output)
107+
108+
try:
109+
from erbsland.sphinx.ansi.parser import ANSILiteralBlock
110+
except ImportError: # pragma: no cover
111+
logger.warning(
112+
"programoutput_use_ansi is enabled, but erbsland ANSI support is "
113+
"not available. Stripping ANSI escape codes instead. Install "
114+
"'erbsland-sphinx-ansi' and enable 'erbsland.sphinx.ansi' to "
115+
"render ANSI output."
116+
)
117+
stripped_output = _strip_ansi_formatting(output)
118+
return nodes.literal_block(stripped_output, stripped_output)
119+
return ANSILiteralBlock(output, output)
120+
121+
87122
class ProgramOutputDirective(rst.Directive):
88123
has_content = False
89124
final_argument_whitespace = True
@@ -308,13 +343,9 @@ def run_programs(app, doctree):
308343
returncode=returncode
309344
)
310345

311-
# The node_class used to be switchable to
312-
# `sphinxcontrib.ansi.ansi_literal_block` if
313-
# `app.config.programoutput_use_ansi` was set. But
314-
# sphinxcontrib.ansi is no longer available on PyPI, so we
315-
# can't test that. And if we can't test it, we can't
316-
# support it.
317-
new_node = nodes.literal_block(output, output)
346+
new_node = _create_output_node(
347+
output, app.config.programoutput_use_ansi, app
348+
)
318349
new_node['language'] = node['language']
319350
node.replace_self(new_node)
320351

@@ -335,6 +366,7 @@ def init_cache(app):
335366
def setup(app):
336367
app.add_config_value('programoutput_prompt_template',
337368
'$ {command}\n{output}', 'env')
369+
app.add_config_value('programoutput_use_ansi', False, 'env')
338370
app.add_directive('program-output', ProgramOutputDirective)
339371
app.add_directive('command-output', ProgramOutputDirective)
340372
app.connect('builder-inited', init_cache)

src/sphinxcontrib/programoutput/tests/test_directive.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
import unittest
3030
from unittest.mock import patch as Patch
3131

32-
33-
from docutils.nodes import caption, container, literal_block, system_message
34-
32+
from docutils.nodes import caption
33+
from docutils.nodes import container
34+
from docutils.nodes import literal_block
35+
from docutils.nodes import system_message
3536
from sphinxcontrib.programoutput import Command
3637

3738
from . import AppMixin
@@ -42,6 +43,8 @@ def with_content(content, **kwargs):
4243
Always use a bare 'python' in the *content* string.
4344
4445
It will be replaced with ``sys.executable``.
46+
47+
Keyword arguments go directly into the Sphinx configuration.
4548
"""
4649
if 'python' in content:
4750
# XXX: This probably breaks if there are spaces in sys.executable.
@@ -439,7 +442,6 @@ def test_name_with_caption(self):
439442
self.assert_output(self.doctree, 'spam', caption='mycaption', name='myname')
440443
self.assert_cache(self.app, 'echo spam', 'spam')
441444

442-
443445
@with_content("""\
444446
.. program-output:: python -c 'import json; d = {"foo": "bar"}; print(json.dumps(d))'
445447
:language: json""",
@@ -450,6 +452,54 @@ def test_language_json(self):
450452
self.assertEqual(literal.astext(), '{"foo": "bar"}')
451453
self.assertEqual(literal["language"], "json")
452454

455+
@with_content("""\
456+
.. program-output:: echo spam""",
457+
programoutput_use_ansi=True)
458+
def test_use_ansi_config_forwarded(self):
459+
with Patch('sphinxcontrib.programoutput._create_output_node') as create_output_node:
460+
create_output_node.return_value = literal_block('spam', 'spam')
461+
doctree = self.doctree
462+
self.assert_output(doctree, 'spam')
463+
create_output_node.assert_called_once()
464+
self.assertEqual(create_output_node.call_args.args[0], 'spam')
465+
self.assertTrue(create_output_node.call_args.args[1])
466+
self.assert_cache(self.app, 'echo spam', 'spam')
467+
468+
@with_content("""\
469+
.. program-output:: python -c 'print("\\x1b[31mspam\\x1b[0m")'""",
470+
programoutput_use_ansi=True)
471+
def test_use_ansi_missing_extension(self):
472+
with Patch('sphinxcontrib.programoutput.logger.warning') as patch_warning:
473+
doctree = self.doctree
474+
475+
self.assert_output(doctree, 'spam')
476+
patch_warning.assert_called_once()
477+
warning = patch_warning.call_args.args[0]
478+
self.assertIn('programoutput_use_ansi is enabled', warning)
479+
self.assertIn("but 'erbsland.sphinx.ansi' is not enabled", warning)
480+
self.assert_cache(
481+
self.app,
482+
sys.executable + " -c 'print(\"\\x1b[31mspam\\x1b[0m\")'",
483+
'\x1b[31mspam\x1b[0m'
484+
)
485+
486+
@with_content("""\
487+
.. program-output:: python -c 'print("\\x1b[31mspam\\x1b[0m")'""",
488+
programoutput_use_ansi=True,
489+
extensions=['sphinxcontrib.programoutput', 'erbsland.sphinx.ansi'])
490+
@unittest.skipIf(sys.version_info[:2] < (3, 10),
491+
"The extension is only available on 3.10+")
492+
def test_use_ansi_enabled_extension(self):
493+
with Patch('sphinxcontrib.programoutput.logger.warning') as patch_warning:
494+
doctree = self.doctree
495+
496+
self.assert_output(doctree, '\x1b[31mspam\x1b[0m')
497+
patch_warning.assert_not_called()
498+
self.assert_cache(
499+
self.app,
500+
sys.executable + " -c 'print(\"\\x1b[31mspam\\x1b[0m\")'",
501+
'\x1b[31mspam\x1b[0m'
502+
)
453503

454504
def test_suite():
455505
return unittest.defaultTestLoader.loadTestsFromName(__name__)

0 commit comments

Comments
 (0)