From de15d61a46daaaa2b0a0e341cdb4e0abe107e012 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Tue, 23 Jul 2024 03:53:16 +0100 Subject: [PATCH] Use pathlib in ``sphinx.project`` --- doc/conf.py | 1 + sphinx/builders/__init__.py | 2 +- sphinx/builders/changes.py | 2 +- sphinx/builders/html/__init__.py | 4 +- sphinx/builders/linkcheck.py | 7 ++-- sphinx/directives/other.py | 2 +- sphinx/environment/__init__.py | 8 ++-- sphinx/environment/adapters/toctree.py | 2 +- sphinx/ext/autosummary/__init__.py | 4 +- sphinx/ext/doctest.py | 2 +- sphinx/project.py | 42 ++++++++++--------- sphinx/util/__init__.py | 4 +- tests/test_builders/test_build_linkcheck.py | 3 +- .../test_directive_object_description.py | 2 +- tests/test_project.py | 9 ++-- 15 files changed, 51 insertions(+), 43 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 9582f7939..73c904014 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -184,6 +184,7 @@ nitpick_ignore = { ('py:class', 'IndexEntry'), # sphinx.domains.IndexEntry ('py:class', 'Node'), # sphinx.domains.Domain ('py:class', 'NullTranslations'), # gettext.NullTranslations + ('py:class', 'Path'), # sphinx.environment.BuildEnvironment.doc2path ('py:class', 'RoleFunction'), # sphinx.domains.Domain ('py:class', 'RSTState'), # sphinx.utils.parsing.nested_parse_to_nodes ('py:class', 'Theme'), # sphinx.application.TemplateBridge diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index afbc0568a..151df6aeb 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -519,7 +519,7 @@ class Builder: if path.isfile(docutilsconf): self.env.note_dependency(docutilsconf) - filename = self.env.doc2path(docname) + filename = str(self.env.doc2path(docname)) filetype = get_filetype(self.app.config.source_suffix, filename) publisher = self.app.registry.get_publisher(self.app, filetype) self.env.temp_data['_parser'] = publisher.parser diff --git a/sphinx/builders/changes.py b/sphinx/builders/changes.py index 48a0ed828..18515649e 100644 --- a/sphinx/builders/changes.py +++ b/sphinx/builders/changes.py @@ -134,7 +134,7 @@ class ChangesBuilder(Builder): with open(targetfn, 'w', encoding='utf-8') as f: text = ''.join(hl(i + 1, line) for (i, line) in enumerate(lines)) ctx = { - 'filename': self.env.doc2path(docname, False), + 'filename': str(self.env.doc2path(docname, False)), 'text': text, } f.write(self.templates.render('changes/rstsource.html', ctx)) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 13ef13e98..6955caf16 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -611,7 +611,7 @@ class StandaloneHTMLBuilder(Builder): title = self.render_partial(title_node)['title'] if title_node else '' # Suffix for the document - source_suffix = self.env.doc2path(docname, False)[len(docname):] + source_suffix = str(self.env.doc2path(docname, False))[len(docname):] # the name for the copied source if self.config.html_copy_source: @@ -976,7 +976,7 @@ class StandaloneHTMLBuilder(Builder): def index_page(self, pagename: str, doctree: nodes.document, title: str) -> None: # only index pages with title if self.indexer is not None and title: - filename = self.env.doc2path(pagename, base=False) + filename = str(self.env.doc2path(pagename, base=False)) metadata = self.env.metadata.get(pagename, {}) if 'no-search' in metadata or 'nosearch' in metadata: self.indexer.feed(pagename, filename, '', new_document('')) diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 8d4a4562f..4416007d5 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -28,6 +28,7 @@ from sphinx.util.nodes import get_node_line if TYPE_CHECKING: from collections.abc import Callable, Iterator + from pathlib import Path from typing import Any from requests import Response @@ -82,7 +83,7 @@ class CheckExternalLinksBuilder(DummyBuilder): filename = self.env.doc2path(result.docname, False) linkstat: dict[str, str | int] = { - 'filename': filename, 'lineno': result.lineno, + 'filename': str(filename), 'lineno': result.lineno, 'status': result.status, 'code': result.code, 'uri': result.uri, 'info': result.message, } @@ -149,7 +150,7 @@ class CheckExternalLinksBuilder(DummyBuilder): self.json_outfile.write(json.dumps(data)) self.json_outfile.write('\n') - def write_entry(self, what: str, docname: str, filename: str, line: int, + def write_entry(self, what: str, docname: str, filename: Path, line: int, uri: str) -> None: self.txt_outfile.write(f'{filename}:{line}: [{what}] {uri}\n') @@ -225,7 +226,7 @@ class HyperlinkCollector(SphinxPostTransform): class Hyperlink(NamedTuple): uri: str docname: str - docpath: str + docpath: Path lineno: int diff --git a/sphinx/directives/other.py b/sphinx/directives/other.py index 047e2264f..2d2227a6e 100644 --- a/sphinx/directives/other.py +++ b/sphinx/directives/other.py @@ -145,7 +145,7 @@ class TocTree(SphinxDirective): continue if docname not in frozen_all_docnames: - if excluded(self.env.doc2path(docname, False)): + if excluded(str(self.env.doc2path(docname, False))): message = __('toctree contains reference to excluded document %r') subtype = 'excluded' else: diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 279e21586..239db1f8f 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -413,13 +413,13 @@ class BuildEnvironment: """ return self.project.path2doc(filename) - def doc2path(self, docname: str, base: bool = True) -> str: + def doc2path(self, docname: str, base: bool = True) -> Path: """Return the filename for the document name. If *base* is True, return absolute path under self.srcdir. If *base* is False, return relative path to self.srcdir. """ - return self.project.doc2path(docname, base) + return self.project.doc2path(docname, absolute=base) def relfn2path(self, filename: str, docname: str | None = None) -> tuple[str, str]: """Return paths to a file referenced from a document, relative to @@ -628,7 +628,7 @@ class BuildEnvironment: doctree = pickle.loads(serialised) doctree.settings.env = self - doctree.reporter = LoggingReporter(self.doc2path(docname)) + doctree.reporter = LoggingReporter(str(self.doc2path(docname))) return doctree @functools.cached_property @@ -650,7 +650,7 @@ class BuildEnvironment: try: doctree = self._write_doc_doctree_cache.pop(docname) doctree.settings.env = self - doctree.reporter = LoggingReporter(self.doc2path(docname)) + doctree.reporter = LoggingReporter(str(self.doc2path(docname))) except KeyError: doctree = self.get_doctree(docname) diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py index 64ad4942a..217cb0d41 100644 --- a/sphinx/environment/adapters/toctree.py +++ b/sphinx/environment/adapters/toctree.py @@ -319,7 +319,7 @@ def _toctree_entry( ref, location=toctreenode, type='toc', subtype='no_title') except KeyError: # this is raised if the included file does not exist - ref_path = env.doc2path(ref, False) + ref_path = str(env.doc2path(ref, False)) if excluded(ref_path): message = __('toctree contains reference to excluded document %r') elif not included(ref_path): diff --git a/sphinx/ext/autosummary/__init__.py b/sphinx/ext/autosummary/__init__.py index 86fc6c62d..6ded1e9f9 100644 --- a/sphinx/ext/autosummary/__init__.py +++ b/sphinx/ext/autosummary/__init__.py @@ -249,7 +249,7 @@ class Autosummary(SphinxDirective): docname = posixpath.join(tree_prefix, real_name) docname = posixpath.normpath(posixpath.join(dirname, docname)) if docname not in self.env.found_docs: - if excluded(self.env.doc2path(docname, False)): + if excluded(str(self.env.doc2path(docname, False))): msg = __('autosummary references excluded document %r. Ignored.') else: msg = __('autosummary: stub file not found %r. ' @@ -802,7 +802,7 @@ def process_generate_options(app: Sphinx) -> None: if genfiles is True: env = app.builder.env - genfiles = [env.doc2path(x, base=False) for x in env.found_docs + genfiles = [str(env.doc2path(x, base=False)) for x in env.found_docs if os.path.isfile(env.doc2path(x))] elif genfiles is False: pass diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py index 4d2e5cf4a..19f01f4ca 100644 --- a/sphinx/ext/doctest.py +++ b/sphinx/ext/doctest.py @@ -373,7 +373,7 @@ Doctest summary try: filename = relpath(node.source, self.env.srcdir).rsplit(':docstring of ', maxsplit=1)[0] # type: ignore[arg-type] # noqa: E501 except Exception: - filename = self.env.doc2path(docname, False) + filename = str(self.env.doc2path(docname, False)) return filename @staticmethod diff --git a/sphinx/project.py b/sphinx/project.py index 0ac9f1e94..7cdd7f6a7 100644 --- a/sphinx/project.py +++ b/sphinx/project.py @@ -4,13 +4,13 @@ from __future__ import annotations import contextlib import os -from glob import glob +from pathlib import Path from typing import TYPE_CHECKING from sphinx.locale import __ from sphinx.util import logging from sphinx.util.matching import get_matching_files -from sphinx.util.osutil import path_stabilize, relpath +from sphinx.util.osutil import path_stabilize if TYPE_CHECKING: from collections.abc import Iterable @@ -24,7 +24,7 @@ class Project: def __init__(self, srcdir: str | os.PathLike[str], source_suffix: Iterable[str]) -> None: #: Source directory. - self.srcdir = srcdir + self.srcdir = Path(srcdir) #: source_suffix. Same as :confval:`source_suffix`. self.source_suffix = tuple(source_suffix) @@ -34,8 +34,8 @@ class Project: self.docnames: set[str] = set() # Bijective mapping between docnames and (srcdir relative) paths. - self._path_to_docname: dict[str, str] = {} - self._docname_to_path: dict[str, str] = {} + self._path_to_docname: dict[Path, str] = {} + self._docname_to_path: dict[str, Path] = {} def restore(self, other: Project) -> None: """Take over a result of last build.""" @@ -60,22 +60,25 @@ class Project: ): if docname := self.path2doc(filename): if docname in self.docnames: - pattern = os.path.join(self.srcdir, docname) + '.*' - files = [relpath(f, self.srcdir) for f in glob(pattern)] + files = [ + str(f.relative_to(self.srcdir)) + for f in self.srcdir.glob(f'{docname}.*') + ] logger.warning( __( - 'multiple files found for the document "%s": %r\n' + 'multiple files found for the document "%s": %s\n' 'Use %r for the build.' ), docname, - files, + ', '.join(files), self.doc2path(docname, absolute=True), once=True, ) - elif os.access(os.path.join(self.srcdir, filename), os.R_OK): + elif os.access(self.srcdir / filename, os.R_OK): self.docnames.add(docname) - self._path_to_docname[filename] = docname - self._docname_to_path[docname] = filename + path = Path(filename) + self._path_to_docname[path] = docname + self._docname_to_path[docname] = path else: logger.warning( __('Ignored unreadable document %r.'), filename, location=docname @@ -91,18 +94,19 @@ class Project: try: return self._path_to_docname[filename] # type: ignore[index] except KeyError: - if os.path.isabs(filename): + path = Path(filename) + if path.is_absolute(): with contextlib.suppress(ValueError): - filename = os.path.relpath(filename, self.srcdir) + path = path.relative_to(self.srcdir) for suffix in self.source_suffix: - if os.path.basename(filename).endswith(suffix): - return path_stabilize(filename).removesuffix(suffix) + if path.name.endswith(suffix): + return path_stabilize(path).removesuffix(suffix) # the file does not have a docname return None - def doc2path(self, docname: str, absolute: bool) -> str: + def doc2path(self, docname: str, absolute: bool) -> Path: """Return the filename for the document name. If *absolute* is True, return as an absolute path. @@ -112,8 +116,8 @@ class Project: filename = self._docname_to_path[docname] except KeyError: # Backwards compatibility: the document does not exist - filename = docname + self._first_source_suffix + filename = Path(docname + self._first_source_suffix) if absolute: - return os.path.join(self.srcdir, filename) + return self.srcdir / filename return filename diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 3e1826243..ccc37ae60 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -50,9 +50,9 @@ def docname_join(basedocname: str, docname: str) -> str: return posixpath.normpath(posixpath.join('/' + basedocname, '..', docname))[1:] -def get_filetype(source_suffix: dict[str, str], filename: str) -> str: +def get_filetype(source_suffix: dict[str, str], filename: str | os.PathLike) -> str: for suffix, filetype in source_suffix.items(): - if filename.endswith(suffix): + if os.fspath(filename).endswith(suffix): # If default filetype (None), considered as restructuredtext. return filetype or 'restructuredtext' raise FiletypeNotFoundError diff --git a/tests/test_builders/test_build_linkcheck.py b/tests/test_builders/test_build_linkcheck.py index 98621a5ff..a67f0ae16 100644 --- a/tests/test_builders/test_build_linkcheck.py +++ b/tests/test_builders/test_build_linkcheck.py @@ -10,6 +10,7 @@ import time import wsgiref.handlers from base64 import b64encode from http.server import BaseHTTPRequestHandler +from pathlib import Path from queue import Queue from typing import TYPE_CHECKING from unittest import mock @@ -1061,7 +1062,7 @@ def test_connection_contention(get_adapter, app, capsys): wqueue: Queue[CheckRequest] = Queue() rqueue: Queue[CheckResult] = Queue() for _ in range(link_count): - wqueue.put(CheckRequest(0, Hyperlink(f"http://{address}", "test", "test.rst", 1))) + wqueue.put(CheckRequest(0, Hyperlink(f"http://{address}", "test", Path("test.rst"), 1))) begin = time.time() checked: list[CheckResult] = [] diff --git a/tests/test_directives/test_directive_object_description.py b/tests/test_directives/test_directive_object_description.py index f2c9f9d8d..4b5107ebf 100644 --- a/tests/test_directives/test_directive_object_description.py +++ b/tests/test_directives/test_directive_object_description.py @@ -14,7 +14,7 @@ def _doctree_for_test(builder, docname: str) -> nodes.document: builder.env.prepare_settings(docname) publisher = create_publisher(builder.app, 'restructuredtext') with sphinx_domains(builder.env): - publisher.set_source(source_path=builder.env.doc2path(docname)) + publisher.set_source(source_path=str(builder.env.doc2path(docname))) publisher.publish() return publisher.document diff --git a/tests/test_project.py b/tests/test_project.py index 45ae7c81b..83098bd1c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,4 +1,5 @@ """Tests project module.""" +from pathlib import Path import pytest @@ -64,15 +65,15 @@ def test_project_doc2path(app): project.discover() # absolute path - assert project.doc2path('index', absolute=True) == str(app.srcdir / 'index.rst') + assert project.doc2path('index', absolute=True) == app.srcdir / 'index.rst' # relative path - assert project.doc2path('index', absolute=False) == 'index.rst' + assert project.doc2path('index', absolute=False) == Path('index.rst') # first source_suffix is used for missing file - assert project.doc2path('foo', absolute=False) == 'foo.rst' + assert project.doc2path('foo', absolute=False) == Path('foo.rst') # matched source_suffix is used if exists (app.srcdir / 'bar.txt').touch() project.discover() - assert project.doc2path('bar', absolute=False) == 'bar.txt' + assert project.doc2path('bar', absolute=False) == Path('bar.txt')