Source code for click_extra.sphinx.click

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Sphinx rendering of CLI based on Click Extra.

.. seealso::
    These directives are based on `Pallets' Sphinx Themes
    <https://github.com/pallets/pallets-sphinx-themes/blob/main/src/pallets_sphinx_themes/themes/click/domain.py>`_,
    `released under a BSD-3-Clause license
    <https://github.com/pallets/pallets-sphinx-themes/blob/main/LICENSE.txt>`_.

    Compared to the latter, it:

    - Add support for MyST syntax.
    - Adds rendering of ANSI codes in CLI results.
    - Has better error handling and reporting which helps you pinpoint the failing
      code in your documentation.
    - Removes the ``println`` function which was used to explicitly print a blank
      line. This is no longer needed as it is now handled natively.
"""

from __future__ import annotations

import ast
import contextlib
import shlex
import subprocess
import sys
import tempfile
from functools import cached_property, partial

import click
from click.testing import CliRunner, EchoingStdin
from docutils import nodes
from docutils.statemachine import StringList
from sphinx.directives import SphinxDirective, directives
from sphinx.directives.code import CodeBlock
from sphinx.util import logging

from ._base import StatelessDomain, compile_directive, make_cleanup

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable
    from typing import ClassVar

    from sphinx.util.typing import OptionSpec


logger = logging.getLogger(__name__)


RST_INDENT = " " * 3
"""The indentation used for rST code blocks lines."""


[docs] class TerminatedEchoingStdin(EchoingStdin): """Like :class:`click.testing.EchoingStdin` but adds a visible ``^D`` in place of the EOT character (``\x04``). :meth:`ClickRunner.invoke` adds ``\x04`` when ``terminate_input=True``. """ def _echo(self, rv: bytes) -> bytes: eof = rv[-1] == b"\x04"[0] if eof: rv = rv[:-1] if not self._paused: self._output.write(rv) if eof: self._output.write(b"^D\n") return rv
[docs] @contextlib.contextmanager def patch_subprocess(): """Patch subprocess to work better with :meth:`ClickRunner.invoke`. ``subprocess.call`` output is redirected to ``click.echo`` so it shows up in the example output. .. caution:: The replacement is installed on the ``subprocess`` module itself (not thread-local), so for the duration of the ``with`` block any other code in the same process that calls ``subprocess.call`` sees the patched version. With ``parallel_read_safe = True`` declared on :class:`ClickDomain`, a parallel reader running concurrently on a different document gets the patched ``subprocess.call`` too. The redirection is benign (output goes to ``click.echo``) but the race is real, and the parallel-safe claim is weaker than it looks for documents that themselves shell out via ``subprocess.call``. """ old_call = subprocess.call def dummy_call(*args, **kwargs): with tempfile.TemporaryFile("wb+") as f: kwargs["stdout"] = f kwargs["stderr"] = f rv = subprocess.Popen(*args, **kwargs).wait() f.seek(0) click.echo(f.read().decode("utf-8", "replace").rstrip()) return rv subprocess.call = dummy_call try: yield finally: subprocess.call = old_call
[docs] class ClickRunner(CliRunner): """A sub-class of :class:`click.testing.CliRunner` for Sphinx directive execution. Produces unfiltered ANSI codes so that the ``Directive`` sub-classes below can render colors in the HTML output. """ def __init__(self): super().__init__(echo_stdin=True) self.namespace = {"click": click, "__file__": "dummy.py"}
[docs] @contextlib.contextmanager def isolation(self, *args, **kwargs): iso = super().isolation(*args, **kwargs) with iso as streams: try: buffer = sys.stdin.buffer except AttributeError: buffer = sys.stdin # FIXME: We need to replace EchoingStdin with our custom # class that outputs "^D". At this point we know sys.stdin # has been patched so it's safe to reassign the class. # Remove this once EchoingStdin is overridable. buffer.__class__ = TerminatedEchoingStdin yield streams
[docs] def invoke( # type: ignore[override] self, cli, args=None, prog_name=None, input=None, terminate_input=False, env=None, _output_lines=None, **extra, ) -> click.testing.Result: """Like :meth:`CliRunner.invoke` but displays what the user would enter in the terminal for env vars, command arguments, and prompts. :param terminate_input: Whether to display ``^D`` after a list of input. :param _output_lines: A list used internally to collect lines to be displayed. """ output_lines = _output_lines if _output_lines is not None else [] if env: for key, value in sorted(env.items()): value = shlex.quote(value) output_lines.append(f"$ export {key}={value}") args = args or [] if prog_name is None: prog_name = cli.name.replace("_", "-") output_lines.append(f"$ {prog_name} {shlex.join(args)}".rstrip()) # remove "python" from command prog_name = prog_name.rsplit(" ", 1)[-1] if isinstance(input, (tuple, list)): input = "\n".join(input) + "\n" if terminate_input: input += "\x04" result = super().invoke( cli=cli, args=args, input=input, env=env, prog_name=prog_name, color=True, **extra, ) # TODO: Maybe we can intercept the exception here either make it: # - part of the output in the rendered Sphinx code block, or # - re-raise it so Sphinx can display it properly in its logs. output_lines.extend(result.output.splitlines()) return result
[docs] def execute_source(self, directive: SphinxDirective) -> None: """Execute the given code, adding it to the runner's namespace.""" code = compile_directive(directive) with patch_subprocess(): exec(code, self.namespace) # noqa: S102
[docs] def run_cli(self, directive: SphinxDirective) -> list[str]: """Execute the given ``source_code``. Returns a simulation of terminal execution, including a mix of input, output, prompts and tracebacks. The execution context is augmented, so you can refer directly to these functions in the provided ``source_code``: - :meth:`invoke()`: which is the same as :meth:`ClickRunner.invoke` - :meth:`isolated_filesystem()`: A context manager that changes to a temporary directory while executing the block. If any local variable in the provided ``source_code`` conflicts with these functions, a :class:`RuntimeError` is raised to help you pinpoint the issue. """ # Use directive.content instead of directive.block_text as the latter # include the directive text itself in rST. source_code = "\n".join(directive.content) # Get the user-friendly location string as provided by Sphinx. location = directive.get_location() buffer: list[str] = [] # Functions available as local variables when executing the code. local_vars = { "invoke": partial(self.invoke, _output_lines=buffer), "isolated_filesystem": self.isolated_filesystem, } # Check for local variable conflicts. tree = ast.parse(source_code, location) for node in ast.walk(tree): if ( isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in local_vars ): # Get the source lines for better error reporting. source_lines = source_code.splitlines() # Get the line number relative to the source code. python_lineno = node.lineno python_line = source_lines[python_lineno - 1] # Compute the absolute line number in the document. if directive.is_myst_syntax: # In MyST, the content offset is the position of the first line # of the source code, relative to the directive itself. doc_lineno = ( directive.lineno + directive.content_offset + python_lineno ) # XXX MyST absolute error line reporting is broken in some # situations, see: # https://github.com/executablebooks/MyST-Parser/pull/1048 else: # In rST, the content offset is the absolute position at which # the source code starts in the document. doc_lineno = directive.content_offset + python_lineno raise RuntimeError( f"Local variable {node.id!r} at " f"{location}:{directive.name}:{doc_lineno} conflicts with " f"the one automatically provided by the {directive.name} " "directive.\n" f"Line: {python_line}" ) code = compile_directive(directive) exec(code, self.namespace, local_vars) # noqa: S102 return buffer
[docs] class ClickDirective(SphinxDirective): has_content = True required_arguments = 0 optional_arguments = 1 """The optional argument overrides the default Pygments language to use.""" final_argument_whitespace = False option_spec: ClassVar[OptionSpec] = CodeBlock.option_spec | { "language": directives.unchanged_required, "show-source": directives.flag, "hide-source": directives.flag, "show-results": directives.flag, "hide-results": directives.flag, "emphasize-result-lines": CodeBlock.option_spec["emphasize-lines"], # TODO: Add a show-prompts and hide-prompts options? } """Options supported by this directive. Support the `same options <https://github.com/sphinx-doc/sphinx/blob/cc7c6f4/sphinx/directives/code.py#L108-L117>`_ as :class:`sphinx.directives.code.CodeBlock`, and some specific to Click directives. The standard ``emphasize-lines`` option applies to the source block only. Use ``emphasize-result-lines`` to highlight specific lines in the captured output block, with the same syntax (e.g. ``:emphasize-result-lines: 1,3-5``). """ default_language: str """Default highlighting language to use to render the code block. `All Pygments' languages short names <https://pygments.org/languages/>`_ are recognized. """ show_source_by_default: bool = True """Whether to render the source code of the example in the code block.""" show_results_by_default: bool = True """Whether to render the results of the example in the code block.""" runner_method: str """The name of the method to call on the :class:`ClickRunner` instance.""" runner_attr: ClassVar[str] = "click_runner" """Name of the attribute holding the runner on the doctree. Subclasses (like :class:`~click_extra.sphinx.python.PythonDirective`) override this so the Click and Python runners don't collide on the same document. """ runner_factory: ClassVar[type] = None # type: ignore[assignment] """Class to instantiate for the per-document runner. Defaults to :class:`ClickRunner` in :class:`ClickDirective` (set after the class definition to break the forward reference). """ @property def runner(self): """Get or create the per-document runner. Creates one runner per document, keyed by :attr:`runner_attr`. """ runner = getattr(self.state.document, self.runner_attr, None) if runner is None: runner = self.runner_factory() setattr(self.state.document, self.runner_attr, runner) return runner
[docs] @cached_property def language(self) -> str: """Short name of the Pygments lexer used to highlight the code block. Returns, in order of precedence, the language specified in the `:language:` directive options, the first argument of the directive (if any), or the default set in the directive class. """ if "language" in self.options: return self.options["language"] # type: ignore[no-any-return] if self.arguments: return str(self.arguments[0]) return self.default_language
[docs] def code_block_options(self, target: str = "source") -> list[str]: """Render the options supported by Sphinx' native ``code-block`` directive. ``target`` selects which block these options will be attached to: ``"source"`` for the directive's input source code, ``"results"`` for the captured output. ``emphasize-lines`` routes to the source block; ``emphasize-result-lines`` is rewritten as ``emphasize-lines`` on the results block, so authors can highlight different lines in each. """ options = [] for option_id in CodeBlock.option_spec: if option_id == "emphasize-lines": if target == "source" and "emphasize-lines" in self.options: options.append(f":emphasize-lines: {self.options[option_id]}") elif target == "results" and "emphasize-result-lines" in self.options: options.append( f":emphasize-lines: {self.options['emphasize-result-lines']}" ) continue if option_id in self.options: value = self.options[option_id] line = f":{option_id}:" if value: line += f" {value}" options.append(line) return options
[docs] @cached_property def show_source(self) -> bool: """Whether to show the source code of the example in the code block. The last occurrence of either ``show-source`` or ``hide-source`` options wins. If neither is set, the default is taken from ``show_source_by_default``. """ show_source = self.show_source_by_default for option_id in self.options: if option_id == "show-source": show_source = True elif option_id == "hide-source": show_source = False return show_source
[docs] @cached_property def show_results(self) -> bool: """Whether to show the results of running the example in the code block. The last occurrence of either ``show-results`` or ``hide-results`` options wins. If neither is set, the default is taken from ``show_results_by_default``. """ show_results = self.show_results_by_default for option_id in self.options: if option_id == "show-results": show_results = True elif option_id == "hide-results": show_results = False return show_results
[docs] @cached_property def is_myst_syntax(self) -> bool: """Check if the current directive is written with MyST syntax.""" return bool(self.state.__module__.split(".", 1)[0] == "myst_parser")
[docs] def render_code_block( self, lines: Iterable[str], language: str, target: str = "source", ) -> list[str]: """Render the code block with the source code or results. ``target`` is forwarded to :meth:`code_block_options` so the ``emphasize-lines`` / ``emphasize-result-lines`` split routes the right highlighting to each block. """ block: list[str] = [] if not lines: return block # Initiate the code block with with its MyST or rST syntax. code_directive = "```{code-block}" if self.is_myst_syntax else ".. code-block::" block.append(f"{code_directive} {language}") # Re-attach each option to the code block. # Indent the line in rST code block. block.extend( line if self.is_myst_syntax else RST_INDENT + line for line in self.code_block_options(target) ) # Both rST and MyST need a blank line before the body of the block else the # first line will be interpreted as a directive option or argument. block.append("") block.extend( line if self.is_myst_syntax else RST_INDENT + line for line in lines ) # In MyST, we need to close the code block. if self.is_myst_syntax: block.append("```") return block
[docs] def run(self) -> list[nodes.Node]: assert hasattr(self.runner, self.runner_method), ( f"{self.runner!r} does not have a method named {self.runner_method!r}." ) runner_func = getattr(self.runner, self.runner_method) results = runner_func(self) # If neither source code nor results are requested, we don't render anything. if not self.show_source and not self.show_results: return [] lines = [] if self.show_source: language = self.language # If we are running a CLI, we force rendering the source code as a # Python code block. if self.runner_method == "run_cli": language = SourceDirective.default_language lines.extend(self.render_code_block(self.content, language, "source")) if self.show_results: lines.extend(self.render_code_block(results, self.language, "results")) # Convert code block lines to a Docutils node tree. # The section element is the main unit of hierarchy for Docutils documents. section = nodes.section() source_file, _line_number = self.get_source_info() self.state.nested_parse( StringList(lines, source_file), # XXX Check that the offset here is properly computed in both rST and MyST. self.content_offset, section, ) return section.children
[docs] class SourceDirective(ClickDirective): """Directive to declare a Click CLI source code. This directive is used to declare a Click CLI example in the documentation. It renders the source code of the example in a Python code block. """ default_language = "python" show_source_by_default = True show_results_by_default = False runner_method = "execute_source"
[docs] class RunDirective(ClickDirective): """Directive to run a Click CLI example. This directive is used to run a Click CLI example in the documentation. It renders the results of running the example in a shell session code block supporting ANSI colors. """ default_language = "ansi-shell-session" show_source_by_default = False show_results_by_default = True runner_method = "run_cli"
[docs] class DeprecatedExampleDirective(SourceDirective): """Deprecated alias for SourceDirective. .. deprecated:: 7.3.0 Use ``click:source`` instead of ``click:example``. """
[docs] def run(self) -> list[nodes.Node]: logger.warning( "The 'click:example' directive is deprecated and will be remove in " "Click Extra 8.0.0. Use 'click:source' instead.", type="click", subtype="deprecated", location=self.get_location(), ) return super().run()
ClickDirective.runner_factory = ClickRunner
[docs] class ClickDomain(StatelessDomain): """Setup new directives under the same ``click`` namespace: - ``click:source`` which renders a Click CLI source code - ``click:example``, an alias to ``click:source`` (deprecated) - ``click:run`` which renders the results of running a Click CLI """ name = "click" label = "Click" directives: ClassVar[dict] = { "source": SourceDirective, "example": DeprecatedExampleDirective, "run": RunDirective, }
cleanup_runner = make_cleanup("click_runner") """Drop the :class:`ClickRunner` from the doctree once the document is read."""