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
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions doc/usage/extensions/doctest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
46 changes: 31 additions & 15 deletions sphinx/directives/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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'),
Expand Down
85 changes: 57 additions & 28 deletions sphinx/ext/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 '<BLANKLINE>' in code:
# convert <BLANKLINE>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 <BLANKLINE> tags and optionally
# remove doctest flags.
if '<BLANKLINE>' in rawsource:
# convert <BLANKLINE>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':
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)

Expand Down
Loading
Loading