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')