From 3cebbd91562d1691a84a6ef97f53383c1928072d Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:16:26 +0100 Subject: [PATCH 01/13] Fix linting job (#12786) --- .github/workflows/lint.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fd59f7ff119..e20833c7a9f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,12 +24,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Get Ruff version from pyproject.toml + run: | + RUFF_VERSION=$(awk -F'[="]' '/\[project\.optional-dependencies\]/ {p=1} /ruff/ {if (p) print $4}' pyproject.toml) + echo "RUFF_VERSION=$RUFF_VERSION" >> $GITHUB_ENV + - name: Install Ruff run: > - RUFF_VERSION=$(awk -F'[="]' '/\[project\.optional-dependencies\]/ {p=1} /ruff/ {if (p) print $4}' pyproject.toml) curl --no-progress-meter --location --fail --proto '=https' --tlsv1.2 - "https://astral.sh/ruff/${RUFF_VERSION}/install.sh" + --write-out "%{stderr}Downloaded: %{url}\n" + "https://astral.sh/ruff/$RUFF_VERSION/install.sh" | sh - name: Lint with Ruff From 76ead8737d5cfd3b0f29a3e9a8093e6a28feb276 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:42:25 +0100 Subject: [PATCH 02/13] Bump Ruff to 0.6.0 --- pyproject.toml | 2 +- tests/test_builders/test_build_epub.py | 4 ++-- tests/test_domains/test_domain_c.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff26246c2a9..2f5840d1a9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ docs = [ ] lint = [ "flake8>=6.0", - "ruff==0.5.7", + "ruff==0.6.0", "mypy==1.11.1", "sphinx-lint>=0.9", "types-colorama==0.4.15.20240311", diff --git a/tests/test_builders/test_build_epub.py b/tests/test_builders/test_build_epub.py index 9dae98918e9..2dbdacf852b 100644 --- a/tests/test_builders/test_build_epub.py +++ b/tests/test_builders/test_build_epub.py @@ -2,9 +2,9 @@ import os import subprocess +import xml.etree.ElementTree as ET from pathlib import Path from subprocess import CalledProcessError -from xml.etree import ElementTree import pytest @@ -37,7 +37,7 @@ def __init__(self, tree): @classmethod def fromstring(cls, string): - tree = ElementTree.fromstring(string) # NoQA: S314 # using known data in tests + tree = ET.fromstring(string) # NoQA: S314 # using known data in tests return cls(tree) def find(self, match): diff --git a/tests/test_domains/test_domain_c.py b/tests/test_domains/test_domain_c.py index 8db1c7653b6..77fe612eced 100644 --- a/tests/test_domains/test_domain_c.py +++ b/tests/test_domains/test_domain_c.py @@ -1,9 +1,9 @@ """Tests the C Domain""" import itertools +import xml.etree.ElementTree as ET import zlib from io import StringIO -from xml.etree import ElementTree import pytest @@ -717,7 +717,7 @@ def extract_role_links(app, filename): lis = [l for l in t.split('\n') if l.startswith(' Date: Thu, 15 Aug 2024 21:49:50 +0100 Subject: [PATCH 03/13] Add per-event overloads to ``Sphinx.connect()`` (#12784) Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com> --- doc/conf.py | 6 + doc/extdev/event_callbacks.rst | 8 +- sphinx/application.py | 347 ++++++++++++++++++++++++++++++++- sphinx/config.py | 6 +- 4 files changed, 350 insertions(+), 17 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index fc86fcd6d98..aad73e59bca 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -186,12 +186,15 @@ ('js:func', 'number'), ('js:func', 'string'), ('py:attr', 'srcline'), + ('py:class', '_ConfigRebuild'), # sphinx.application.Sphinx.add_config_value ('py:class', '_StrPath'), # sphinx.environment.BuildEnvironment.doc2path ('py:class', 'Element'), # sphinx.domains.Domain ('py:class', 'Documenter'), # sphinx.application.Sphinx.add_autodocumenter ('py:class', 'IndexEntry'), # sphinx.domains.IndexEntry + ('py:class', 'Lexer'), # sphinx.application.Sphinx.add_lexer ('py:class', 'Node'), # sphinx.domains.Domain ('py:class', 'NullTranslations'), # gettext.NullTranslations + ('py:class', 'Path'), # sphinx.application.Sphinx.connect ('py:class', 'RoleFunction'), # sphinx.domains.Domain ('py:class', 'RSTState'), # sphinx.utils.parsing.nested_parse_to_nodes ('py:class', 'Theme'), # sphinx.application.TemplateBridge @@ -199,6 +202,8 @@ ('py:class', 'StringList'), # sphinx.utils.parsing.nested_parse_to_nodes ('py:class', 'system_message'), # sphinx.utils.docutils.SphinxDirective ('py:class', 'TitleGetter'), # sphinx.domains.Domain + ('py:class', 'todo_node'), # sphinx.application.Sphinx.connect + ('py:class', 'Transform'), # sphinx.application.Sphinx.add_transform ('py:class', 'XRefRole'), # sphinx.domains.Domain ('py:class', 'docutils.nodes.Element'), ('py:class', 'docutils.nodes.Node'), @@ -210,6 +215,7 @@ ('py:class', 'docutils.parsers.rst.states.Inliner'), ('py:class', 'docutils.transforms.Transform'), ('py:class', 'nodes.NodeVisitor'), + ('py:class', 'nodes.TextElement'), # sphinx.application.Sphinx.connect ('py:class', 'nodes.document'), ('py:class', 'nodes.reference'), ('py:class', 'pygments.lexer.Lexer'), diff --git a/doc/extdev/event_callbacks.rst b/doc/extdev/event_callbacks.rst index 1edade48faa..04eae51be1d 100644 --- a/doc/extdev/event_callbacks.rst +++ b/doc/extdev/event_callbacks.rst @@ -107,10 +107,10 @@ Here is a more detailed list of these events. :param app: :class:`.Sphinx` :param env: :class:`.BuildEnvironment` - :param added: ``set[str]`` - :param changed: ``set[str]`` - :param removed: ``set[str]`` - :returns: ``list[str]`` of additional docnames to re-read + :param added: ``Set[str]`` + :param changed: ``Set[str]`` + :param removed: ``Set[str]`` + :returns: ``Sequence[str]`` of additional docnames to re-read Emitted when the environment determines which source files have changed and should be re-read. diff --git a/sphinx/application.py b/sphinx/application.py index 142e1929400..ea1f79ba74e 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -10,15 +10,11 @@ import pickle import sys from collections import deque -from collections.abc import Callable, Collection, Sequence # NoQA: TCH003 from io import StringIO from os import path -from typing import IO, TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, overload -from docutils.nodes import TextElement # NoQA: TCH002 from docutils.parsers.rst import Directive, roles -from docutils.transforms import Transform # NoQA: TCH002 -from pygments.lexer import Lexer # NoQA: TCH002 import sphinx from sphinx import locale, package_dir @@ -41,16 +37,22 @@ from sphinx.util.tags import Tags if TYPE_CHECKING: - from typing import Final + from collections.abc import Callable, Collection, Iterable, Sequence, Set + from pathlib import Path + from typing import IO, Any, Final, Literal from docutils import nodes from docutils.nodes import Element, Node from docutils.parsers import Parser + from docutils.transforms import Transform + from pygments.lexer import Lexer + from sphinx import addnodes from sphinx.builders import Builder from sphinx.domains import Domain, Index from sphinx.environment.collectors import EnvironmentCollector from sphinx.ext.autodoc import Documenter + from sphinx.ext.todo import todo_node from sphinx.extension import Extension from sphinx.roles import XRefRole from sphinx.search import SearchLanguage @@ -456,6 +458,329 @@ def require_sphinx(version: tuple[int, int] | str) -> None: req = f'{major}.{minor}' raise VersionRequirementError(req) + # ---- Core events ------------------------------------------------------- + + @overload + def connect( + self, + event: Literal['config-inited'], + callback: Callable[[Sphinx, Config], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['builder-inited'], + callback: Callable[[Sphinx], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-get-outdated'], + callback: Callable[ + [Sphinx, BuildEnvironment, Set[str], Set[str], Set[str]], Sequence[str] + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-before-read-docs'], + callback: Callable[[Sphinx, BuildEnvironment, list[str]], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-purge-doc'], + callback: Callable[[Sphinx, BuildEnvironment, str], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['source-read'], + callback: Callable[[Sphinx, str, list[str]], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['include-read'], + callback: Callable[[Sphinx, Path, str, list[str]], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['doctree-read'], + callback: Callable[[Sphinx, nodes.document], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-merge-info'], + callback: Callable[ + [Sphinx, BuildEnvironment, list[str], BuildEnvironment], None + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-updated'], + callback: Callable[[Sphinx, BuildEnvironment], str], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-get-updated'], + callback: Callable[[Sphinx, BuildEnvironment], Iterable[str]], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['env-check-consistency'], + callback: Callable[[Sphinx, BuildEnvironment], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['write-started'], + callback: Callable[[Sphinx, Builder], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['doctree-resolved'], + callback: Callable[[Sphinx, nodes.document, str], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['missing-reference'], + callback: Callable[ + [Sphinx, BuildEnvironment, addnodes.pending_xref, nodes.TextElement], + nodes.reference | None, + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['warn-missing-reference'], + callback: Callable[[Sphinx, Domain, addnodes.pending_xref], bool | None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['build-finished'], + callback: Callable[[Sphinx, Exception | None], None], + priority: int = 500 + ) -> int: + ... + + # ---- Events from builtin builders -------------------------------------- + + @overload + def connect( + self, + event: Literal['html-collect-pages'], + callback: Callable[[Sphinx], Iterable[tuple[str, dict[str, Any], str]]], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['html-page-context'], + callback: Callable[ + [Sphinx, str, str, dict[str, Any], nodes.document], str | None + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['linkcheck-process-uri'], + callback: Callable[[Sphinx, str], str | None], + priority: int = 500 + ) -> int: + ... + + # ---- Events from builtin extensions-- ---------------------------------- + + @overload + def connect( + self, + event: Literal['object-description-transform'], + callback: Callable[[Sphinx, str, str, addnodes.desc_content], None], + priority: int = 500 + ) -> int: + ... + + # ---- Events from first-party extensions -------------------------------- + + @overload + def connect( + self, + event: Literal['autodoc-process-docstring'], + callback: Callable[ + [ + Sphinx, + Literal['module', 'class', 'exception', 'function', 'method', 'attribute'], + str, + Any, + dict[str, bool], + Sequence[str], + ], + None, + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['autodoc-before-process-signature'], + callback: Callable[[Sphinx, Any, bool], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['autodoc-process-signature'], + callback: Callable[ + [ + Sphinx, + Literal['module', 'class', 'exception', 'function', 'method', 'attribute'], + str, + Any, + dict[str, bool], + str | None, + str | None, + ], + tuple[str | None, str | None] | None, + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['autodoc-process-bases'], + callback: Callable[[Sphinx, str, Any, dict[str, bool], list[str]], None], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['autodoc-skip-member'], + callback: Callable[ + [ + Sphinx, + Literal['module', 'class', 'exception', 'function', 'method', 'attribute'], + str, + Any, + bool, + dict[str, bool], + ], + bool, + ], + priority: int = 500 + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['todo-defined'], + callback: Callable[[Sphinx, todo_node], None], + priority: int = 500, + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['viewcode-find-source'], + callback: Callable[ + [Sphinx, str], + tuple[str, dict[str, tuple[Literal['class', 'def', 'other'], int, int]]], + ], + priority: int = 500, + ) -> int: + ... + + @overload + def connect( + self, + event: Literal['viewcode-follow-imported'], + callback: Callable[[Sphinx, str, str], str | None], + priority: int = 500, + ) -> int: + ... + + # ---- Catch-all --------------------------------------------------------- + + @overload + def connect( + self, + event: str, + callback: Callable[..., Any], + priority: int = 500 + ) -> int: + ... + # event interface def connect(self, event: str, callback: Callable, priority: int = 500) -> int: """Register *callback* to be called when *event* is emitted. @@ -851,7 +1176,7 @@ def add_index_to_domain(self, domain: str, index: type[Index], _override: bool = def add_object_type(self, directivename: str, rolename: str, indextemplate: str = '', parse_node: Callable | None = None, - ref_nodeclass: type[TextElement] | None = None, + ref_nodeclass: type[nodes.TextElement] | None = None, objname: str = '', doc_field_types: Sequence = (), override: bool = False, ) -> None: @@ -918,9 +1243,11 @@ def add_object_type(self, directivename: str, rolename: str, indextemplate: str ref_nodeclass, objname, doc_field_types, override=override) - def add_crossref_type(self, directivename: str, rolename: str, indextemplate: str = '', - ref_nodeclass: type[TextElement] | None = None, objname: str = '', - override: bool = False) -> None: + def add_crossref_type( + self, directivename: str, rolename: str, indextemplate: str = '', + ref_nodeclass: type[nodes.TextElement] | None = None, objname: str = '', + override: bool = False, + ) -> None: """Register a new crossref object type. This method is very similar to :meth:`~Sphinx.add_object_type` except that the diff --git a/sphinx/config.py b/sphinx/config.py index bd5941e2cae..bae92140d59 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: import os - from collections.abc import Collection, Iterator, Sequence, Set + from collections.abc import Collection, Iterable, Iterator, Sequence, Set from typing import TypeAlias from sphinx.application import Sphinx @@ -739,8 +739,8 @@ def check_primary_domain(app: Sphinx, config: Config) -> None: config.primary_domain = None -def check_root_doc(app: Sphinx, env: BuildEnvironment, added: set[str], - changed: set[str], removed: set[str]) -> set[str]: +def check_root_doc(app: Sphinx, env: BuildEnvironment, added: Set[str], + changed: Set[str], removed: Set[str]) -> Iterable[str]: """Adjust root_doc to 'contents' to support an old project which does not have any root_doc setting. """ From f807729ae5060538fffa9d03b6973d5652284fda Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 16 Aug 2024 10:45:24 +0100 Subject: [PATCH 04/13] Avoid a potential ``NameError`` (#12789) --- sphinx/util/parallel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/parallel.py b/sphinx/util/parallel.py index 32912e014d7..f17ef71294b 100644 --- a/sphinx/util/parallel.py +++ b/sphinx/util/parallel.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) # our parallel functionality only works for the forking Process -parallel_available = multiprocessing and os.name == 'posix' +parallel_available = HAS_MULTIPROCESSING and os.name == 'posix' class SerialTasks: From 0530f9808b6afe39bfdfc5f606418382e6a3d7ab Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 16 Aug 2024 10:46:06 +0100 Subject: [PATCH 05/13] Add type hint for a use of Pytest's ``tmp_path_factory`` (#12791) --- sphinx/testing/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 899fea6b613..03e38e85e86 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -239,7 +239,7 @@ def if_graphviz_found(app: SphinxTestApp) -> None: # NoQA: PT004 @pytest.fixture(scope='session') -def sphinx_test_tempdir(tmp_path_factory: Any) -> Path: +def sphinx_test_tempdir(tmp_path_factory: pytest.TempPathFactory) -> Path: """Temporary directory.""" return tmp_path_factory.getbasetemp() From d56cf30ecb2d68651c75b454f0aeae74304285dd Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 16 Aug 2024 10:46:54 +0100 Subject: [PATCH 06/13] Fix ``sphinx.testing.path.path.write_bytes`` bytes type (#12790) --- sphinx/testing/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/path.py b/sphinx/testing/path.py index 3a9ee8ddb20..49f0ffa6005 100644 --- a/sphinx/testing/path.py +++ b/sphinx/testing/path.py @@ -178,7 +178,7 @@ def read_bytes(self) -> builtins.bytes: with open(self, mode='rb') as f: return f.read() - def write_bytes(self, bytes: str, append: bool = False) -> None: + def write_bytes(self, bytes: bytes, append: bool = False) -> None: """ Writes the given `bytes` to the file. From f9b3355642acadbb45aaaf68012b2097aa094694 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 14 Aug 2024 21:16:19 +0100 Subject: [PATCH 07/13] Split ``Index{Entry}`` --- sphinx/domains/__init__.py | 124 ++++----------------------------- sphinx/domains/_index.py | 113 ++++++++++++++++++++++++++++++ sphinx/domains/std/__init__.py | 9 ++- 3 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 sphinx/domains/_index.py diff --git a/sphinx/domains/__init__.py b/sphinx/domains/__init__.py index b05643e1436..5f716ab61b7 100644 --- a/sphinx/domains/__init__.py +++ b/sphinx/domains/__init__.py @@ -7,20 +7,16 @@ from __future__ import annotations import copy -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, NamedTuple, cast +from typing import TYPE_CHECKING, Any, cast -from docutils.nodes import Element, Node, system_message - -from sphinx.errors import SphinxError +from sphinx.domains._index import Index, IndexEntry from sphinx.locale import _ if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - from typing import TypeAlias + from collections.abc import Callable, Iterable, Sequence from docutils import nodes + from docutils.nodes import Element, Node from docutils.parsers.rst import Directive from docutils.parsers.rst.states import Inliner @@ -28,7 +24,14 @@ from sphinx.builders import Builder from sphinx.environment import BuildEnvironment from sphinx.roles import XRefRole - from sphinx.util.typing import RoleFunction + from sphinx.util.typing import RoleFunction, TitleGetter + +__all__ = ( + 'Domain', + 'Index', + 'IndexEntry', + 'ObjType', +) class ObjType: @@ -57,107 +60,6 @@ def __init__(self, lname: str, *roles: Any, **attrs: Any) -> None: self.attrs.update(attrs) -class IndexEntry(NamedTuple): - name: str - subtype: int - docname: str - anchor: str - extra: str - qualifier: str - descr: str - - -class Index(ABC): - """ - An Index is the description for a domain-specific index. To add an index to - a domain, subclass Index, overriding the three name attributes: - - * `name` is an identifier used for generating file names. - It is also used for a hyperlink target for the index. Therefore, users can - refer the index page using ``ref`` role and a string which is combined - domain name and ``name`` attribute (ex. ``:ref:`py-modindex```). - * `localname` is the section title for the index. - * `shortname` is a short name for the index, for use in the relation bar in - HTML output. Can be empty to disable entries in the relation bar. - - and providing a :meth:`generate()` method. Then, add the index class to - your domain's `indices` list. Extensions can add indices to existing - domains using :meth:`~sphinx.application.Sphinx.add_index_to_domain()`. - - .. versionchanged:: 3.0 - - Index pages can be referred by domain name and index name via - :rst:role:`ref` role. - """ - - name: str - localname: str - shortname: str | None = None - - def __init__(self, domain: Domain) -> None: - if not self.name or self.localname is None: - raise SphinxError('Index subclass %s has no valid name or localname' - % self.__class__.__name__) - self.domain = domain - - @abstractmethod - def generate(self, docnames: Iterable[str] | None = None, - ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: - """Get entries for the index. - - If ``docnames`` is given, restrict to entries referring to these - docnames. - - The return value is a tuple of ``(content, collapse)``: - - ``collapse`` - A boolean that determines if sub-entries should start collapsed (for - output formats that support collapsing sub-entries). - - ``content``: - A sequence of ``(letter, entries)`` tuples, where ``letter`` is the - "heading" for the given ``entries``, usually the starting letter, and - ``entries`` is a sequence of single entries. Each entry is a sequence - ``[name, subtype, docname, anchor, extra, qualifier, descr]``. The - items in this sequence have the following meaning: - - ``name`` - The name of the index entry to be displayed. - - ``subtype`` - The sub-entry related type. One of: - - ``0`` - A normal entry. - ``1`` - An entry with sub-entries. - ``2`` - A sub-entry. - - ``docname`` - *docname* where the entry is located. - - ``anchor`` - Anchor for the entry within ``docname`` - - ``extra`` - Extra info for the entry. - - ``qualifier`` - Qualifier for the description. - - ``descr`` - Description for the entry. - - Qualifier and description are not rendered for some output formats such - as LaTeX. - """ - raise NotImplementedError - - -TitleGetter: TypeAlias = Callable[[Node], str | None] - - class Domain: """ A Domain is meant to be a group of "object" description directives for @@ -268,7 +170,7 @@ def role(self, name: str) -> RoleFunction | None: def role_adapter(typ: str, rawtext: str, text: str, lineno: int, inliner: Inliner, options: dict | None = None, content: Sequence[str] = (), - ) -> tuple[list[Node], list[system_message]]: + ) -> tuple[list[Node], list[nodes.system_message]]: return self.roles[name](fullname, rawtext, text, lineno, inliner, options or {}, content) self._role_cache[name] = role_adapter diff --git a/sphinx/domains/_index.py b/sphinx/domains/_index.py new file mode 100644 index 00000000000..1598d2f2026 --- /dev/null +++ b/sphinx/domains/_index.py @@ -0,0 +1,113 @@ +"""Domain indices.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, NamedTuple + +from sphinx.errors import SphinxError + +if TYPE_CHECKING: + from collections.abc import Iterable + + from sphinx.domains import Domain + + +class IndexEntry(NamedTuple): + name: str + subtype: int + docname: str + anchor: str + extra: str + qualifier: str + descr: str + + +class Index(ABC): + """ + An Index is the description for a domain-specific index. To add an index to + a domain, subclass Index, overriding the three name attributes: + + * `name` is an identifier used for generating file names. + It is also used for a hyperlink target for the index. Therefore, users can + refer the index page using ``ref`` role and a string which is combined + domain name and ``name`` attribute (ex. ``:ref:`py-modindex```). + * `localname` is the section title for the index. + * `shortname` is a short name for the index, for use in the relation bar in + HTML output. Can be empty to disable entries in the relation bar. + + and providing a :meth:`generate()` method. Then, add the index class to + your domain's `indices` list. Extensions can add indices to existing + domains using :meth:`~sphinx.application.Sphinx.add_index_to_domain()`. + + .. versionchanged:: 3.0 + + Index pages can be referred by domain name and index name via + :rst:role:`ref` role. + """ + + name: str + localname: str + shortname: str | None = None + + def __init__(self, domain: Domain) -> None: + if not self.name or self.localname is None: + msg = f'Index subclass {self.__class__.__name__} has no valid name or localname' + raise SphinxError(msg) + self.domain = domain + + @abstractmethod + def generate( + self, + docnames: Iterable[str] | None = None, + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + """Get entries for the index. + + If ``docnames`` is given, restrict to entries referring to these + docnames. + + The return value is a tuple of ``(content, collapse)``: + + ``collapse`` + A boolean that determines if sub-entries should start collapsed (for + output formats that support collapsing sub-entries). + + ``content``: + A sequence of ``(letter, entries)`` tuples, where ``letter`` is the + "heading" for the given ``entries``, usually the starting letter, and + ``entries`` is a sequence of single entries. Each entry is a sequence + ``[name, subtype, docname, anchor, extra, qualifier, descr]``. The + items in this sequence have the following meaning: + + ``name`` + The name of the index entry to be displayed. + + ``subtype`` + The sub-entry related type. One of: + + ``0`` + A normal entry. + ``1`` + An entry with sub-entries. + ``2`` + A sub-entry. + + ``docname`` + *docname* where the entry is located. + + ``anchor`` + Anchor for the entry within ``docname`` + + ``extra`` + Extra info for the entry. + + ``qualifier`` + Qualifier for the description. + + ``descr`` + Description for the entry. + + Qualifier and description are not rendered for some output formats such + as LaTeX. + """ + raise NotImplementedError diff --git a/sphinx/domains/std/__init__.py b/sphinx/domains/std/__init__.py index 629cad3a5cd..882a8a7bf1b 100644 --- a/sphinx/domains/std/__init__.py +++ b/sphinx/domains/std/__init__.py @@ -14,7 +14,7 @@ from sphinx import addnodes from sphinx.addnodes import desc_signature, pending_xref from sphinx.directives import ObjectDescription -from sphinx.domains import Domain, ObjType, TitleGetter +from sphinx.domains import Domain, ObjType from sphinx.locale import _, __ from sphinx.roles import EmphasizedLiteral, XRefRole from sphinx.util import docname_join, logging, ws_re @@ -28,7 +28,12 @@ from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.environment import BuildEnvironment - from sphinx.util.typing import ExtensionMetadata, OptionSpec, RoleFunction + from sphinx.util.typing import ( + ExtensionMetadata, + OptionSpec, + RoleFunction, + TitleGetter, + ) logger = logging.getLogger(__name__) From c9d3414be7652c8b75ec8a5a037760950557b1bc Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 14 Aug 2024 21:22:49 +0100 Subject: [PATCH 08/13] Better typing for `ObjType` --- sphinx/domains/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sphinx/domains/__init__.py b/sphinx/domains/__init__.py index 5f716ab61b7..ef3b5e90ade 100644 --- a/sphinx/domains/__init__.py +++ b/sphinx/domains/__init__.py @@ -53,11 +53,10 @@ class ObjType: 'searchprio': 1, } - def __init__(self, lname: str, *roles: Any, **attrs: Any) -> None: - self.lname = lname - self.roles: tuple = roles - self.attrs: dict = self.known_attrs.copy() - self.attrs.update(attrs) + def __init__(self, lname: str, /, *roles: Any, **attrs: Any) -> None: + self.lname: str = lname + self.roles: tuple[Any, ...] = roles + self.attrs: dict[str, Any] = self.known_attrs | attrs class Domain: From d1c23a08a9d8b1fc77ad8c1a368c17b18e8fdc85 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 16 Aug 2024 21:46:55 +0100 Subject: [PATCH 09/13] Add per-event overloads to ``EventManager.connect()`` (#12787) Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- .flake8 | 1 + sphinx/events.py | 313 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 312 insertions(+), 2 deletions(-) diff --git a/.flake8 b/.flake8 index 6c182a22c19..e14b09a0543 100644 --- a/.flake8 +++ b/.flake8 @@ -33,5 +33,6 @@ exclude = doc/usage/extensions/example*.py, per-file-ignores = doc/conf.py:W605 + sphinx/events.py:E704, tests/test_extensions/ext_napoleon_pep526_data_google.py:MLL001, tests/test_extensions/ext_napoleon_pep526_data_numpy.py:MLL001, diff --git a/sphinx/events.py b/sphinx/events.py index df2fdf7048d..17de456f033 100644 --- a/sphinx/events.py +++ b/sphinx/events.py @@ -7,7 +7,7 @@ from collections import defaultdict from operator import attrgetter -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, NamedTuple, overload from sphinx.errors import ExtensionError, SphinxError from sphinx.locale import __ @@ -15,9 +15,19 @@ from sphinx.util.inspect import safe_getattr if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable, Sequence, Set + from pathlib import Path + from typing import Any, Literal + from docutils import nodes + + from sphinx import addnodes from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.config import Config + from sphinx.domains import Domain + from sphinx.environment import BuildEnvironment + from sphinx.ext.todo import todo_node logger = logging.getLogger(__name__) @@ -66,6 +76,305 @@ def add(self, name: str) -> None: raise ExtensionError(__('Event %r already present') % name) self.events[name] = '' + # ---- Core events ------------------------------------------------------- + + @overload + def connect( + self, + name: Literal['config-inited'], + callback: Callable[[Sphinx, Config], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['builder-inited'], + callback: Callable[[Sphinx], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-get-outdated'], + callback: Callable[ + [Sphinx, BuildEnvironment, Set[str], Set[str], Set[str]], Sequence[str] + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-before-read-docs'], + callback: Callable[[Sphinx, BuildEnvironment, list[str]], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-purge-doc'], + callback: Callable[[Sphinx, BuildEnvironment, str], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['source-read'], + callback: Callable[[Sphinx, str, list[str]], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['include-read'], + callback: Callable[[Sphinx, Path, str, list[str]], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['doctree-read'], + callback: Callable[[Sphinx, nodes.document], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-merge-info'], + callback: Callable[ + [Sphinx, BuildEnvironment, list[str], BuildEnvironment], None + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-updated'], + callback: Callable[[Sphinx, BuildEnvironment], str], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-get-updated'], + callback: Callable[[Sphinx, BuildEnvironment], Iterable[str]], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['env-check-consistency'], + callback: Callable[[Sphinx, BuildEnvironment], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['write-started'], + callback: Callable[[Sphinx, Builder], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['doctree-resolved'], + callback: Callable[[Sphinx, nodes.document, str], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['missing-reference'], + callback: Callable[ + [Sphinx, BuildEnvironment, addnodes.pending_xref, nodes.TextElement], + nodes.reference | None, + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['warn-missing-reference'], + callback: Callable[[Sphinx, Domain, addnodes.pending_xref], bool | None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['build-finished'], + callback: Callable[[Sphinx, Exception | None], None], + priority: int, + ) -> int: ... + + # ---- Events from builtin builders -------------------------------------- + + @overload + def connect( + self, + name: Literal['html-collect-pages'], + callback: Callable[[Sphinx], Iterable[tuple[str, dict[str, Any], str]]], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['html-page-context'], + callback: Callable[ + [Sphinx, str, str, dict[str, Any], nodes.document], str | None + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['linkcheck-process-uri'], + callback: Callable[[Sphinx, str], str | None], + priority: int, + ) -> int: ... + + # ---- Events from builtin extensions-- ---------------------------------- + + @overload + def connect( + self, + name: Literal['object-description-transform'], + callback: Callable[[Sphinx, str, str, addnodes.desc_content], None], + priority: int, + ) -> int: ... + + # ---- Events from first-party extensions -------------------------------- + + @overload + def connect( + self, + name: Literal['autodoc-process-docstring'], + callback: Callable[ + [ + Sphinx, + Literal[ + 'module', 'class', 'exception', 'function', 'method', 'attribute' + ], + str, + Any, + dict[str, bool], + Sequence[str], + ], + None, + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['autodoc-before-process-signature'], + callback: Callable[[Sphinx, Any, bool], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['autodoc-process-signature'], + callback: Callable[ + [ + Sphinx, + Literal[ + 'module', 'class', 'exception', 'function', 'method', 'attribute' + ], + str, + Any, + dict[str, bool], + str | None, + str | None, + ], + tuple[str | None, str | None] | None, + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['autodoc-process-bases'], + callback: Callable[[Sphinx, str, Any, dict[str, bool], list[str]], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['autodoc-skip-member'], + callback: Callable[ + [ + Sphinx, + Literal[ + 'module', 'class', 'exception', 'function', 'method', 'attribute' + ], + str, + Any, + bool, + dict[str, bool], + ], + bool, + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['todo-defined'], + callback: Callable[[Sphinx, todo_node], None], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['viewcode-find-source'], + callback: Callable[ + [Sphinx, str], + tuple[str, dict[str, tuple[Literal['class', 'def', 'other'], int, int]]], + ], + priority: int, + ) -> int: ... + + @overload + def connect( + self, + name: Literal['viewcode-follow-imported'], + callback: Callable[[Sphinx, str, str], str | None], + priority: int, + ) -> int: ... + + # ---- Catch-all --------------------------------------------------------- + + @overload + def connect( + self, + name: str, + callback: Callable[..., Any], + priority: int, + ) -> int: ... + def connect(self, name: str, callback: Callable, priority: int) -> int: """Connect a handler to specific event.""" if name not in self.events: From ae8b37793d064d76db14ee3b1c3603c552d94d4f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 16 Aug 2024 22:35:32 +0100 Subject: [PATCH 10/13] Improve typing for ``mocksvgconverter.py`` (#12788) Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- .../mocksvgconverter.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py b/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py index c0928605a9e..234732ddc0c 100644 --- a/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py +++ b/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py @@ -2,34 +2,33 @@ Does foo.svg --> foo.pdf with no change to the file. """ +from __future__ import annotations + import shutil +from typing import TYPE_CHECKING from sphinx.transforms.post_transforms.images import ImageConverter -if False: - # For type annotation - from typing import Any, Dict # NoQA +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata - from sphinx.application import Sphinx # NoQA class MyConverter(ImageConverter): conversion_rules = [ ('image/svg+xml', 'application/pdf'), ] - def is_available(self): - # type: () -> bool + def is_available(self) -> bool: return True - def convert(self, _from, _to): - # type: (unicode, unicode) -> bool + def convert(self, _from: str, _to: str) -> bool: """Mock converts the image from SVG to PDF.""" shutil.copyfile(_from, _to) return True -def setup(app): - # type: (Sphinx) -> Dict[unicode, Any] +def setup(app: Sphinx) -> ExtensionMetadata: app.add_post_transform(MyConverter) return { From 90b57914d10352580855c727cbe2a5a2f0b0152b Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:37:19 +0100 Subject: [PATCH 11/13] Bump Ruff to 0.6.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f5840d1a9c..dcca55027f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ docs = [ ] lint = [ "flake8>=6.0", - "ruff==0.6.0", + "ruff==0.6.1", "mypy==1.11.1", "sphinx-lint>=0.9", "types-colorama==0.4.15.20240311", From 2e1415cf6c0e904734fea49de738d0748d3e1fbd Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Sat, 17 Aug 2024 00:18:24 +0100 Subject: [PATCH 12/13] Use ``headlessFirefox`` and update to Node 20 (#12794) --- .github/workflows/nodejs.yml | 8 +++++--- tests/js/jasmine-browser.mjs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index fece8861873..29359f90958 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -23,11 +23,14 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +env: + FORCE_COLOR: "1" + jobs: build: runs-on: ubuntu-latest env: - node-version: "16" + node-version: "20" steps: - uses: actions/checkout@v4 @@ -37,5 +40,4 @@ jobs: node-version: ${{ env.node-version }} cache: "npm" - run: npm install - - name: Run headless test - run: xvfb-run -a npm test + - run: npm test diff --git a/tests/js/jasmine-browser.mjs b/tests/js/jasmine-browser.mjs index 7cc8610dd76..b84217fd8c5 100644 --- a/tests/js/jasmine-browser.mjs +++ b/tests/js/jasmine-browser.mjs @@ -23,6 +23,6 @@ export default { hostname: "127.0.0.1", browser: { - name: "firefox" + name: "headlessFirefox" } }; From 334e69fbb40e27ff55929976a4413ffd60c13d30 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Sat, 17 Aug 2024 02:53:48 +0100 Subject: [PATCH 13/13] Test command line argument parsing (#12795) --- sphinx/cmd/build.py | 6 +- sphinx/cmd/make_mode.py | 75 +++++++------ tests/test_command_line.py | 217 +++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 tests/test_command_line.py diff --git a/sphinx/cmd/build.py b/sphinx/cmd/build.py index 1773fa670a7..9f6cf2a3363 100644 --- a/sphinx/cmd/build.py +++ b/sphinx/cmd/build.py @@ -210,6 +210,9 @@ def get_parser() -> argparse.ArgumentParser: dest='exception_on_warning', help=__('raise an exception on warnings')) + if parser.prog == '__main__.py': + parser.prog = 'sphinx-build' + return parser @@ -386,7 +389,8 @@ def main(argv: Sequence[str] = (), /) -> int: if argv[:1] == ['--bug-report']: return _bug_report_info() if argv[:1] == ['-M']: - return make_main(argv) + from sphinx.cmd import make_mode + return make_mode.run_make_mode(argv[1:]) else: return build_main(argv) diff --git a/sphinx/cmd/make_mode.py b/sphinx/cmd/make_mode.py index 65df9f6227e..d1ba3fccf9c 100644 --- a/sphinx/cmd/make_mode.py +++ b/sphinx/cmd/make_mode.py @@ -58,31 +58,31 @@ class Make: - def __init__(self, srcdir: str, builddir: str, opts: Sequence[str]) -> None: - self.srcdir = srcdir - self.builddir = builddir + def __init__(self, *, source_dir: str, build_dir: str, opts: Sequence[str]) -> None: + self.source_dir = source_dir + self.build_dir = build_dir self.opts = [*opts] - def builddir_join(self, *comps: str) -> str: - return path.join(self.builddir, *comps) + def build_dir_join(self, *comps: str) -> str: + return path.join(self.build_dir, *comps) def build_clean(self) -> int: - srcdir = path.abspath(self.srcdir) - builddir = path.abspath(self.builddir) - if not path.exists(self.builddir): + source_dir = path.abspath(self.source_dir) + build_dir = path.abspath(self.build_dir) + if not path.exists(self.build_dir): return 0 - elif not path.isdir(self.builddir): - print("Error: %r is not a directory!" % self.builddir) + elif not path.isdir(self.build_dir): + print("Error: %r is not a directory!" % self.build_dir) return 1 - elif srcdir == builddir: - print("Error: %r is same as source directory!" % self.builddir) + elif source_dir == build_dir: + print("Error: %r is same as source directory!" % self.build_dir) return 1 - elif path.commonpath([srcdir, builddir]) == builddir: - print("Error: %r directory contains source directory!" % self.builddir) + elif path.commonpath([source_dir, build_dir]) == build_dir: + print("Error: %r directory contains source directory!" % self.build_dir) return 1 - print("Removing everything under %r..." % self.builddir) - for item in os.listdir(self.builddir): - rmtree(self.builddir_join(item)) + print("Removing everything under %r..." % self.build_dir) + for item in os.listdir(self.build_dir): + rmtree(self.build_dir_join(item)) return 0 def build_help(self) -> None: @@ -105,7 +105,7 @@ def build_latexpdf(self) -> int: if not makecmd.lower().startswith('make'): raise RuntimeError('Invalid $MAKE command: %r' % makecmd) try: - with chdir(self.builddir_join('latex')): + with chdir(self.build_dir_join('latex')): if '-Q' in self.opts: with open('__LATEXSTDOUT__', 'w') as outfile: returncode = subprocess.call([makecmd, @@ -117,7 +117,7 @@ def build_latexpdf(self) -> int: ) if returncode: print('Latex error: check %s' % - self.builddir_join('latex', '__LATEXSTDOUT__') + self.build_dir_join('latex', '__LATEXSTDOUT__') ) elif '-q' in self.opts: returncode = subprocess.call( @@ -129,7 +129,7 @@ def build_latexpdf(self) -> int: ) if returncode: print('Latex error: check .log file in %s' % - self.builddir_join('latex') + self.build_dir_join('latex') ) else: returncode = subprocess.call([makecmd, 'all-pdf']) @@ -148,7 +148,7 @@ def build_latexpdfja(self) -> int: if not makecmd.lower().startswith('make'): raise RuntimeError('Invalid $MAKE command: %r' % makecmd) try: - with chdir(self.builddir_join('latex')): + with chdir(self.build_dir_join('latex')): return subprocess.call([makecmd, 'all-pdf']) except OSError: print('Error: Failed to run: %s' % makecmd) @@ -163,32 +163,33 @@ def build_info(self) -> int: if not makecmd.lower().startswith('make'): raise RuntimeError('Invalid $MAKE command: %r' % makecmd) try: - with chdir(self.builddir_join('texinfo')): + with chdir(self.build_dir_join('texinfo')): return subprocess.call([makecmd, 'info']) except OSError: print('Error: Failed to run: %s' % makecmd) return 1 def build_gettext(self) -> int: - dtdir = self.builddir_join('gettext', '.doctrees') + dtdir = self.build_dir_join('gettext', '.doctrees') if self.run_generic_build('gettext', doctreedir=dtdir) > 0: return 1 return 0 def run_generic_build(self, builder: str, doctreedir: str | None = None) -> int: # compatibility with old Makefile - papersize = os.getenv('PAPER', '') - opts = self.opts - if papersize in ('a4', 'letter'): - opts.extend(['-D', 'latex_elements.papersize=' + papersize + 'paper']) + paper_size = os.getenv('PAPER', '') + if paper_size in {'a4', 'letter'}: + self.opts.extend(['-D', f'latex_elements.papersize={paper_size}paper']) if doctreedir is None: - doctreedir = self.builddir_join('doctrees') + doctreedir = self.build_dir_join('doctrees') - args = ['-b', builder, - '-d', doctreedir, - self.srcdir, - self.builddir_join(builder)] - return build_main(args + opts) + args = [ + '--builder', builder, + '--doctree-dir', doctreedir, + self.source_dir, + self.build_dir_join(builder), + ] + return build_main(args + self.opts) def run_make_mode(args: Sequence[str]) -> int: @@ -196,8 +197,10 @@ def run_make_mode(args: Sequence[str]) -> int: print('Error: at least 3 arguments (builder, source ' 'dir, build dir) are required.', file=sys.stderr) return 1 - make = Make(args[1], args[2], args[3:]) - run_method = 'build_' + args[0] + + builder_name = args[0] + make = Make(source_dir=args[1], build_dir=args[2], opts=args[3:]) + run_method = f'build_{builder_name}' if hasattr(make, run_method): return getattr(make, run_method)() - return make.run_generic_build(args[0]) + return make.run_generic_build(builder_name) diff --git a/tests/test_command_line.py b/tests/test_command_line.py new file mode 100644 index 00000000000..119602de905 --- /dev/null +++ b/tests/test_command_line.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import os.path +from typing import Any + +import pytest + +from sphinx.cmd import make_mode +from sphinx.cmd.build import get_parser +from sphinx.cmd.make_mode import run_make_mode + +DEFAULTS = { + 'filenames': [], + 'jobs': 1, + 'force_all': False, + 'freshenv': False, + 'doctreedir': None, + 'confdir': None, + 'noconfig': False, + 'define': [], + 'htmldefine': [], + 'tags': [], + 'nitpicky': False, + 'verbosity': 0, + 'quiet': False, + 'really_quiet': False, + 'color': 'auto', + 'warnfile': None, + 'warningiserror': False, + 'keep_going': False, + 'traceback': False, + 'pdb': False, + 'exception_on_warning': False, +} + +EXPECTED_BUILD_MAIN = { + 'builder': 'html', + 'sourcedir': 'source_dir', + 'outputdir': 'build_dir', + 'filenames': ['filename1', 'filename2'], + 'freshenv': True, + 'noconfig': True, + 'quiet': True, +} + +EXPECTED_MAKE_MODE = { + 'builder': 'html', + 'sourcedir': 'source_dir', + 'outputdir': os.path.join('build_dir', 'html'), + 'doctreedir': os.path.join('build_dir', 'doctrees'), + 'filenames': ['filename1', 'filename2'], + 'freshenv': True, + 'noconfig': True, + 'quiet': True, +} + +BUILDER_BUILD_MAIN = [ + '--builder', + 'html', +] +BUILDER_MAKE_MODE = [ + 'html', +] +POSITIONAL_DIRS = [ + 'source_dir', + 'build_dir', +] +POSITIONAL_FILENAMES = [ + 'filename1', + 'filename2', +] +POSITIONAL = POSITIONAL_DIRS + POSITIONAL_FILENAMES +POSITIONAL_MAKE_MODE = BUILDER_MAKE_MODE + POSITIONAL +EARLY_OPTS = [ + '--quiet', +] +LATE_OPTS = [ + '-E', + '--isolated', +] +OPTS = EARLY_OPTS + LATE_OPTS +OPTS_BUILD_MAIN = BUILDER_BUILD_MAIN + OPTS + + +def parse_arguments(args: list[str]) -> dict[str, Any]: + parsed = vars(get_parser().parse_args(args)) + return {k: v for k, v in parsed.items() if k not in DEFAULTS or v != DEFAULTS[k]} + + +def test_build_main_parse_arguments_pos_first() -> None: + # + args = [ + *POSITIONAL, + *OPTS, + ] + assert parse_arguments(args) == EXPECTED_BUILD_MAIN + + +def test_build_main_parse_arguments_pos_last() -> None: + # + args = [ + *OPTS, + *POSITIONAL, + ] + assert parse_arguments(args) == EXPECTED_BUILD_MAIN + + +def test_build_main_parse_arguments_pos_middle() -> None: + # + args = [ + *EARLY_OPTS, + *BUILDER_BUILD_MAIN, + *POSITIONAL, + *LATE_OPTS, + ] + assert parse_arguments(args) == EXPECTED_BUILD_MAIN + + +@pytest.mark.xfail(reason='sphinx-build does not yet support filenames after options') +def test_build_main_parse_arguments_filenames_last() -> None: + args = [ + *POSITIONAL_DIRS, + *OPTS, + *POSITIONAL_FILENAMES, + ] + assert parse_arguments(args) == EXPECTED_BUILD_MAIN + + +def test_build_main_parse_arguments_pos_intermixed( + capsys: pytest.CaptureFixture[str], +) -> None: + args = [ + *EARLY_OPTS, + *BUILDER_BUILD_MAIN, + *POSITIONAL_DIRS, + *LATE_OPTS, + *POSITIONAL_FILENAMES, + ] + with pytest.raises(SystemExit): + parse_arguments(args) + stderr = capsys.readouterr().err.splitlines() + assert stderr[-1].endswith('error: unrecognized arguments: filename1 filename2') + + +def test_make_mode_parse_arguments_pos_first(monkeypatch: pytest.MonkeyPatch) -> None: + # -M + monkeypatch.setattr(make_mode, 'build_main', parse_arguments) + args = [ + *POSITIONAL_MAKE_MODE, + *OPTS, + ] + assert run_make_mode(args) == EXPECTED_MAKE_MODE + + +def test_make_mode_parse_arguments_pos_last( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # -M + monkeypatch.setattr(make_mode, 'build_main', parse_arguments) + args = [ + *OPTS, + *POSITIONAL_MAKE_MODE, + ] + with pytest.raises(SystemExit): + run_make_mode(args) + stderr = capsys.readouterr().err.splitlines() + assert stderr[-1].endswith('error: argument --builder/-b: expected one argument') + + +def test_make_mode_parse_arguments_pos_middle( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # -M + monkeypatch.setattr(make_mode, 'build_main', parse_arguments) + args = [ + *EARLY_OPTS, + *POSITIONAL_MAKE_MODE, + *LATE_OPTS, + ] + with pytest.raises(SystemExit): + run_make_mode(args) + stderr = capsys.readouterr().err.splitlines() + assert stderr[-1].endswith('error: argument --builder/-b: expected one argument') + + +@pytest.mark.xfail(reason='sphinx-build does not yet support filenames after options') +def test_make_mode_parse_arguments_filenames_last( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(make_mode, 'build_main', parse_arguments) + args = [ + *BUILDER_MAKE_MODE, + *POSITIONAL_DIRS, + *OPTS, + *POSITIONAL_FILENAMES, + ] + assert run_make_mode(args) == EXPECTED_MAKE_MODE + + +def test_make_mode_parse_arguments_pos_intermixed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(make_mode, 'build_main', parse_arguments) + args = [ + *EARLY_OPTS, + *BUILDER_MAKE_MODE, + *POSITIONAL_DIRS, + *LATE_OPTS, + *POSITIONAL_FILENAMES, + ] + with pytest.raises(SystemExit): + run_make_mode(args) + stderr = capsys.readouterr().err.splitlines() + assert stderr[-1].endswith('error: argument --builder/-b: expected one argument')