diff --git a/CHANGES.rst b/CHANGES.rst
index 7dbf774..f64cb60 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,13 @@
0.19 (unreleased)
=================
-- Nothing changed yet.
+- Reintroduce ANSI output integration through the
+ ``programoutput_use_ansi`` configuration value. When enabled,
+ command output is emitted as an ANSI-aware literal block for
+ processing by the `erbsland.sphinx.ansi
+ `_ extension. Note
+ that this extension and thus ANSI support is only available on
+ Python 3.10 and newer.
0.18 (2024-12-06)
diff --git a/docs/index.rst b/docs/index.rst
index 32cf6af..23ccbb0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -239,6 +239,10 @@ Reference
.. versionchanged:: 0.18
Add the ``language`` option.
+ .. versionchanged:: 0.19
+ Reintroduce ANSI support via :confval:`programoutput_use_ansi`
+ using the ``erbsland.sphinx.ansi`` extension.
+
.. directive:: command-output
Same as :dir:`program-output`, but with enabled ``prompt`` option.
@@ -265,6 +269,18 @@ This extension understands the following configuration options:
been applied.
* ``returncode`` is the return code of the command as integer.
+.. confval:: programoutput_use_ansi
+
+ A boolean that defaults to ``False``. If set to ``True``, generated output
+ blocks are emitted as ``erbsland.sphinx.ansi.parser.ANSILiteralBlock`` so
+ ANSI escape sequences can be rendered by ``erbsland.sphinx.ansi``.
+
+ This requires both the ``erbsland-sphinx-ansi`` package to be installed and
+ ``erbsland.sphinx.ansi`` to be enabled in your Sphinx ``extensions`` list.
+ If this integration is unavailable (package missing or extension not
+ enabled), a warning is logged and ANSI escape sequences are stripped from
+ the output block.
+
Support
=======
diff --git a/setup.py b/setup.py
index 1d2ac73..11bbbaf 100644
--- a/setup.py
+++ b/setup.py
@@ -53,6 +53,7 @@ def read_version_number():
# method is invoked. So we now have to test side effects.
# That's OK, and the same side effect test works on older
# versions as well.
+ "erbsland-sphinx-ansi; python_version >= '3.10'",
]
setup(
diff --git a/src/sphinxcontrib/programoutput/__init__.py b/src/sphinxcontrib/programoutput/__init__.py
index 9938514..e3ffddb 100644
--- a/src/sphinxcontrib/programoutput/__init__.py
+++ b/src/sphinxcontrib/programoutput/__init__.py
@@ -32,20 +32,22 @@
.. moduleauthor:: Sebastian Wiesner
"""
-
-from __future__ import print_function, division, absolute_import
-
-import sys
import os
+import re
import shlex
-from subprocess import Popen, PIPE, STDOUT
-from collections import defaultdict, namedtuple
+import sys
+from collections import defaultdict
+from collections import namedtuple
+from subprocess import PIPE
+from subprocess import STDOUT
+from subprocess import Popen
from docutils import nodes
from docutils.parsers import rst
-from docutils.parsers.rst.directives import flag, unchanged, nonnegative_int
+from docutils.parsers.rst.directives import flag
+from docutils.parsers.rst.directives import nonnegative_int
+from docutils.parsers.rst.directives import unchanged
from docutils.statemachine import StringList
-
from sphinx.util import logging as sphinx_logging
__version__ = '0.19.dev0'
@@ -84,6 +86,39 @@ def _slice(value):
return tuple((parts + [None] * 2)[:2])
+_ANSI_FORMAT_SEQUENCE = re.compile(r'\x1b\[[^m]+m')
+
+
+def _strip_ansi_formatting(text):
+ return _ANSI_FORMAT_SEQUENCE.sub('', text)
+
+
+def _create_output_node(output, use_ansi, app=None):
+ if not use_ansi:
+ return nodes.literal_block(output, output)
+
+ if app is not None and 'erbsland.sphinx.ansi' not in app.extensions:
+ logger.warning(
+ "programoutput_use_ansi is enabled, but 'erbsland.sphinx.ansi' "
+ "is not enabled. Stripping ANSI escape codes instead."
+ )
+ stripped_output = _strip_ansi_formatting(output)
+ return nodes.literal_block(stripped_output, stripped_output)
+
+ try:
+ from erbsland.sphinx.ansi.parser import ANSILiteralBlock
+ except ImportError: # pragma: no cover
+ logger.warning(
+ "programoutput_use_ansi is enabled, but erbsland ANSI support is "
+ "not available. Stripping ANSI escape codes instead. Install "
+ "'erbsland-sphinx-ansi' and enable 'erbsland.sphinx.ansi' to "
+ "render ANSI output."
+ )
+ stripped_output = _strip_ansi_formatting(output)
+ return nodes.literal_block(stripped_output, stripped_output)
+ return ANSILiteralBlock(output, output)
+
+
class ProgramOutputDirective(rst.Directive):
has_content = False
final_argument_whitespace = True
@@ -308,13 +343,9 @@ def run_programs(app, doctree):
returncode=returncode
)
- # The node_class used to be switchable to
- # `sphinxcontrib.ansi.ansi_literal_block` if
- # `app.config.programoutput_use_ansi` was set. But
- # sphinxcontrib.ansi is no longer available on PyPI, so we
- # can't test that. And if we can't test it, we can't
- # support it.
- new_node = nodes.literal_block(output, output)
+ new_node = _create_output_node(
+ output, app.config.programoutput_use_ansi, app
+ )
new_node['language'] = node['language']
node.replace_self(new_node)
@@ -335,6 +366,7 @@ def init_cache(app):
def setup(app):
app.add_config_value('programoutput_prompt_template',
'$ {command}\n{output}', 'env')
+ app.add_config_value('programoutput_use_ansi', False, 'env')
app.add_directive('program-output', ProgramOutputDirective)
app.add_directive('command-output', ProgramOutputDirective)
app.connect('builder-inited', init_cache)
diff --git a/src/sphinxcontrib/programoutput/tests/test_directive.py b/src/sphinxcontrib/programoutput/tests/test_directive.py
index 5a587e9..cd6aa48 100644
--- a/src/sphinxcontrib/programoutput/tests/test_directive.py
+++ b/src/sphinxcontrib/programoutput/tests/test_directive.py
@@ -29,9 +29,10 @@
import unittest
from unittest.mock import patch as Patch
-
-from docutils.nodes import caption, container, literal_block, system_message
-
+from docutils.nodes import caption
+from docutils.nodes import container
+from docutils.nodes import literal_block
+from docutils.nodes import system_message
from sphinxcontrib.programoutput import Command
from . import AppMixin
@@ -42,6 +43,8 @@ def with_content(content, **kwargs):
Always use a bare 'python' in the *content* string.
It will be replaced with ``sys.executable``.
+
+ Keyword arguments go directly into the Sphinx configuration.
"""
if 'python' in content:
# XXX: This probably breaks if there are spaces in sys.executable.
@@ -439,7 +442,6 @@ def test_name_with_caption(self):
self.assert_output(self.doctree, 'spam', caption='mycaption', name='myname')
self.assert_cache(self.app, 'echo spam', 'spam')
-
@with_content("""\
.. program-output:: python -c 'import json; d = {"foo": "bar"}; print(json.dumps(d))'
:language: json""",
@@ -450,6 +452,54 @@ def test_language_json(self):
self.assertEqual(literal.astext(), '{"foo": "bar"}')
self.assertEqual(literal["language"], "json")
+ @with_content("""\
+ .. program-output:: echo spam""",
+ programoutput_use_ansi=True)
+ def test_use_ansi_config_forwarded(self):
+ with Patch('sphinxcontrib.programoutput._create_output_node') as create_output_node:
+ create_output_node.return_value = literal_block('spam', 'spam')
+ doctree = self.doctree
+ self.assert_output(doctree, 'spam')
+ create_output_node.assert_called_once()
+ self.assertEqual(create_output_node.call_args.args[0], 'spam')
+ self.assertTrue(create_output_node.call_args.args[1])
+ self.assert_cache(self.app, 'echo spam', 'spam')
+
+ @with_content("""\
+ .. program-output:: python -c 'print("\\x1b[31mspam\\x1b[0m")'""",
+ programoutput_use_ansi=True)
+ def test_use_ansi_missing_extension(self):
+ with Patch('sphinxcontrib.programoutput.logger.warning') as patch_warning:
+ doctree = self.doctree
+
+ self.assert_output(doctree, 'spam')
+ patch_warning.assert_called_once()
+ warning = patch_warning.call_args.args[0]
+ self.assertIn('programoutput_use_ansi is enabled', warning)
+ self.assertIn("but 'erbsland.sphinx.ansi' is not enabled", warning)
+ self.assert_cache(
+ self.app,
+ sys.executable + " -c 'print(\"\\x1b[31mspam\\x1b[0m\")'",
+ '\x1b[31mspam\x1b[0m'
+ )
+
+ @with_content("""\
+ .. program-output:: python -c 'print("\\x1b[31mspam\\x1b[0m")'""",
+ programoutput_use_ansi=True,
+ extensions=['sphinxcontrib.programoutput', 'erbsland.sphinx.ansi'])
+ @unittest.skipIf(sys.version_info[:2] < (3, 10),
+ "The extension is only available on 3.10+")
+ def test_use_ansi_enabled_extension(self):
+ with Patch('sphinxcontrib.programoutput.logger.warning') as patch_warning:
+ doctree = self.doctree
+
+ self.assert_output(doctree, '\x1b[31mspam\x1b[0m')
+ patch_warning.assert_not_called()
+ self.assert_cache(
+ self.app,
+ sys.executable + " -c 'print(\"\\x1b[31mspam\\x1b[0m\")'",
+ '\x1b[31mspam\x1b[0m'
+ )
def test_suite():
return unittest.defaultTestLoader.loadTestsFromName(__name__)