Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://pypi.org/project/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)
Expand Down
16 changes: 16 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
=======

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
62 changes: 47 additions & 15 deletions src/sphinxcontrib/programoutput/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,22 @@

.. moduleauthor:: Sebastian Wiesner <lunaryorn@gmail.com>
"""

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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
58 changes: 54 additions & 4 deletions src/sphinxcontrib/programoutput/tests/test_directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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""",
Expand All @@ -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__)
Expand Down