Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------

Expand Down
56 changes: 53 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"},
{name = "Jason Madden", email = "[email protected]"},
]
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"]
105 changes: 0 additions & 105 deletions setup.py

This file was deleted.

1 change: 0 additions & 1 deletion src/sphinxcontrib/__init__.py

This file was deleted.

95 changes: 57 additions & 38 deletions src/sphinxcontrib/programoutput/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -124,6 +135,7 @@ def run(self):
return [node]



_Command = namedtuple(
'Command', 'command shell hide_standard_error working_directory')

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -253,69 +266,75 @@ 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'],
output=output,
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)


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