From 3585afc07f18e5da25ce3d39f51fac428c9300c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Thu, 6 Mar 2025 12:12:09 -0300 Subject: [PATCH 1/3] wip --- docs/index.rst | 14 +++ setup.py | 1 + src/sphinxcontrib/programoutput/__init__.py | 133 +++++++++++++------- 3 files changed, 106 insertions(+), 42 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index b617c23..d4af26b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -149,6 +149,20 @@ Remember to use ``shell`` carefully to avoid unintended interpretation of shell syntax and swallowing of fatal errors! +Rich output +----------- + +If you want to include rich output, like colorized text or tables, you can use ``:rich:``:: + + .. command-output:: ls -l + :rich: + + +.. command-output:: ls -l + :rich: + + + Error handling -------------- diff --git a/setup.py b/setup.py index 442a95a..7667227 100644 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ def read_version_number(): ], extras_require={ 'test': tests_require, + 'rich': ['rich'], 'docs': [ 'furo', ], diff --git a/src/sphinxcontrib/programoutput/__init__.py b/src/sphinxcontrib/programoutput/__init__.py index 9938514..9c17714 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -77,6 +77,21 @@ def _container_wrapper(directive, literal_node, caption): return container_node +def wrap_with_caption(node, caption, source='', line=0): + """ + Wrap the given node in a container with a caption. + This mimics a figure-like construct. + """ + container = nodes.container('', classes=['literal-block-wrapper']) + caption_node = nodes.caption(caption, caption) + caption_node.source = source + caption_node.line = line + container += caption_node + container += node + return container + + + def _slice(value): parts = [int(v.strip()) for v in value.split(',')] if len(parts) > 2: @@ -89,11 +104,20 @@ 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, # Caption for wrapping the output (for both rich and literal). + title=unchanged, # Title for rich SVG export only. + name=unchanged, + language=unchanged, + rich=flag # When provided, enable rich SVG output. + ) def run(self): env = self.state.document.settings.env @@ -114,16 +138,23 @@ def run(self): node['use_shell'] = 'shell' in self.options node['returncode'] = self.options.get('returncode', 0) node['language'] = self.options.get('language', 'text') + + import ipdb;ipdb.set_trace() + if 'rich' in self.options: + node['rich'] = True + if 'title' in self.options: + node['title'] = self.options['title'] + # Store the caption (if provided) regardless of the output mode. + if 'caption' in self.options: + node['caption'] = self.options['caption'] or self.arguments[0] + 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) - self.add_name(node) return [node] + _Command = namedtuple( 'Command', 'command shell hide_standard_error working_directory') @@ -253,54 +284,47 @@ 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. - - The program output is retrieved from the cache in - ``app.env.programoutput_cache``. + Execute all commands represented by `program_output` nodes in the doctree. + Each node is replaced with the output of the executed command. + + 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 the + 'programoutput_rich_width' setting in conf.py. An optional title (':title:') can be provided + for the rich SVG output. In both rich and literal modes, if a ':caption:' is provided, + the output will be wrapped in a container with a caption. """ - 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 +332,37 @@ 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. + if node.get('rich', False): + try: + # Import Rich's Console for rendering. + from rich.console import Console + except ImportError: + # If Rich is not installed, log a warning and fall back to a literal block. + logger.warning("Rich is not installed; using a plain literal block instead.") + new_node = nodes.literal_block(output, output) + new_node['language'] = node['language'] + else: + # Get the console width from configuration (default: 80). + rich_width = app.config.programoutput_rich_width + # Create a Console instance in record mode with the configured width. + console = Console(record=True, width=rich_width) + console.print(output) + # Use the provided title for the SVG if specified; otherwise, pass an empty string. + svg_title = node.get('title', '') + # Export the captured console output to an SVG string with inline styles. + svg_output = console.export_svg(title=svg_title, inline_styles=True) + # Create a raw node with the SVG content for HTML output. + new_node = nodes.raw('', svg_output, format='html') + else: + # 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'] + + # If a caption is provided, wrap the output node in a container with a caption. + if 'caption' in node: + new_node = wrap_with_caption(new_node, node['caption'], node.get('source', ''), node.line) + # Replace the original node with the new node. node.replace_self(new_node) @@ -333,8 +380,10 @@ def init_cache(app): def setup(app): - app.add_config_value('programoutput_prompt_template', - '$ {command}\n{output}', 'env') + import ipdb;ipdb.set_trace() + app.add_config_value('programoutput_prompt_template', '$ {command}\n{output}', 'env') + # Add a configuration value for the Rich console width (default: 80) + app.add_config_value('programoutput_rich_width', 100, 'env') app.add_directive('program-output', ProgramOutputDirective) app.add_directive('command-output', ProgramOutputDirective) app.connect('builder-inited', init_cache) From 199aa0202e92e4910a155c98f032b785c45e2d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Thu, 6 Mar 2025 15:35:14 -0300 Subject: [PATCH 2/3] no title. simplify logic --- pyproject.toml | 57 ++++++++++- setup.py | 106 -------------------- src/sphinxcontrib/__init__.py | 1 - src/sphinxcontrib/programoutput/__init__.py | 31 +++--- 4 files changed, 67 insertions(+), 128 deletions(-) delete mode 100644 setup.py delete mode 100644 src/sphinxcontrib/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 96625b5..d109192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,56 @@ [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", +] + + +[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 7667227..0000000 --- a/setup.py +++ /dev/null @@ -1,106 +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, - 'rich': ['rich'], - '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 9c17714..127726a 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -112,8 +112,7 @@ class ProgramOutputDirective(rst.Directive): extraargs=unchanged, returncode=nonnegative_int, cwd=unchanged, - caption=unchanged, # Caption for wrapping the output (for both rich and literal). - title=unchanged, # Title for rich SVG export only. + caption=unchanged, name=unchanged, language=unchanged, rich=flag # When provided, enable rich SVG output. @@ -138,12 +137,8 @@ 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: - import ipdb;ipdb.set_trace() - if 'rich' in self.options: - node['rich'] = True - if 'title' in self.options: - node['title'] = self.options['title'] # Store the caption (if provided) regardless of the output mode. if 'caption' in self.options: node['caption'] = self.options['caption'] or self.arguments[0] @@ -222,7 +217,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): """ @@ -333,28 +329,28 @@ def run_programs(app, doctree): ) # Check if the 'rich' option is enabled to render output as a Rich SVG. - if node.get('rich', False): + rich_flow = node.get('rich', False) + if rich_flow: try: # Import Rich's Console for rendering. from rich.console import Console + from rich.text import Text except ImportError: - # If Rich is not installed, log a warning and fall back to a literal block. + rich_flow = False logger.warning("Rich is not installed; using a plain literal block instead.") - new_node = nodes.literal_block(output, output) - new_node['language'] = node['language'] + else: # Get the console width from configuration (default: 80). rich_width = app.config.programoutput_rich_width # Create a Console instance in record mode with the configured width. console = Console(record=True, width=rich_width) - console.print(output) - # Use the provided title for the SVG if specified; otherwise, pass an empty string. - svg_title = node.get('title', '') + console.print(Text.from_ansi(output)) # Export the captured console output to an SVG string with inline styles. - svg_output = console.export_svg(title=svg_title, inline_styles=True) + svg_output = console.export_svg() # Create a raw node with the SVG content for HTML output. new_node = nodes.raw('', svg_output, format='html') - else: + + 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'] @@ -380,7 +376,6 @@ def init_cache(app): def setup(app): - import ipdb;ipdb.set_trace() app.add_config_value('programoutput_prompt_template', '$ {command}\n{output}', 'env') # Add a configuration value for the Rich console width (default: 80) app.add_config_value('programoutput_rich_width', 100, 'env') From bcb169553916d8b1f22fb59f66eb1b8dc392b003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Thu, 6 Mar 2025 16:23:39 -0300 Subject: [PATCH 3/3] documentation --- docs/index.rst | 11 +++-- pyproject.toml | 5 +-- src/sphinxcontrib/programoutput/__init__.py | 49 +++++---------------- 3 files changed, 22 insertions(+), 43 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index d4af26b..ee6c84a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -149,10 +149,12 @@ Remember to use ``shell`` carefully to avoid unintended interpretation of shell syntax and swallowing of fatal errors! -Rich output ------------ +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:: -If you want to include rich output, like colorized text or tables, you can use ``:rich:``:: .. command-output:: ls -l :rich: @@ -162,6 +164,9 @@ If you want to include rich output, like colorized text or tables, you can use ` :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 d109192..4eaa706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,8 @@ Homepage = "https://github.com/OpenNTI/sphinxcontrib-programoutput" Changelog = "https://github.com/OpenNTI/sphinxcontrib-programoutput/blob/master/CHANGES.rst" [project.optional-dependencies] -docs = [ - "furo", -] +docs = ["furo"] +rich = ["rich"] [tool.setuptools.packages.find] diff --git a/src/sphinxcontrib/programoutput/__init__.py b/src/sphinxcontrib/programoutput/__init__.py index 127726a..8f779a2 100644 --- a/src/sphinxcontrib/programoutput/__init__.py +++ b/src/sphinxcontrib/programoutput/__init__.py @@ -77,21 +77,6 @@ def _container_wrapper(directive, literal_node, caption): return container_node -def wrap_with_caption(node, caption, source='', line=0): - """ - Wrap the given node in a container with a caption. - This mimics a figure-like construct. - """ - container = nodes.container('', classes=['literal-block-wrapper']) - caption_node = nodes.caption(caption, caption) - caption_node.source = source - caption_node.line = line - container += caption_node - container += node - return container - - - def _slice(value): parts = [int(v.strip()) for v in value.split(',')] if len(parts) > 2: @@ -137,14 +122,15 @@ 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: - - # Store the caption (if provided) regardless of the output mode. - if 'caption' in self.options: - node['caption'] = self.options['caption'] or self.arguments[0] - + 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) + self.add_name(node) return [node] @@ -284,10 +270,7 @@ def run_programs(app, doctree): Each node is replaced with the output of the executed command. 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 the - 'programoutput_rich_width' setting in conf.py. An optional title (':title:') can be provided - for the rich SVG output. In both rich and literal modes, if a ':caption:' is provided, - the output will be wrapped in a container with a caption. + using Rich's Console. The console width is configurable via envvar COLUMNS """ cache = app.env.programoutput_cache @@ -329,7 +312,7 @@ def run_programs(app, doctree): ) # Check if the 'rich' option is enabled to render output as a Rich SVG. - rich_flow = node.get('rich', False) + rich_flow = node.get('rich', None) or app.config.programoutput_force_rich if rich_flow: try: # Import Rich's Console for rendering. @@ -340,13 +323,10 @@ def run_programs(app, doctree): logger.warning("Rich is not installed; using a plain literal block instead.") else: - # Get the console width from configuration (default: 80). - rich_width = app.config.programoutput_rich_width - # Create a Console instance in record mode with the configured width. - console = Console(record=True, width=rich_width) + 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() + 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') @@ -355,10 +335,6 @@ def run_programs(app, doctree): new_node = nodes.literal_block(output, output) new_node['language'] = node['language'] - # If a caption is provided, wrap the output node in a container with a caption. - if 'caption' in node: - new_node = wrap_with_caption(new_node, node['caption'], node.get('source', ''), node.line) - # Replace the original node with the new node. node.replace_self(new_node) @@ -377,8 +353,7 @@ def init_cache(app): def setup(app): app.add_config_value('programoutput_prompt_template', '$ {command}\n{output}', 'env') - # Add a configuration value for the Rich console width (default: 80) - app.add_config_value('programoutput_rich_width', 100, '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)