diff --git a/docs/index.rst b/docs/index.rst index b617c23..ee6c84a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -149,6 +149,25 @@ Remember to use ``shell`` carefully to avoid unintended interpretation of shell syntax and swallowing of fatal errors! +Rich SVG output +--------------- + +By default, this extension outputs the command result as a literal block. However, you can enable +Rich-based SVG output to display colored and styled output in an SVG format:: + + + .. command-output:: ls -l + :rich: + + +.. command-output:: ls -l + :rich: + + +If you want this output by default, enable it globally in your ``conf.py`` with ``programoutput_force_rich = True``. +With this configuration, you won't need to flag each directive with ``:rich:``. + + Error handling -------------- diff --git a/pyproject.toml b/pyproject.toml index 96625b5..4eaa706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,55 @@ [build-system] -requires = [ - "wheel", - "setuptools", +requires = ["setuptools"] +build-backend="setuptools.build_meta" + +[project] +name = "sphinxcontrib-programoutput" +authors = [ + {name = "Sebastian Wiesner", email = "lunaryorn@gmail.com"}, + {name = "Jason Madden", email = "jason@seecoresoftware.com"}, ] +description="Sphinx extension to include program output" +readme = "README.rst" +license = { text = "BSD" } +version = "0.19dev" +requires-python = ">=3.8" +keywords = ["sphinx", "cli", "command", "output", "program", "example"] + +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3 :: Only', + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + 'Topic :: Documentation', + 'Topic :: Utilities', + 'Framework :: Sphinx', + 'Framework :: Sphinx :: Extension', +] + +dependencies = [ + "sphinx", +] + +[project.urls] +Repository = "https://github.com/OpenNTI/sphinxcontrib-programoutput" +Homepage = "https://github.com/OpenNTI/sphinxcontrib-programoutput" +Changelog = "https://github.com/OpenNTI/sphinxcontrib-programoutput/blob/master/CHANGES.rst" + +[project.optional-dependencies] +docs = ["furo"] +rich = ["rich"] + + +[tool.setuptools.packages.find] +where = ["./src"] +include = ["sphinxcontrib.programoutput"] \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 442a95a..0000000 --- a/setup.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011, 2012, Sebastian Wiesner -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: - -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -import os -import re -from setuptools import setup -from setuptools import find_namespace_packages - -def read_desc(): - with open('README.rst', encoding='utf-8') as stream: - readme = stream.read() - # CHANGES.rst includes sphinx-specific markup, so - # it can't be in the long description without some processing - # that we're not doing -- its invalid ReST - - return readme - -def read_version_number(): - VERSION_PATTERN = re.compile(r"__version__ = '([^']+)'") - with open(os.path.join('src', 'sphinxcontrib', 'programoutput', '__init__.py'), - encoding='utf-8') as stream: - for line in stream: - match = VERSION_PATTERN.search(line) - if match: - return match.group(1) - - raise ValueError('Could not extract version number') - -tests_require = [ - # Sphinx 8.1 stopped raising SphinxWarning when the ``logger.warning`` - # 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. -] - -setup( - name='sphinxcontrib-programoutput', - version=read_version_number(), - url='https://sphinxcontrib-programoutput.readthedocs.org/', - license='BSD', - author='Sebastian Wiesner', - author_email='lunaryorn@gmail.com', - maintainer="Jason Madden", - maintainer_email='jason@seecoresoftware.com', - description='Sphinx extension to include program output', - long_description=read_desc(), - keywords="sphinx cli command output program example", - zip_safe=False, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3 :: Only', - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - 'Topic :: Documentation', - 'Topic :: Utilities', - 'Framework :: Sphinx', - 'Framework :: Sphinx :: Extension', - ], - platforms='any', - packages=find_namespace_packages('src'), - package_dir={'': 'src'}, - include_package_data=True, - install_requires=[ - 'Sphinx>=5.0.0', - ], - extras_require={ - 'test': tests_require, - 'docs': [ - 'furo', - ], - }, - python_requires=">=3.8", -) diff --git a/src/sphinxcontrib/__init__.py b/src/sphinxcontrib/__init__.py deleted file mode 100644 index 69e3be5..0000000 --- a/src/sphinxcontrib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/src/sphinxcontrib/programoutput/__init__.py b/src/sphinxcontrib/programoutput/__init__.py index 9938514..8f779a2 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -89,11 +89,19 @@ class ProgramOutputDirective(rst.Directive): final_argument_whitespace = True required_arguments = 1 - option_spec = dict(shell=flag, prompt=flag, nostderr=flag, - ellipsis=_slice, extraargs=unchanged, - returncode=nonnegative_int, cwd=unchanged, - caption=unchanged, name=unchanged, - language=unchanged) + option_spec = dict( + shell=flag, + prompt=flag, + nostderr=flag, + ellipsis=_slice, + extraargs=unchanged, + returncode=nonnegative_int, + cwd=unchanged, + caption=unchanged, + name=unchanged, + language=unchanged, + rich=flag # When provided, enable rich SVG output. + ) def run(self): env = self.state.document.settings.env @@ -114,8 +122,11 @@ def run(self): node['use_shell'] = 'shell' in self.options node['returncode'] = self.options.get('returncode', 0) node['language'] = self.options.get('language', 'text') + node['rich'] = 'rich' in self.options + if 'ellipsis' in self.options: node['strip_lines'] = self.options['ellipsis'] + if 'caption' in self.options: caption = self.options['caption'] or self.arguments[0] node = _container_wrapper(self, node, caption) @@ -124,6 +135,7 @@ def run(self): return [node] + _Command = namedtuple( 'Command', 'command shell hide_standard_error working_directory') @@ -191,7 +203,8 @@ def execute(self): # pylint:disable=consider-using-with return Popen(command, shell=self.shell, stdout=PIPE, stderr=PIPE if self.hide_standard_error else STDOUT, - cwd=self.working_directory) + cwd=self.working_directory, + ) def get_output(self): """ @@ -253,54 +266,44 @@ def _prompt_template_as_unicode(app): def run_programs(app, doctree): """ - Execute all programs represented by ``program_output`` nodes in - ``doctree``. Each ``program_output`` node in ``doctree`` is then - replaced with a node, that represents the output of this program. + Execute all commands represented by `program_output` nodes in the doctree. + Each node is replaced with the output of the executed command. - The program output is retrieved from the cache in - ``app.env.programoutput_cache``. + If the 'rich' option is enabled on the node, the output is rendered as a styled SVG + using Rich's Console. The console width is configurable via envvar COLUMNS """ - cache = app.env.programoutput_cache for node in doctree.findall(program_output): + # Create a Command instance from the node's attributes. command = Command.from_program_output_node(node) try: + # Retrieve the command output from the cache (execute the command if not cached). returncode, output = cache[command] except EnvironmentError as error: + # Log error and replace the node with an error message if command execution fails. error_message = 'Command {0} failed: {1}'.format(command, error) error_node = doctree.reporter.error(error_message, base_node=node) - # Sphinx 1.8.0b1 started dropping all system_message nodes with a - # level less than 5 by default (or 2 if `keep_warnings` is set to true). - # This appears to be undocumented. Reporting failures is an important - # part of what this extension does, so we raise the default level. - error_node['level'] = 6 + error_node['level'] = 6 # Set high severity to ensure visibility. node.replace_self(error_node) + continue else: + # Log a warning if the return code does not match the expected value. if returncode != node['returncode']: logger.warning( 'Unexpected return code %s from command %r (output=%r)', returncode, command, output ) - # replace lines with ..., if ellipsis is specified - - # Recall that `output` is guaranteed to be a unicode string on - # all versions of Python. + # If the 'ellipsis' option is specified, replace the indicated lines with an ellipsis. if 'strip_lines' in node: start, stop = node['strip_lines'] lines = output.splitlines() lines[start:stop] = ['...'] output = '\n'.join(lines) + # Format output with a prompt if 'show_prompt' is enabled. if node['show_prompt']: - # The command in the node is also guaranteed to be - # unicode, but the prompt template might not be. This - # could be a native string on Python 2, or one with an - # explicit b prefix on 2 or 3 (for some reason). - # Attempt to decode it using UTF-8, preferentially, or - # fallback to sys.getfilesystemencoding(). If all that fails, fall back - # to the default encoding (which may have often worked before). prompt_template = _prompt_template_as_unicode(app) output = prompt_template.format( command=node['command'], @@ -308,14 +311,30 @@ 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['language'] = node['language'] + # Check if the 'rich' option is enabled to render output as a Rich SVG. + rich_flow = node.get('rich', None) or app.config.programoutput_force_rich + if rich_flow: + try: + # Import Rich's Console for rendering. + from rich.console import Console + from rich.text import Text + except ImportError: + rich_flow = False + logger.warning("Rich is not installed; using a plain literal block instead.") + + else: + console = Console(record=True) + console.print(Text.from_ansi(output)) + # Export the captured console output to an SVG string with inline styles. + svg_output = console.export_svg(title='') + # Create a raw node with the SVG content for HTML output. + new_node = nodes.raw('', svg_output, format='html') + + if not rich_flow: + # For non-rich output, simply create a literal block with the command output. + new_node = nodes.literal_block(output, output) + new_node['language'] = node['language'] + node.replace_self(new_node) @@ -333,8 +352,8 @@ def init_cache(app): def setup(app): - app.add_config_value('programoutput_prompt_template', - '$ {command}\n{output}', 'env') + app.add_config_value('programoutput_prompt_template', '$ {command}\n{output}', 'env') + app.add_config_value('programoutput_force_rich', False, 'env') app.add_directive('program-output', ProgramOutputDirective) app.add_directive('command-output', ProgramOutputDirective) app.connect('builder-inited', init_cache)