From ebfc909a4a08d52e7df9d7168013747a08ad9d85 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 19 Feb 2026 12:00:47 -0500 Subject: [PATCH 1/3] feat: support ansi via erbsland-sphinx-ansi Signed-off-by: Henry Schreiner --- CHANGES.rst | 5 ++- docs/index.rst | 16 +++++++ setup.py | 1 + src/sphinxcontrib/programoutput/__init__.py | 45 ++++++++++++++++--- .../programoutput/tests/test_directive.py | 32 +++++++++++++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7dbf774..2f1647d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,10 @@ 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. 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..0ffe825 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -38,6 +38,7 @@ import sys import os import shlex +import re from subprocess import Popen, PIPE, STDOUT from collections import defaultdict, namedtuple @@ -84,6 +85,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: + 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 +342,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 +365,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..4a0957a 100644 --- a/src/sphinxcontrib/programoutput/tests/test_directive.py +++ b/src/sphinxcontrib/programoutput/tests/test_directive.py @@ -451,6 +451,38 @@ def test_language_json(self): 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_dependency(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.assert_cache( + self.app, + sys.executable + " -c 'print(\"\\x1b[31mspam\\x1b[0m\")'", + '\x1b[31mspam\x1b[0m' + ) + + def test_suite(): return unittest.defaultTestLoader.loadTestsFromName(__name__) From 872b45b7d8270ddcca19cd3daca2744cb80b94e2 Mon Sep 17 00:00:00 2001 From: Jason Madden Date: Fri, 20 Feb 2026 12:25:18 -0600 Subject: [PATCH 2/3] Add a test for when the ansi extension is enabled. --- src/sphinxcontrib/programoutput/__init__.py | 21 ++++++------- .../programoutput/tests/test_directive.py | 30 ++++++++++++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/sphinxcontrib/programoutput/__init__.py b/src/sphinxcontrib/programoutput/__init__.py index 0ffe825..e3ffddb 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -32,21 +32,22 @@ .. moduleauthor:: Sebastian Wiesner """ - -from __future__ import print_function, division, absolute_import - -import sys import os -import shlex import re -from subprocess import Popen, PIPE, STDOUT -from collections import defaultdict, namedtuple +import shlex +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' @@ -106,7 +107,7 @@ def _create_output_node(output, use_ansi, app=None): try: from erbsland.sphinx.ansi.parser import ANSILiteralBlock - except ImportError: + 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 " diff --git a/src/sphinxcontrib/programoutput/tests/test_directive.py b/src/sphinxcontrib/programoutput/tests/test_directive.py index 4a0957a..219e412 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,7 +452,6 @@ 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) @@ -464,11 +465,10 @@ def test_use_ansi_config_forwarded(self): 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_dependency(self): + def test_use_ansi_missing_extension(self): with Patch('sphinxcontrib.programoutput.logger.warning') as patch_warning: doctree = self.doctree @@ -476,12 +476,28 @@ def test_use_ansi_missing_dependency(self): 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']) + 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__) From ad556e10f0ae7c5dcc01df6ebb565a7d79da8185 Mon Sep 17 00:00:00 2001 From: Jason Madden Date: Fri, 20 Feb 2026 12:35:20 -0600 Subject: [PATCH 3/3] The ansi extension is only on 3.10+. Note this in changes and fix the test that assumed it. --- CHANGES.rst | 9 ++++++--- src/sphinxcontrib/programoutput/tests/test_directive.py | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2f1647d..f64cb60 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,9 +6,12 @@ ================= - 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. + ``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/src/sphinxcontrib/programoutput/tests/test_directive.py b/src/sphinxcontrib/programoutput/tests/test_directive.py index 219e412..cd6aa48 100644 --- a/src/sphinxcontrib/programoutput/tests/test_directive.py +++ b/src/sphinxcontrib/programoutput/tests/test_directive.py @@ -487,6 +487,8 @@ def test_use_ansi_missing_extension(self): .. 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