diff --git a/sphinx/ext/autosummary/__init__.py b/sphinx/ext/autosummary/__init__.py index c3bd7dd4b0..6bba9bd0ff 100644 --- a/sphinx/ext/autosummary/__init__.py +++ b/sphinx/ext/autosummary/__init__.py @@ -58,6 +58,7 @@ from inspect import Parameter from types import ModuleType from typing import TYPE_CHECKING, cast +import docutils from docutils import nodes from docutils.parsers.rst import directives from docutils.parsers.rst.states import RSTStateMachine, state_classes @@ -398,8 +399,9 @@ class Autosummary(SphinxDirective): table.append(real_table) group = nodes.tgroup('', cols=2) real_table.append(group) - group.append(nodes.colspec('', colwidth=10)) - group.append(nodes.colspec('', colwidth=90)) + for width in (10, 90): + colwidth = str(width) if docutils.__version_info__[:2] >= (1, 0) else width + group.append(nodes.colspec('', colwidth=colwidth)) body = nodes.tbody('') group.append(body) diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py index 9da6c4318d..308c186f31 100644 --- a/sphinx/ext/doctest.py +++ b/sphinx/ext/doctest.py @@ -24,6 +24,7 @@ from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import _is_doctest_block from sphinx.util.osutil import relpath if TYPE_CHECKING: @@ -661,4 +662,4 @@ def _condition_default(node: Node) -> bool: def _condition_with_doctest(node: Node) -> bool: - return _condition_default(node) or isinstance(node, nodes.doctest_block) + return _condition_default(node) or _is_doctest_block(node) diff --git a/sphinx/io.py b/sphinx/io.py index 8ac92f48e2..149083c501 100644 --- a/sphinx/io.py +++ b/sphinx/io.py @@ -5,9 +5,10 @@ from __future__ import annotations import warnings from typing import TYPE_CHECKING +import docutils from docutils.io import FileInput from docutils.readers import standalone -from docutils.transforms.references import DanglingReferences +from docutils.transforms import references from docutils.writers import UnfilteredWriter from sphinx.deprecation import RemovedInSphinx10Warning @@ -52,7 +53,9 @@ class SphinxBaseReader(standalone.Reader): # type: ignore[type-arg] transforms = super().get_transforms() + self.transforms # remove transforms which is not needed for Sphinx - unused = [DanglingReferences] + unused: list[type[Transform]] = [references.DanglingReferences] + if docutils.__version_info__[:2] >= (1, 0): + unused.append(references.ReportUnreferencedTargets) # type: ignore[attr-defined] for transform in unused: if transform in transforms: transforms.remove(transform) diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index eb04025954..c7c4e80572 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -6,6 +6,7 @@ import re import unicodedata from typing import TYPE_CHECKING, cast +import docutils from docutils import nodes from docutils.transforms import Transform, Transformer from docutils.transforms.parts import ContentsFilter @@ -19,7 +20,11 @@ from sphinx.deprecation import _deprecation_warning from sphinx.locale import _, __ from sphinx.util import logging from sphinx.util.i18n import format_date -from sphinx.util.nodes import apply_source_workaround, is_smartquotable +from sphinx.util.nodes import ( + _is_doctest_block, + apply_source_workaround, + is_smartquotable, +) if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -183,7 +188,7 @@ class HandleCodeBlocks(SphinxTransform): def apply(self, **kwargs: Any) -> None: # move doctest blocks out of blockquotes for node in self.document.findall(nodes.block_quote): - if all(isinstance(child, nodes.doctest_block) for child in node.children): + if all(_is_doctest_block(child) for child in node.children): node.replace_self(node.children) # combine successive doctest blocks # for node in self.document.findall(nodes.doctest_block): @@ -278,6 +283,8 @@ class ExtraTranslatableNodes(SphinxTransform): return def is_translatable_node(node: Node) -> TypeIs[nodes.Element]: + if _is_doctest_block(node): + return 'doctest-block' in targets return isinstance(node, target_nodes) for node in self.document.findall(is_translatable_node): @@ -325,13 +332,18 @@ class UnreferencedFootnotesDetector(SphinxTransform): class DoctestTransform(SphinxTransform): - """Set "doctest" style to each doctest_block node""" + """Set the style and highlighting language of doctest blocks.""" default_priority = 500 def apply(self, **kwargs: Any) -> None: for node in self.document.findall(nodes.doctest_block): node['classes'].append('doctest') + if docutils.__version_info__[:2] >= (1, 0): + # Docutils 1.0 represents doctests as literal blocks. + for literal in self.document.findall(nodes.literal_block): + if _is_doctest_block(literal): + literal.setdefault('language', 'pycon') class FilterSystemMessages(SphinxTransform): diff --git a/sphinx/transforms/references.py b/sphinx/transforms/references.py index 8655dbfa5f..c8c2915fba 100644 --- a/sphinx/transforms/references.py +++ b/sphinx/transforms/references.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +import docutils from docutils.transforms.references import DanglingReferences from sphinx.transforms import SphinxTransform @@ -42,7 +43,10 @@ class SphinxDomains(SphinxTransform): def setup(app: Sphinx) -> ExtensionMetadata: - app.add_transform(SphinxDanglingReferences) + # Docutils 1.0 splits reference matching and reporting into separate + # transforms, which are already provided by the standalone reader. + if docutils.__version_info__[:2] < (1, 0): + app.add_transform(SphinxDanglingReferences) app.add_transform(SphinxDomains) return { diff --git a/sphinx/util/docutils.py b/sphinx/util/docutils.py index 74a53ef605..a992e925e0 100644 --- a/sphinx/util/docutils.py +++ b/sphinx/util/docutils.py @@ -18,7 +18,7 @@ from docutils.io import FileOutput from docutils.parsers.rst import Directive, directives, roles from docutils.readers import standalone from docutils.statemachine import StateMachine -from docutils.transforms.references import DanglingReferences +from docutils.transforms import references from docutils.utils import Reporter, unescape from sphinx.errors import SphinxError @@ -77,11 +77,18 @@ if TYPE_CHECKING: ) -> tuple[RoleFunction | None, list[system_message]]: ... -_READER_TRANSFORMS = [ - transform - for transform in standalone.Reader().get_transforms() - if transform is not DanglingReferences -] +if docutils.__version_info__[:2] >= (1, 0): + _READER_TRANSFORMS = [ + transform + for transform in standalone.Reader().get_transforms() + if transform is not references.ReportUnreferencedTargets # type: ignore[attr-defined] + ] +else: + _READER_TRANSFORMS = [ + transform + for transform in standalone.Reader().get_transforms() + if transform is not references.DanglingReferences + ] additional_nodes: set[type[Element]] = set() diff --git a/sphinx/util/nodes.py b/sphinx/util/nodes.py index 90c44c6a9c..3a1805a2ea 100644 --- a/sphinx/util/nodes.py +++ b/sphinx/util/nodes.py @@ -8,6 +8,7 @@ import unicodedata from io import StringIO from typing import TYPE_CHECKING, Any, cast +import docutils from docutils import nodes from docutils.nodes import Node @@ -287,6 +288,15 @@ IMAGE_TYPE_NODES = ( ) # fmt: skip +def _is_doctest_block(node: Node) -> bool: + if docutils.__version_info__[:2] >= (1, 0) and isinstance( + node, nodes.literal_block + ): + return 'doctest' in node['classes'] + # Also accept doctest_block nodes from third-party parsers on Docutils 1.0. + return isinstance(node, nodes.doctest_block) + + def _clean_extracted_message(text: str) -> str: """Remove trailing backslashes from each line of *text*.""" if '\\' in text: diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 5ab57784c7..3c6060eaf1 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -7,6 +7,7 @@ import re import urllib.parse from typing import TYPE_CHECKING +import docutils from docutils import nodes from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator @@ -672,6 +673,15 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): def depart_block_quote(self, node: Element) -> None: self.body.append('\n') + if docutils.__version_info__[:2] >= (1, 0): + + def visit_attribution(self, node: Element) -> None: + # Keep attributions inside our blockquote wrapper. Docutils 1.0's + # visitor closes the blockquote before emitting the attribution. + prefix, suffix = self.attribution_formats[self.settings.attribution] + self.context.append(suffix) + self.body.append(self.starttag(node, 'p', prefix, CLASS='attribution')) + # overwritten def visit_literal(self, node: Element) -> None: if 'kbd' in node['classes']: diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index b6dc333cb3..141eb8eae7 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -11,6 +11,7 @@ from collections import defaultdict from pathlib import Path from typing import TYPE_CHECKING, cast +import docutils from docutils import nodes, writers from roman_numerals import RomanNumeral @@ -1249,7 +1250,12 @@ class LaTeXTranslator(SphinxTranslator): assert self.table is not None self.table.colcount += 1 if 'colwidth' in node: - self.table.colwidths.append(node['colwidth']) + if docutils.__version_info__[:2] >= (1, 0): + # breaking change in Docutils 1.0 + # https://sourceforge.net/p/docutils/code/10397 + self.table.colwidths.append(int(node['colwidth'])) + else: + self.table.colwidths.append(node['colwidth']) if 'stub' in node: self.table.stubs.append(self.table.colcount - 1) diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py index a40d65ebc7..e984defa15 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -7,6 +7,7 @@ import re import textwrap from typing import TYPE_CHECKING, cast +import docutils from docutils import nodes, writers from sphinx import __display_version__, addnodes @@ -1041,7 +1042,12 @@ class TexinfoTranslator(SphinxTranslator): pass def visit_colspec(self, node: Element) -> None: - self.colwidths.append(node['colwidth']) + if docutils.__version_info__[:2] >= (1, 0): + # breaking change in Docutils 1.0 + # https://sourceforge.net/p/docutils/code/10397 + self.colwidths.append(int(node['colwidth'])) + else: + self.colwidths.append(node['colwidth']) if len(self.colwidths) != self.n_cols: return self.body.append('\n\n@multitable ') diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index a542ab45c0..f6b3b7ee8f 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -9,6 +9,7 @@ import textwrap from itertools import chain, groupby, pairwise from typing import TYPE_CHECKING, cast +import docutils from docutils import nodes, writers from docutils.utils import column_width @@ -904,7 +905,12 @@ class TextTranslator(SphinxTranslator): raise nodes.SkipNode def visit_colspec(self, node: Element) -> None: - self.table.colwidth.append(node['colwidth']) + if docutils.__version_info__[:2] >= (1, 0): + # breaking change in Docutils 1.0 + # https://sourceforge.net/p/docutils/code/10397 + self.table.colwidth.append(int(node['colwidth'])) + else: + self.table.colwidth.append(node['colwidth']) raise nodes.SkipNode def visit_tgroup(self, node: Element) -> None: diff --git a/tests/test_builders/test_build_html_5_output.py b/tests/test_builders/test_build_html_5_output.py index b712d1415a..9ab869153e 100644 --- a/tests/test_builders/test_build_html_5_output.py +++ b/tests/test_builders/test_build_html_5_output.py @@ -5,11 +5,17 @@ from __future__ import annotations import re from typing import TYPE_CHECKING +import docutils import pytest from docutils import nodes from tests.test_builders.xpath_util import check_xpath +if docutils.__version_info__[:2] >= (1, 0): + _RFC_1_URL = 'https://datatracker.ietf.org/doc/html/rfc1/' +else: + _RFC_1_URL = 'https://datatracker.ietf.org/doc/html/rfc1.html' + if TYPE_CHECKING: from collections.abc import Callable, Iterable from pathlib import Path @@ -147,14 +153,12 @@ def tail_check(check: str) -> Callable[[Iterable[Element]], Literal[True]]: ), ( 'markup.html', - ".//a[@href='https://datatracker.ietf.org/doc/html/rfc1.html']" - "[@class='rfc reference external']/strong", + f".//a[@href='{_RFC_1_URL}'][@class='rfc reference external']/strong", 'RFC 1', ), ( 'markup.html', - ".//a[@href='https://datatracker.ietf.org/doc/html/rfc1.html']" - "[@class='rfc reference external']/strong", + f".//a[@href='{_RFC_1_URL}'][@class='rfc reference external']/strong", 'Request for Comments #1', ), ( diff --git a/tests/test_markup/test_markup.py b/tests/test_markup/test_markup.py index 4889ed6daa..cff335ba7e 100644 --- a/tests/test_markup/test_markup.py +++ b/tests/test_markup/test_markup.py @@ -5,6 +5,7 @@ from __future__ import annotations import re from types import SimpleNamespace +import docutils import pytest from docutils import nodes, utils from docutils.parsers.rst import Parser as RstParser @@ -23,6 +24,11 @@ from sphinx.writers.latex import LaTeXTranslator, LaTeXWriter from tests.utils import extract_node +if docutils.__version_info__[:2] >= (1, 0): + _RFC_2324_URL = 'https://datatracker.ietf.org/doc/html/rfc2324/' +else: + _RFC_2324_URL = 'https://datatracker.ietf.org/doc/html/rfc2324.html' + TYPE_CHECKING = False if TYPE_CHECKING: from sphinx.environment import BuildEnvironment @@ -212,12 +218,12 @@ def rst_to_latex(rst: str, *, app: SphinxTestApp) -> str: ':rfc:`2324`', ( '

RFC 2324

' + f'href="{_RFC_2324_URL}">RFC 2324

' ), ( '\\sphinxAtStartPar\n' '\\index{RFC@\\spxentry{RFC}!RFC 2324@\\spxentry{RFC 2324}}' - '\\sphinxhref{https://datatracker.ietf.org/doc/html/rfc2324.html}' + rf'\sphinxhref{{{_RFC_2324_URL}}}' '{\\sphinxstylestrong{RFC 2324}}' ), ), @@ -226,13 +232,13 @@ def rst_to_latex(rst: str, *, app: SphinxTestApp) -> str: ':rfc:`2324#section-1`', ( '

' + f'href="{_RFC_2324_URL}#section-1">' 'RFC 2324 Section 1

' ), ( '\\sphinxAtStartPar\n' '\\index{RFC@\\spxentry{RFC}!RFC 2324 Section 1@\\spxentry{RFC 2324 Section 1}}' - '\\sphinxhref{https://datatracker.ietf.org/doc/html/rfc2324.html\\#section-1}' + rf'\sphinxhref{{{_RFC_2324_URL}\#section-1}}' '{\\sphinxstylestrong{RFC 2324 Section 1}}' ), ), @@ -462,6 +468,16 @@ def test_inline( assert latex_expected == latex_translated, f'from {rst!r}' +@pytest.mark.sphinx('html', testroot='_blank') +def test_block_quote_attribution(app: SphinxTestApp) -> None: + html = rst_to_html(' A quotation.\n\n -- The author\n', app=app) + assert html == ( + '
\n

A quotation.

\n' + '

—The author

\n' + '
' + ) + + @pytest.mark.parametrize( ('rst', 'html_expected', 'latex_expected'), [ diff --git a/tests/test_transforms/test_transforms_doctest.py b/tests/test_transforms/test_transforms_doctest.py new file mode 100644 index 0000000000..792096a638 --- /dev/null +++ b/tests/test_transforms/test_transforms_doctest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import docutils +import pytest +from docutils import nodes + +from sphinx.testing import restructuredtext + +if TYPE_CHECKING: + from sphinx.testing.util import SphinxTestApp + + +@pytest.mark.sphinx('dummy', testroot='_blank') +def test_indented_doctest(app: SphinxTestApp) -> None: + doctree = restructuredtext.parse(app, ' >>> 1 + 1\n 2\n') + + assert len(doctree) == 1 + node = doctree[0] + if docutils.__version_info__[:2] >= (1, 0): + assert isinstance(node, nodes.literal_block) + assert node['language'] == 'pycon' + else: + assert isinstance(node, nodes.doctest_block) + assert node.astext() == '>>> 1 + 1\n2' + assert 'doctest' in node['classes'] + + +@pytest.mark.parametrize( + ('target', 'translate_doctest', 'translate_literal'), + [('doctest-block', True, False), ('literal-block', False, True)], +) +@pytest.mark.sphinx('dummy', testroot='_blank') +def test_doctest_translation( + app: SphinxTestApp, + target: str, + translate_doctest: bool, + translate_literal: bool, +) -> None: + app.config.gettext_additional_targets = [target] + doctree = restructuredtext.parse( + app, '>>> 1 + 1\n2\n\n::\n\n an ordinary literal block\n' + ) + + doctest, literal = doctree.children + assert isinstance(doctest, nodes.Element) + assert doctest.get('translatable', False) is translate_doctest + assert isinstance(literal, nodes.Element) + assert literal.get('translatable', False) is translate_literal