diff --git a/AUTHORS.rst b/AUTHORS.rst index fd129bb8f72..471049e32ae 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -119,6 +119,7 @@ Contributors * Till Hoffmann -- doctest option to exit after first failed test * Tim Hoffmann -- theme improvements * Valentin Heinisch -- warning types improvement +* Valerian Rey -- doctest options coming from code-block * Victor Wheeler -- documentation improvements * Vince Salvino -- JavaScript search improvements * Will Maier -- directory HTML builder diff --git a/doc/usage/extensions/doctest.rst b/doc/usage/extensions/doctest.rst index a418f1bf744..b7abb1203cf 100644 --- a/doc/usage/extensions/doctest.rst +++ b/doc/usage/extensions/doctest.rst @@ -40,6 +40,9 @@ There are two kinds of test blocks: * *code-output-style* blocks consist of an ordinary piece of Python code, and optionally, a piece of output for that code. +The following directives that build visible code blocks all also have the +options from :rst:dir:`code-block` (e.g. ``emphasize-lines``, ``caption``, +etc.). Directives ---------- diff --git a/sphinx/directives/code.py b/sphinx/directives/code.py index e8f9d6ddf8c..a3d6f5f383c 100644 --- a/sphinx/directives/code.py +++ b/sphinx/directives/code.py @@ -3,7 +3,7 @@ import sys import textwrap from difflib import unified_diff -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from docutils import nodes from docutils.parsers.rst import directives @@ -96,15 +96,14 @@ def container_wrapper( raise RuntimeError # never reached -class CodeBlock(SphinxDirective): +class BaseCodeBlock(SphinxDirective): """Directive for a code block with special highlighting or line numbering settings. """ has_content = True required_arguments = 0 - optional_arguments = 1 - final_argument_whitespace = False + optional_arguments = 0 option_spec: ClassVar[OptionSpec] = { 'force': directives.flag, 'linenos': directives.flag, @@ -153,17 +152,6 @@ def run(self) -> list[Node]: literal['linenos'] = True literal['classes'] += self.options.get('class', []) literal['force'] = 'force' in self.options - if self.arguments: - # highlight language specified - literal['language'] = self.arguments[0] - else: - # no highlight language specified. Then this directive refers the current - # highlight setting via ``highlight`` directive or ``highlight_language`` - # configuration. - literal['language'] = ( - self.env.current_document.highlight_language - or self.config.highlight_language - ) extra_args = literal['highlight_args'] = {} if hl_lines is not None: extra_args['hl_lines'] = hl_lines @@ -185,6 +173,34 @@ def run(self) -> list[Node]: return [literal] +class CodeBlock(BaseCodeBlock): + """BaseCodeBlock with an optional language argument for syntax highlighting""" + + optional_arguments = 1 + final_argument_whitespace = False + + def run(self) -> list[Node]: + result = super().run()[0] + # If caption wrapping occurred, the literal_block is inside the container + if isinstance(result, nodes.container): + # result[0] is caption, result[1] is literal_block + literal = cast('Element', result[1]) + else: + literal = cast('Element', result) + if self.arguments: + # highlight language specified + literal['language'] = self.arguments[0] + else: + # no highlight language specified. Then this directive refers the current + # highlight setting via ``highlight`` directive or ``highlight_language`` + # configuration. + literal['language'] = ( + self.env.current_document.highlight_language + or self.config.highlight_language + ) + return [result] + + class LiteralIncludeReader: INVALID_OPTIONS_PAIR = [ ('lineno-match', 'lineno-start'), diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py index 9da6c4318d4..a9d415f4415 100644 --- a/sphinx/ext/doctest.py +++ b/sphinx/ext/doctest.py @@ -11,7 +11,7 @@ import sys import time from io import StringIO -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from docutils import nodes from docutils.parsers.rst import directives @@ -21,6 +21,7 @@ import sphinx from sphinx._cli.util.colour import bold from sphinx.builders import Builder +from sphinx.directives.code import BaseCodeBlock from sphinx.locale import __ from sphinx.util import logging from sphinx.util.docutils import SphinxDirective @@ -30,7 +31,7 @@ from collections.abc import Callable, Set from typing import Any, ClassVar - from docutils.nodes import Element, Node, TextElement + from docutils.nodes import Element, Node from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata, OptionSpec @@ -63,7 +64,7 @@ def is_allowed_version(spec: str, version: str) -> bool: # set up the necessary directives -class TestDirective(SphinxDirective): +class TestDirective(BaseCodeBlock, SphinxDirective): """Base class for doctest-related directives.""" has_content = True @@ -74,32 +75,55 @@ class TestDirective(SphinxDirective): def run(self) -> list[Node]: # use ordinary docutils nodes for test code: they get special attributes # so that our builder recognizes them, and the other builders are happy. - code = '\n'.join(self.content) - test = None - if self.name == 'doctest': - if '' in code: - # convert s to ordinary blank lines for presentation - test = code - code = blankline_re.sub('', code) - if ( - doctestopt_re.search(code) - and 'no-trim-doctest-flags' not in self.options - ): - if not test: - test = code - code = doctestopt_re.sub('', code) - nodetype: type[TextElement] = nodes.literal_block - if self.name in {'testsetup', 'testcleanup'} or 'hide' in self.options: - nodetype = nodes.comment + test = '\n'.join(self.content) # This is the code that doctest will run + if self.arguments: groups = [x.strip() for x in self.arguments[0].split(',')] else: groups = ['default'] - node = nodetype(code, code, testnodetype=self.name, groups=groups) + + node: Element + if self.name in {'testsetup', 'testcleanup'} or 'hide' in self.options: + # Invisible block: content can be the unformatted test + node = nodes.comment(test, test) + else: + # Visible block: content has to be formatted according to the BaseCodeBlock + # options. This uses the node built by BaseCodeBlock.run and adapts it. + node = cast('Element', super().run()[0]) + + if self.name == 'doctest': + # Step 1: get the actual code from the built node. The structure of the node + # differs if there's a caption. + + # If caption wrapping occurred, the literal_block is inside the container + if isinstance(node, nodes.container): + literal = node[1] # node[0] is caption, node[1] is literal_block + else: + literal = node + assert isinstance(literal, nodes.literal_block) + rawsource = literal.rawsource + + # Step 2: modify the source code to remove tags and optionally + # remove doctest flags. + if '' in rawsource: + # convert s to ordinary blank lines for presentation + rawsource = blankline_re.sub('', rawsource) + if ( + doctestopt_re.search(rawsource) + and 'no-trim-doctest-flags' not in self.options + ): + rawsource = doctestopt_re.sub('', rawsource) + + # Step 3: update the rawsource and text content of the literal_block. + # rawsource is metadata; the displayed text comes from the Text child node, + # which is immutable (Text subclasses str) and must be replaced. + literal.rawsource = rawsource + literal[:] = [nodes.Text(rawsource)] + + node['testnodetype'] = self.name + node['groups'] = groups self.set_source_info(node) - if test is not None: - # only save if it differs from code - node['test'] = test + node['test'] = test if self.name == 'doctest': node['language'] = 'pycon' elif self.name == 'testcode': @@ -160,7 +184,8 @@ class TestcleanupDirective(TestDirective): class DoctestDirective(TestDirective): - option_spec: ClassVar[OptionSpec] = { + option_spec: ClassVar[OptionSpec] = TestDirective.option_spec.copy() + option_spec |= { 'hide': directives.flag, 'no-trim-doctest-flags': directives.flag, 'options': directives.unchanged, @@ -171,7 +196,8 @@ class DoctestDirective(TestDirective): class TestcodeDirective(TestDirective): - option_spec: ClassVar[OptionSpec] = { + option_spec: ClassVar[OptionSpec] = TestDirective.option_spec.copy() + option_spec |= { 'hide': directives.flag, 'no-trim-doctest-flags': directives.flag, 'pyversion': directives.unchanged_required, @@ -181,7 +207,8 @@ class TestcodeDirective(TestDirective): class TestoutputDirective(TestDirective): - option_spec: ClassVar[OptionSpec] = { + option_spec: ClassVar[OptionSpec] = TestDirective.option_spec.copy() + option_spec |= { 'hide': directives.flag, 'no-trim-doctest-flags': directives.flag, 'options': directives.unchanged, @@ -654,8 +681,10 @@ def setup(app: Sphinx) -> ExtensionMetadata: def _condition_default(node: Node) -> bool: + # literal_block is for visible block of code, comment if for invisible block of code, and + # container is for visible block of code with a caption. return ( - isinstance(node, (nodes.literal_block, nodes.comment)) + isinstance(node, (nodes.literal_block, nodes.comment, nodes.container)) and 'testnodetype' in node ) diff --git a/tests/test_extensions/test_ext_doctest.py b/tests/test_extensions/test_ext_doctest.py index c8a12c24de8..ce9a5f32274 100644 --- a/tests/test_extensions/test_ext_doctest.py +++ b/tests/test_extensions/test_ext_doctest.py @@ -11,9 +11,20 @@ from packaging.specifiers import InvalidSpecifier from packaging.version import InvalidVersion -from sphinx.ext.doctest import DocTestBuilder, is_allowed_version +from sphinx.directives.code import BaseCodeBlock, CodeBlock +from sphinx.ext.doctest import ( + DocTestBuilder, + DoctestDirective, + TestcodeDirective, + TestoutputDirective, + _condition_default, + is_allowed_version, +) if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from sphinx.testing.util import SphinxTestApp cleanup_called = 0 @@ -201,3 +212,234 @@ def test_doctest_block_group_name( assert f'File "dir/bar.py", line ?, in {group_name}' in failures assert f'File "foo.py", line ?, in {group_name}' in failures assert f'File "index.rst", line 4, in {group_name}' in failures + + +# Tests for the new BaseCodeBlock-derived TestDirective features + + +def test_condition_default_literal_block() -> None: + node = nodes.literal_block('code', 'code') + node['testnodetype'] = 'doctest' + assert _condition_default(node) is True + + +def test_condition_default_comment() -> None: + node = nodes.comment('code', 'code') + node['testnodetype'] = 'testsetup' + assert _condition_default(node) is True + + +def test_condition_default_container_with_testnodetype() -> None: + """Container nodes (produced when :caption: is used) must also match.""" + container = nodes.container() + container['testnodetype'] = 'doctest' + assert _condition_default(container) is True + + +def test_condition_default_container_without_testnodetype() -> None: + """Plain container nodes (no testnodetype) must not match.""" + container = nodes.container() + assert _condition_default(container) is False + + +def test_condition_default_plain_literal_block() -> None: + """A literal_block without testnodetype must not match.""" + node = nodes.literal_block('code', 'code') + assert _condition_default(node) is False + + +def test_test_directive_option_spec_includes_basecodeblock_options() -> None: + """Doctest directives should inherit BaseCodeBlock options.""" + for directive_cls in (DoctestDirective, TestcodeDirective, TestoutputDirective): + for option in ('linenos', 'caption', 'emphasize-lines', 'dedent', 'force'): + assert option in directive_cls.option_spec, ( + f'{directive_cls.__name__} missing option {option!r}' + ) + + +def test_basecodeblock_has_no_language_argument() -> None: + assert BaseCodeBlock.optional_arguments == 0 + + +def test_codeblock_has_language_argument() -> None: + assert CodeBlock.optional_arguments == 1 + + +def _make_doctest_app( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], + rst_content: str, +) -> SphinxTestApp: + """Create a minimal Sphinx app with doctest extension from RST content.""" + (tmp_path / 'conf.py').write_text( + "extensions = ['sphinx.ext.doctest']\n", encoding='utf-8' + ) + (tmp_path / 'index.rst').write_text(rst_content, encoding='utf-8') + app = make_app(buildername='dummy', srcdir=tmp_path) + app.build(force_all=True) + return app + + +def test_doctest_directive_with_linenos( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """The :linenos: option from BaseCodeBlock should be accepted by doctest directives.""" + rst = """\ +Test +==== + +.. doctest:: + :linenos: + + >>> 1 + 1 + 2 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + literal_blocks = list(doctree.findall(nodes.literal_block)) + assert literal_blocks, 'expected at least one literal_block' + assert literal_blocks[0].get('linenos') is True + + +def test_doctest_directive_with_caption_produces_container( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """:caption: on a doctest directive wraps the literal_block in a container.""" + rst = """\ +Test +==== + +.. doctest:: + :caption: My caption + + >>> 1 + 1 + 2 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + containers = list(doctree.findall(nodes.container)) + assert containers, 'expected a container node when :caption: is used' + container = containers[0] + assert 'testnodetype' in container + assert container['testnodetype'] == 'doctest' + # The literal_block is the second child (first is the caption) + assert isinstance(container[1], nodes.literal_block) + + +def test_testcode_directive_with_caption_produces_container( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """:caption: on a testcode directive also wraps in a container.""" + rst = """\ +Test +==== + +.. testcode:: + :caption: My testcode caption + + print(1 + 1) + +.. testoutput:: + + 2 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + containers = list(doctree.findall(nodes.container)) + assert containers, 'expected a container node when :caption: is used' + container = containers[0] + assert container['testnodetype'] == 'testcode' + + +def test_doctest_node_test_attribute_always_set( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """node['test'] must always be set, even without BLANKLINE or doctest flags.""" + rst = """\ +Test +==== + +.. doctest:: + + >>> x = 1 + >>> x + 1 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + literal_blocks = list(doctree.findall(nodes.literal_block)) + assert literal_blocks + node = literal_blocks[0] + assert 'test' in node + assert node['test'] == '>>> x = 1\n>>> x\n1' + + +def test_doctest_blankline_trimmed_in_display_but_kept_in_test( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """ is removed from the displayed literal but kept in node['test'].""" + rst = """\ +Test +==== + +.. doctest:: + + >>> print() + +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + literal_blocks = list(doctree.findall(nodes.literal_block)) + assert literal_blocks + node = literal_blocks[0] + # The raw test code retains + assert '' in node['test'] + # The displayed text has it removed + assert '' not in node.astext() + + +def test_doctest_flags_trimmed_in_display_but_kept_in_test( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """Doctest flags are removed from the displayed literal but kept in node['test'].""" + rst = """\ +Test +==== + +.. doctest:: + + >>> 1 + 1 # doctest: +ELLIPSIS + 2 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + literal_blocks = list(doctree.findall(nodes.literal_block)) + assert literal_blocks + node = literal_blocks[0] + # The raw test code retains the doctest flag + assert '# doctest: +ELLIPSIS' in node['test'] + # The displayed text has it trimmed + assert '# doctest:' not in node.astext() + + +def test_doctest_flags_kept_with_no_trim_option( + tmp_path: Path, make_app: Callable[..., SphinxTestApp] +) -> None: + """:no-trim-doctest-flags: preserves flags in both test and displayed text.""" + rst = """\ +Test +==== + +.. doctest:: + :no-trim-doctest-flags: + + >>> 1 + 1 # doctest: +ELLIPSIS + 2 +""" + app = _make_doctest_app(tmp_path, make_app, rst) + doctree = app.env.get_doctree('index') + literal_blocks = list(doctree.findall(nodes.literal_block)) + assert literal_blocks + node = literal_blocks[0] + assert '# doctest: +ELLIPSIS' in node['test'] + assert '# doctest: +ELLIPSIS' in node.astext()