From 22e0f8944b147df7c4cf72c9f805b390307cb76e Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 7 Feb 2025 13:51:42 +0000 Subject: [PATCH] Use pathlib in more places --- sphinx/application.py | 4 +- sphinx/builders/__init__.py | 40 +++++++------- sphinx/builders/_epub_base.py | 14 +++-- sphinx/builders/changes.py | 4 +- sphinx/builders/gettext.py | 4 +- sphinx/builders/latex/__init__.py | 4 +- sphinx/builders/text.py | 11 ++-- sphinx/builders/xml.py | 11 ++-- sphinx/directives/other.py | 6 +-- sphinx/environment/__init__.py | 9 ++-- sphinx/environment/collectors/asset.py | 2 +- sphinx/ext/apidoc/_cli.py | 4 +- sphinx/ext/apidoc/_generate.py | 45 ++++++++-------- sphinx/ext/autosummary/__init__.py | 4 +- sphinx/ext/autosummary/generate.py | 7 ++- sphinx/ext/coverage.py | 4 +- sphinx/ext/graphviz.py | 11 ++-- sphinx/ext/imgmath.py | 3 +- sphinx/search/ja.py | 3 +- sphinx/testing/util.py | 8 +-- sphinx/util/_files.py | 52 ++++++++++++------- sphinx/util/fileutil.py | 37 +++++++------ sphinx/util/matching.py | 16 ++++-- sphinx/util/osutil.py | 3 +- .../mocksvgconverter.py | 6 ++- .../test-ext-viewcode-find-package/conf.py | 4 +- .../test_environment_record_dependencies.py | 6 ++- tests/test_util/test_util_fileutil.py | 9 ++-- 28 files changed, 170 insertions(+), 161 deletions(-) diff --git a/sphinx/application.py b/sphinx/application.py index f56cbff97..cbb260460 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -415,9 +415,7 @@ class Sphinx: # ---- main "build" method ------------------------------------------------- - def build( - self, force_all: bool = False, filenames: list[str] | None = None - ) -> None: + def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None: self.phase = BuildPhase.READING try: if force_all: diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 0b162305e..f39698405 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -3,11 +3,11 @@ from __future__ import annotations import codecs -import os.path import pickle import re import time from contextlib import nullcontext +from pathlib import Path from typing import TYPE_CHECKING, final from docutils import nodes @@ -33,7 +33,7 @@ from sphinx.util.build_phase import BuildPhase from sphinx.util.display import progress_message, status_iterator from sphinx.util.docutils import sphinx_domains from sphinx.util.i18n import CatalogRepository, docname_to_domain -from sphinx.util.osutil import SEP, canon_path, ensuredir, relative_uri, relpath +from sphinx.util.osutil import canon_path, ensuredir, relative_uri, relpath from sphinx.util.parallel import ( ParallelTasks, SerialTasks, @@ -47,7 +47,6 @@ from sphinx import roles # NoQA: F401 isort:skip if TYPE_CHECKING: from collections.abc import Iterable, Sequence, Set - from pathlib import Path from typing import Any, Literal from docutils.nodes import Node @@ -246,7 +245,7 @@ class Builder: return def cat2relpath(cat: CatalogInfo, srcdir: Path = self.srcdir) -> str: - return relpath(cat.mo_path, srcdir).replace(os.path.sep, SEP) + return Path(relpath(cat.mo_path, srcdir)).as_posix() logger.info(bold(__('building [mo]: ')) + message) # NoQA: G003 for catalog in status_iterator( @@ -271,16 +270,16 @@ class Builder: message = __('all of %d po files') % len(list(repo.catalogs)) self.compile_catalogs(set(repo.catalogs), message) - def compile_specific_catalogs(self, specified_files: list[str]) -> None: - def to_domain(fpath: str) -> str | None: - docname = self.env.path2doc(os.path.abspath(fpath)) - if docname: - return docname_to_domain(docname, self.config.gettext_compact) - else: - return None + def compile_specific_catalogs(self, specified_files: Iterable[Path]) -> None: + env = self.env + gettext_compact = self.config.gettext_compact + domains = { + docname_to_domain(docname, gettext_compact) if docname else None + for file in specified_files + if (docname := env.path2doc(file)) + } catalogs = set() - domains = set(map(to_domain, specified_files)) repo = CatalogRepository( self.srcdir, self.config.locale_dirs, @@ -315,20 +314,19 @@ class Builder: self.build(None, summary=__('all source files'), method='all') @final - def build_specific(self, filenames: list[str]) -> None: + def build_specific(self, filenames: Sequence[Path]) -> None: """Only rebuild as much as needed for changes in the *filenames*.""" docnames: list[str] = [] + filenames = [Path(filename).resolve() for filename in filenames] for filename in filenames: - filename = os.path.normpath(os.path.abspath(filename)) - - if not os.path.isfile(filename): + if not filename.is_file(): logger.warning( __('file %r given on command line does not exist, '), filename ) continue - if not filename.startswith(str(self.srcdir)): + if not filename.is_relative_to(self.srcdir): logger.warning( __( 'file %r given on command line is not under the ' @@ -622,9 +620,9 @@ class Builder: env.prepare_settings(docname) # Add confdir/docutils.conf to dependencies list if exists - docutilsconf = self.confdir / 'docutils.conf' - if os.path.isfile(docutilsconf): - env.note_dependency(docutilsconf) + docutils_conf = self.confdir / 'docutils.conf' + if docutils_conf.is_file(): + env.note_dependency(docutils_conf) filename = str(env.doc2path(docname)) filetype = get_filetype(self.app.config.source_suffix, filename) @@ -675,7 +673,7 @@ class Builder: doctree.settings.record_dependencies = None doctree_filename = self.doctreedir / f'{docname}.doctree' - ensuredir(os.path.dirname(doctree_filename)) + doctree_filename.parent.mkdir(parents=True, exist_ok=True) with open(doctree_filename, 'wb') as f: pickle.dump(doctree, f, pickle.HIGHEST_PROTOCOL) diff --git a/sphinx/builders/_epub_base.py b/sphinx/builders/_epub_base.py index 66e1fa7a2..a9527c3c0 100644 --- a/sphinx/builders/_epub_base.py +++ b/sphinx/builders/_epub_base.py @@ -229,18 +229,16 @@ class EpubBuilder(StandaloneHTMLBuilder): appeared.add(node['refuri']) def get_toc(self) -> None: - """Get the total table of contents, containing the root_doc - and pre and post files not managed by sphinx. + """Get the total table of contents, containing the master_doc + and pre and post files not managed by Sphinx. """ doctree = self.env.get_and_resolve_doctree( - self.config.root_doc, self, prune_toctrees=False, includehidden=True + self.config.master_doc, self, prune_toctrees=False, includehidden=True ) self.refnodes = self.get_refnodes(doctree, []) - master_dir = os.path.dirname(self.config.root_doc) - if master_dir: - master_dir += '/' # XXX or os.sep? - for item in self.refnodes: - item['refuri'] = master_dir + item['refuri'] + master_dir = Path(self.config.master_doc).parent + for item in self.refnodes: + item['refuri'] = str(master_dir / item['refuri']) self.toc_add_files(self.refnodes) def toc_add_files(self, refnodes: list[dict[str, Any]]) -> None: diff --git a/sphinx/builders/changes.py b/sphinx/builders/changes.py index 7edf9c39c..547ac5d7e 100644 --- a/sphinx/builders/changes.py +++ b/sphinx/builders/changes.py @@ -3,7 +3,6 @@ from __future__ import annotations import html -import os.path from pathlib import Path from typing import TYPE_CHECKING @@ -14,7 +13,6 @@ from sphinx.locale import _, __ from sphinx.theming import HTMLThemeFactory from sphinx.util import logging from sphinx.util.fileutil import copy_asset_file -from sphinx.util.osutil import ensuredir if TYPE_CHECKING: from collections.abc import Set @@ -143,7 +141,7 @@ class ChangesBuilder(Builder): } rendered = self.templates.render('changes/rstsource.html', ctx) targetfn = self.outdir / 'rst' / f'{docname}.html' - ensuredir(os.path.dirname(targetfn)) + targetfn.parent.mkdir(parents=True, exist_ok=True) with open(targetfn, 'w', encoding='utf-8') as f: f.write(rendered) themectx = { diff --git a/sphinx/builders/gettext.py b/sphinx/builders/gettext.py index 1d581afb6..b33b8ddef 100644 --- a/sphinx/builders/gettext.py +++ b/sphinx/builders/gettext.py @@ -209,8 +209,8 @@ else: ctime = time.strftime('%Y-%m-%d %H:%M%z', timestamp) -def should_write(filepath: str | os.PathLike[str], new_content: str) -> bool: - if not os.path.exists(filepath): +def should_write(filepath: Path, new_content: str) -> bool: + if not filepath.exists(): return True try: with codecs.open(str(filepath), encoding='utf-8') as oldpot: diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 6bd30c120..af459872a 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -498,11 +498,11 @@ class LaTeXBuilder(Builder): err, ) if self.config.latex_logo: - if not os.path.isfile(self.confdir / self.config.latex_logo): + source = self.confdir / self.config.latex_logo + if not source.is_file(): raise SphinxError( __('logo file %r does not exist') % self.config.latex_logo ) - source = self.confdir / self.config.latex_logo copyfile( source, self.outdir / source.name, diff --git a/sphinx/builders/text.py b/sphinx/builders/text.py index 75be377ca..849134f0e 100644 --- a/sphinx/builders/text.py +++ b/sphinx/builders/text.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os.path from typing import TYPE_CHECKING from docutils.io import StringOutput @@ -10,7 +9,7 @@ from docutils.io import StringOutput from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging -from sphinx.util.osutil import _last_modified_time, ensuredir +from sphinx.util.osutil import _last_modified_time from sphinx.writers.text import TextTranslator, TextWriter if TYPE_CHECKING: @@ -68,13 +67,13 @@ class TextBuilder(Builder): self.secnumbers = self.env.toc_secnumbers.get(docname, {}) destination = StringOutput(encoding='utf-8') self.writer.write(doctree, destination) - outfilename = self.outdir / (docname + self.out_suffix) - ensuredir(os.path.dirname(outfilename)) + out_file_name = self.outdir / (docname + self.out_suffix) + out_file_name.parent.mkdir(parents=True, exist_ok=True) try: - with open(outfilename, 'w', encoding='utf-8') as f: + with open(out_file_name, 'w', encoding='utf-8') as f: f.write(self.writer.output) except OSError as err: - logger.warning(__('error writing file %s: %s'), outfilename, err) + logger.warning(__('error writing file %s: %s'), out_file_name, err) def finish(self) -> None: pass diff --git a/sphinx/builders/xml.py b/sphinx/builders/xml.py index b1204b849..8e246bece 100644 --- a/sphinx/builders/xml.py +++ b/sphinx/builders/xml.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os.path from typing import TYPE_CHECKING from docutils import nodes @@ -12,7 +11,7 @@ from docutils.writers.docutils_xml import XMLTranslator from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging -from sphinx.util.osutil import _last_modified_time, ensuredir +from sphinx.util.osutil import _last_modified_time from sphinx.writers.xml import PseudoXMLWriter, XMLWriter if TYPE_CHECKING: @@ -82,13 +81,13 @@ class XMLBuilder(Builder): value[i] = list(val) destination = StringOutput(encoding='utf-8') self.writer.write(doctree, destination) - outfilename = self.outdir / (docname + self.out_suffix) - ensuredir(os.path.dirname(outfilename)) + out_file_name = self.outdir / (docname + self.out_suffix) + out_file_name.parent.mkdir(parents=True, exist_ok=True) try: - with open(outfilename, 'w', encoding='utf-8') as f: + with open(out_file_name, 'w', encoding='utf-8') as f: f.write(self.writer.output) except OSError as err: - logger.warning(__('error writing file %s: %s'), outfilename, err) + logger.warning(__('error writing file %s: %s'), out_file_name, err) def finish(self) -> None: pass diff --git a/sphinx/directives/other.py b/sphinx/directives/other.py index 8c3d00668..d9c2b98fd 100644 --- a/sphinx/directives/other.py +++ b/sphinx/directives/other.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from os.path import abspath, relpath +from os.path import relpath from pathlib import Path from typing import TYPE_CHECKING, cast @@ -387,7 +387,7 @@ class Include(BaseInclude, SphinxDirective): # We must preserve them and leave them out of the include-read event: text = '\n'.join(include_lines[:-2]) - path = Path(relpath(abspath(source), start=self.env.srcdir)) + path = Path(relpath(Path(source).resolve(), start=self.env.srcdir)) docname = self.env.docname # Emit the "include-read" event @@ -412,7 +412,7 @@ class Include(BaseInclude, SphinxDirective): # docutils "standard" includes, do not do path processing return super().run() rel_filename, filename = self.env.relfn2path(self.arguments[0]) - self.arguments[0] = filename + self.arguments[0] = str(filename) self.env.note_included(filename) return super().run() diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 1d8253350..9496f6968 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -22,7 +22,7 @@ from sphinx.locale import __ from sphinx.transforms import SphinxTransformer from sphinx.util import logging from sphinx.util._files import DownloadFiles, FilenameUniqDict -from sphinx.util._pathlib import _StrPathProperty +from sphinx.util._pathlib import _StrPath, _StrPathProperty from sphinx.util._serialise import stable_str from sphinx.util._timestamps import _format_rfc3339_microseconds from sphinx.util.docutils import LoggingReporter @@ -47,7 +47,6 @@ if TYPE_CHECKING: from sphinx.events import EventManager from sphinx.extension import Extension from sphinx.project import Project - from sphinx.util._pathlib import _StrPath logger = logging.getLogger(__name__) @@ -131,7 +130,7 @@ class BuildEnvironment: self.all_docs: dict[str, int] = {} # docname -> set of dependent file # names, relative to documentation root - self.dependencies: dict[str, set[str]] = defaultdict(set) + self.dependencies: dict[str, set[_StrPath]] = {} # docname -> set of included file # docnames included from other documents self.included: dict[str, set[str]] = defaultdict(set) @@ -524,6 +523,8 @@ class BuildEnvironment: changed.add(docname) continue # finally, check the mtime of dependencies + if docname not in self.dependencies: + continue for dep in self.dependencies[docname]: try: # this will do the right thing when dep is absolute too @@ -614,7 +615,7 @@ class BuildEnvironment: """ if docname is None: docname = self.docname - self.dependencies[docname].add(os.fspath(filename)) + self.dependencies.setdefault(docname, set()).add(_StrPath(filename)) def note_included(self, filename: str | os.PathLike[str]) -> None: """Add *filename* as a included from other document. diff --git a/sphinx/environment/collectors/asset.py b/sphinx/environment/collectors/asset.py index eff497434..131055876 100644 --- a/sphinx/environment/collectors/asset.py +++ b/sphinx/environment/collectors/asset.py @@ -169,7 +169,7 @@ class DownloadFileCollector(EnvironmentCollector): continue node['filename'] = app.env.dlfiles.add_file( app.env.docname, rel_filename - ) + ).as_posix() def setup(app: Sphinx) -> ExtensionMetadata: diff --git a/sphinx/ext/apidoc/_cli.py b/sphinx/ext/apidoc/_cli.py index 5549f1ac2..a1ee91586 100644 --- a/sphinx/ext/apidoc/_cli.py +++ b/sphinx/ext/apidoc/_cli.py @@ -3,8 +3,6 @@ from __future__ import annotations import argparse import fnmatch import locale -import os -import os.path import re import sys from pathlib import Path @@ -262,7 +260,7 @@ def main(argv: Sequence[str] = (), /) -> int: opts = _parse_args(argv) rootpath = opts.module_path excludes = tuple( - re.compile(fnmatch.translate(os.path.abspath(exclude))) + re.compile(fnmatch.translate(str(Path(exclude).resolve()))) for exclude in dict.fromkeys(opts.exclude_pattern) ) diff --git a/sphinx/ext/apidoc/_generate.py b/sphinx/ext/apidoc/_generate.py index 8099eb8ad..b7b012442 100644 --- a/sphinx/ext/apidoc/_generate.py +++ b/sphinx/ext/apidoc/_generate.py @@ -50,16 +50,15 @@ def module_join(*modnames: str | None) -> str: return '.'.join(filter(None, modnames)) -def is_packagedir( - dirname: str | None = None, files: Iterable[str | Path] | None = None +def is_package_dir( + files: Iterable[Path | str] = (), *, dir_path: Path | None = None ) -> bool: """Check given *files* contains __init__ file.""" - if files is None and dirname is None: - return False - - if files is None: - files = Path(dirname or '.').iterdir() - return any(f for f in files if is_initpy(f)) + if files != (): + return any(map(is_initpy, files)) + if dir_path is not None: + return any(map(is_initpy, dir_path.iterdir())) + return False def write_file(name: str, text: str, opts: ApidocOptions) -> Path: @@ -235,12 +234,12 @@ def is_skipped_module( def walk( - rootpath: str, + root_path: str | Path, excludes: Sequence[re.Pattern[str]], opts: ApidocOptions, ) -> Iterator[tuple[str, list[str], list[str]]]: """Walk through the directory and list files and subdirectories up.""" - for root, subs, files in os.walk(rootpath, followlinks=opts.followlinks): + for root, subs, files in os.walk(root_path, followlinks=opts.followlinks): # document only Python module files (that aren't excluded) files = sorted( f @@ -266,14 +265,14 @@ def walk( def has_child_module( - rootpath: str, excludes: Sequence[re.Pattern[str]], opts: ApidocOptions + root_path: str | Path, excludes: Sequence[re.Pattern[str]], opts: ApidocOptions ) -> bool: """Check the given directory contains child module/s (at least one).""" - return any(files for _root, _subs, files in walk(rootpath, excludes, opts)) + return any(files for _root, _subs, files in walk(root_path, excludes, opts)) def recurse_tree( - rootpath: str | os.PathLike[str], + root_path: str | os.PathLike[str], excludes: Sequence[re.Pattern[str]], opts: ApidocOptions, user_template_dir: str | os.PathLike[str] | None = None, @@ -282,24 +281,24 @@ def recurse_tree( ReST files. """ # check if the base directory is a package and get its name - rootpath = os.fspath(rootpath) - if is_packagedir(rootpath) or opts.implicit_namespaces: - root_package = rootpath.split(os.path.sep)[-1] + root_path = Path(root_path) + if is_package_dir(dir_path=root_path) or opts.implicit_namespaces: + root_package = root_path.name else: # otherwise, the base is a directory with packages root_package = None toplevels = [] written_files = [] - for root, subs, files in walk(rootpath, excludes, opts): - is_pkg = is_packagedir(None, files) + for root, subs, files in walk(root_path, excludes, opts): + is_pkg = is_package_dir(files) is_namespace = not is_pkg and opts.implicit_namespaces if is_pkg: for f in files.copy(): if is_initpy(f): files.remove(f) files.insert(0, f) - elif root != rootpath: + elif root != str(root_path): # only accept non-package at toplevel unless using implicit namespaces if not opts.implicit_namespaces: subs.clear() @@ -309,7 +308,9 @@ def recurse_tree( # we are in a package with something to document if subs or len(files) > 1 or not is_skipped_package(root, opts): subpackage = ( - root[len(rootpath) :].lstrip(os.path.sep).replace(os.path.sep, '.') + root.removeprefix(str(root_path)) + .lstrip(os.path.sep) + .replace(os.path.sep, '.') ) # if this is not a namespace or # a namespace and there is something there to document @@ -330,10 +331,10 @@ def recurse_tree( toplevels.append(module_join(root_package, subpackage)) else: # if we are at the root level, we don't require it to be a package - assert root == rootpath + assert root == str(root_path) assert root_package is None for py_file in files: - if not is_skipped_module(Path(rootpath, py_file), opts, excludes): + if not is_skipped_module(Path(root_path, py_file), opts, excludes): module = py_file.split('.')[0] written_files.append( create_module_file( diff --git a/sphinx/ext/autosummary/__init__.py b/sphinx/ext/autosummary/__init__.py index 9cefc58d4..175a7a7d5 100644 --- a/sphinx/ext/autosummary/__init__.py +++ b/sphinx/ext/autosummary/__init__.py @@ -51,8 +51,6 @@ from __future__ import annotations import functools import inspect import operator -import os -import os.path import posixpath import re import sys @@ -893,7 +891,7 @@ def process_generate_options(app: Sphinx) -> None: genfiles = [ str(env.doc2path(x, base=False)) for x in env.found_docs - if os.path.isfile(env.doc2path(x)) + if env.doc2path(x).is_file() ] elif genfiles is False: pass diff --git a/sphinx/ext/autosummary/generate.py b/sphinx/ext/autosummary/generate.py index 24f014d8c..b50284267 100644 --- a/sphinx/ext/autosummary/generate.py +++ b/sphinx/ext/autosummary/generate.py @@ -18,8 +18,6 @@ import argparse import importlib import inspect import locale -import os -import os.path import pkgutil import pydoc import re @@ -52,6 +50,7 @@ from sphinx.util.osutil import ensuredir from sphinx.util.template import SphinxTemplateLoader if TYPE_CHECKING: + import os from collections.abc import Sequence, Set from gettext import NullTranslations from typing import Any @@ -556,7 +555,7 @@ def generate_autosummary_docs( # a :toctree: option continue - path = output_dir or os.path.abspath(entry.path) + path = output_dir or Path(entry.path).resolve() ensuredir(path) try: @@ -862,7 +861,7 @@ def main(argv: Sequence[str] = (), /) -> None: args = get_parser().parse_args(argv or sys.argv[1:]) if args.templates: - app.config.templates_path.append(os.path.abspath(args.templates)) + app.config.templates_path.append(str(Path(args.templates).resolve())) app.config.autosummary_ignore_module_all = not args.respect_module_all written_files = generate_autosummary_docs( diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 67eac22b0..e6b8bb61e 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -170,8 +170,8 @@ class CoverageBuilder(Builder): name = 'coverage' epilog = __( 'Testing of coverage in the sources finished, look at the ' - 'results in %(outdir)s' + os.path.sep + 'python.txt.' - ) + 'results in %(outdir)s{sep}python.txt.' + ).format(sep=os.path.sep) def init(self) -> None: self.c_sourcefiles: list[str] = [] diff --git a/sphinx/ext/graphviz.py b/sphinx/ext/graphviz.py index 64c8456e3..c3770d3a6 100644 --- a/sphinx/ext/graphviz.py +++ b/sphinx/ext/graphviz.py @@ -24,7 +24,6 @@ from sphinx.util._pathlib import _StrPath from sphinx.util.docutils import SphinxDirective from sphinx.util.i18n import search_image_for_language from sphinx.util.nodes import set_source_info -from sphinx.util.osutil import ensuredir if TYPE_CHECKING: from typing import Any, ClassVar @@ -299,13 +298,13 @@ def render_dot( relfn = _StrPath(self.builder.imgpath, fname) outfn = self.builder.outdir / self.builder.imagedir / fname - if os.path.isfile(outfn): + if outfn.is_file(): return relfn, outfn if getattr(self.builder, '_graphviz_warned_dot', {}).get(graphviz_dot): return None, None - ensuredir(os.path.dirname(outfn)) + outfn.parent.mkdir(parents=True, exist_ok=True) dot_args = [graphviz_dot] dot_args.extend(self.builder.config.graphviz_dot_args) @@ -313,9 +312,9 @@ def render_dot( docname = options.get('docname', 'index') if filename: - cwd = os.path.dirname(self.builder.srcdir / filename) + cwd = (self.builder.srcdir / filename).parent else: - cwd = os.path.dirname(self.builder.srcdir / docname) + cwd = (self.builder.srcdir / docname).parent if format == 'png': dot_args.extend(['-Tcmapx', f'-o{outfn}.map']) @@ -341,7 +340,7 @@ def render_dot( __('dot exited with error:\n[stderr]\n%r\n[stdout]\n%r') % (exc.stderr, exc.stdout) ) from exc - if not os.path.isfile(outfn): + if not outfn.is_file(): raise GraphvizError( __('dot did not produce an output file:\n[stderr]\n%r\n[stdout]\n%r') % (ret.stderr, ret.stdout) diff --git a/sphinx/ext/imgmath.py b/sphinx/ext/imgmath.py index 2fde626fd..5ffd653d7 100644 --- a/sphinx/ext/imgmath.py +++ b/sphinx/ext/imgmath.py @@ -25,7 +25,6 @@ from sphinx.errors import SphinxError from sphinx.locale import _, __ from sphinx.util import logging from sphinx.util.math import get_node_equation_number, wrap_displaymath -from sphinx.util.osutil import ensuredir from sphinx.util.png import read_png_depth, write_png_depth from sphinx.util.template import LaTeXRenderer @@ -266,7 +265,7 @@ def render_math( f'{sha1(latex.encode(), usedforsecurity=False).hexdigest()}.{image_format}' ) generated_path = self.builder.outdir / self.builder.imagedir / 'math' / filename - ensuredir(os.path.dirname(generated_path)) + generated_path.parent.mkdir(parents=True, exist_ok=True) if generated_path.is_file(): if image_format == 'png': depth = read_png_depth(generated_path) diff --git a/sphinx/search/ja.py b/sphinx/search/ja.py index 9d6df1fe1..f855fe4a6 100644 --- a/sphinx/search/ja.py +++ b/sphinx/search/ja.py @@ -13,6 +13,7 @@ from __future__ import annotations import os import re import sys +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -91,7 +92,7 @@ class MecabSplitter(BaseSplitter): libpath = ctypes.util.find_library(lib) else: libpath = None - if os.path.exists(lib): + if Path(lib).exists(): libpath = lib if libpath is None: msg = 'MeCab dynamic library is not available' diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index 218cb3f7b..a7244bb32 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -232,9 +232,7 @@ class SphinxTestApp(sphinx.application.Sphinx): def __repr__(self) -> str: return f'<{self.__class__.__name__} buildername={self._builder_name!r}>' - def build( - self, force_all: bool = False, filenames: list[str] | None = None - ) -> None: + def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None: self.env._pickled_doctree_cache.clear() super().build(force_all, filenames) @@ -246,9 +244,7 @@ class SphinxTestAppWrapperForSkipBuilding(SphinxTestApp): if it has already been built and there are any output files. """ - def build( - self, force_all: bool = False, filenames: list[str] | None = None - ) -> None: + def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None: if not list(self.outdir.iterdir()): # if listdir is empty, do build. super().build(force_all, filenames) diff --git a/sphinx/util/_files.py b/sphinx/util/_files.py index 65313801b..f0a23f936 100644 --- a/sphinx/util/_files.py +++ b/sphinx/util/_files.py @@ -1,12 +1,14 @@ from __future__ import annotations import hashlib -import os.path +from pathlib import Path from typing import TYPE_CHECKING +from sphinx.util._pathlib import _StrPath + if TYPE_CHECKING: + import os from collections.abc import Set - from typing import Any class FilenameUniqDict(dict[str, tuple[set[str], str]]): @@ -16,22 +18,27 @@ class FilenameUniqDict(dict[str, tuple[set[str], str]]): """ def __init__(self) -> None: + super().__init__() self._existing: set[str] = set() def add_file(self, docname: str, newfile: str | os.PathLike[str]) -> str: newfile = str(newfile) if newfile in self: - self[newfile][0].add(docname) - return self[newfile][1] - uniquename = os.path.basename(newfile) - base, ext = os.path.splitext(uniquename) + docnames, unique_name = self[newfile] + docnames.add(docname) + return unique_name + + new_file = Path(newfile) + unique_name = new_file.name + base = new_file.stem + ext = new_file.suffix i = 0 - while uniquename in self._existing: + while unique_name in self._existing: i += 1 - uniquename = f'{base}{i}{ext}' - self[newfile] = ({docname}, uniquename) - self._existing.add(uniquename) - return uniquename + unique_name = f'{base}{i}{ext}' + self[newfile] = ({docname}, unique_name) + self._existing.add(unique_name) + return unique_name def purge_doc(self, docname: str) -> None: for filename, (docs, unique) in list(self.items()): @@ -41,7 +48,7 @@ class FilenameUniqDict(dict[str, tuple[set[str], str]]): self._existing.discard(unique) def merge_other( - self, docnames: Set[str], other: dict[str, tuple[set[str], Any]] + self, docnames: Set[str], other: dict[str, tuple[set[str], str]] ) -> None: for filename, (docs, _unique) in other.items(): for doc in docs & set(docnames): @@ -54,21 +61,26 @@ class FilenameUniqDict(dict[str, tuple[set[str], str]]): self._existing = state -class DownloadFiles(dict[str, tuple[set[str], str]]): +class DownloadFiles(dict[Path, tuple[set[str], _StrPath]]): """A special dictionary for download files. .. important:: This class would be refactored in nearly future. Hence don't hack this directly. """ - def add_file(self, docname: str, filename: str) -> str: + def add_file(self, docname: str, filename: str | os.PathLike[str]) -> _StrPath: + filename = Path(filename) if filename not in self: - digest = hashlib.md5(filename.encode(), usedforsecurity=False).hexdigest() - dest = f'{digest}/{os.path.basename(filename)}' - self[filename] = (set(), dest) + digest = hashlib.md5( + filename.as_posix().encode(), usedforsecurity=False + ).hexdigest() + dest_path = _StrPath(digest, filename.name) + self[filename] = ({docname}, dest_path) + return dest_path - self[filename][0].add(docname) - return self[filename][1] + docnames, dest_path = self[filename] + docnames.add(docname) + return dest_path def purge_doc(self, docname: str) -> None: for filename, (docs, _dest) in list(self.items()): @@ -77,7 +89,7 @@ class DownloadFiles(dict[str, tuple[set[str], str]]): del self[filename] def merge_other( - self, docnames: Set[str], other: dict[str, tuple[set[str], Any]] + self, docnames: Set[str], other: dict[Path, tuple[set[str], _StrPath]] ) -> None: for filename, (docs, _dest) in other.items(): for docname in docs & set(docnames): diff --git a/sphinx/util/fileutil.py b/sphinx/util/fileutil.py index 4e665bfb0..57490a0a5 100644 --- a/sphinx/util/fileutil.py +++ b/sphinx/util/fileutil.py @@ -21,16 +21,16 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -def _template_basename(filename: str | os.PathLike[str]) -> str | None: +def _template_basename(filename: Path) -> Path | None: """Given an input filename: If the input looks like a template, then return the filename output should be written to. Otherwise, return no result (None). """ - basename = os.path.basename(filename) - if basename.lower().endswith('_t'): - return str(filename)[:-2] - elif basename.lower().endswith('.jinja'): - return str(filename)[:-6] + basename = filename.name.lower() + if basename.endswith('_t'): + return filename.with_name(filename.name[:-2]) + elif basename.endswith('.jinja'): + return filename.with_name(filename.name[:-6]) return None @@ -53,13 +53,14 @@ def copy_asset_file( :param renderer: The template engine. If not given, SphinxRenderer is used by default :param bool force: Overwrite the destination file even if it exists. """ - if not os.path.exists(source): + source = Path(source) + if not source.exists(): return destination = Path(destination) if destination.is_dir(): # Use source filename if destination points a directory - destination /= os.path.basename(source) + destination /= source.name if _template_basename(source) and context is not None: if renderer is None: @@ -67,8 +68,7 @@ def copy_asset_file( renderer = SphinxRenderer() - with open(source, encoding='utf-8') as fsrc: - template_content = fsrc.read() + template_content = source.read_text(encoding='utf-8') rendered_template = renderer.render_string(template_content, context) if not force and destination.exists() and template_content != rendered_template: @@ -86,15 +86,14 @@ def copy_asset_file( return destination = _template_basename(destination) or destination - with open(destination, 'w', encoding='utf-8') as fdst: - msg = __('Writing evaluated template result to %s') - logger.info( - msg, - os.fsdecode(destination), - type='misc', - subtype='template_evaluation', - ) - fdst.write(rendered_template) + msg = __('Writing evaluated template result to %s') + logger.info( + msg, + os.fsdecode(destination), + type='misc', + subtype='template_evaluation', + ) + destination.write_text(rendered_template, encoding='utf-8') else: copyfile(source, destination, force=force) diff --git a/sphinx/util/matching.py b/sphinx/util/matching.py index 0ab75c47d..8b666ea26 100644 --- a/sphinx/util/matching.py +++ b/sphinx/util/matching.py @@ -4,9 +4,11 @@ from __future__ import annotations import os.path import re +import unicodedata +from pathlib import Path from typing import TYPE_CHECKING -from sphinx.util.osutil import canon_path, path_stabilize +from sphinx.util.osutil import canon_path if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator @@ -125,7 +127,7 @@ def get_matching_files( """ # dirname is a normalized absolute path. - dirname = os.path.normpath(os.path.abspath(dirname)) + dirname = Path(dirname).resolve() exclude_matchers = compile_matchers(exclude_patterns) include_matchers = compile_matchers(include_patterns) @@ -134,11 +136,12 @@ def get_matching_files( relative_root = os.path.relpath(root, dirname) if relative_root == '.': relative_root = '' # suppress dirname for files on the target dir + relative_root_path = Path(relative_root) # Filter files included_files = [] for entry in sorted(files): - entry = path_stabilize(os.path.join(relative_root, entry)) + entry = _unicode_nfc((relative_root_path / entry).as_posix()) keep = False for matcher in include_matchers: if matcher(entry): @@ -156,7 +159,7 @@ def get_matching_files( # Filter directories filtered_dirs = [] for dir_name in sorted(dirs): - normalised = path_stabilize(os.path.join(relative_root, dir_name)) + normalised = _unicode_nfc((relative_root_path / dir_name).as_posix()) for matcher in exclude_matchers: if matcher(normalised): break # break the inner loop @@ -168,3 +171,8 @@ def get_matching_files( # Yield filtered files yield from included_files + + +def _unicode_nfc(s: str, /) -> str: + """Normalise the string to NFC form.""" + return unicodedata.normalize('NFC', s) diff --git a/sphinx/util/osutil.py b/sphinx/util/osutil.py index d24bbf55b..0a90ffb9d 100644 --- a/sphinx/util/osutil.py +++ b/sphinx/util/osutil.py @@ -257,7 +257,8 @@ class FileAvoidWrite: def rmtree(path: str | os.PathLike[str], /) -> None: - if os.path.isdir(path): + path = Path(path) + if path.is_dir(): shutil.rmtree(path) else: os.remove(path) diff --git a/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py b/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py index 97b08a9a3..36be4b3df 100644 --- a/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py +++ b/tests/roots/test-ext-imgmockconverter/mocksvgconverter.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING from sphinx.transforms.post_transforms.images import ImageConverter if TYPE_CHECKING: + import os + from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -20,7 +22,9 @@ class MyConverter(ImageConverter): def is_available(self) -> bool: return True - def convert(self, _from: str, _to: str) -> bool: + def convert( + self, _from: str | os.PathLike[str], _to: str | os.PathLike[str] + ) -> bool: """Mock converts the image from SVG to PDF.""" shutil.copyfile(_from, _to) return True diff --git a/tests/roots/test-ext-viewcode-find-package/conf.py b/tests/roots/test-ext-viewcode-find-package/conf.py index cad4c5597..be67c6296 100644 --- a/tests/roots/test-ext-viewcode-find-package/conf.py +++ b/tests/roots/test-ext-viewcode-find-package/conf.py @@ -1,7 +1,7 @@ -import os import sys +from pathlib import Path -source_dir = os.path.abspath('.') +source_dir = str(Path.cwd().resolve()) if source_dir not in sys.path: sys.path.insert(0, source_dir) extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] diff --git a/tests/test_environment/test_environment_record_dependencies.py b/tests/test_environment/test_environment_record_dependencies.py index f5d80ca2f..cdddb1c5a 100644 --- a/tests/test_environment/test_environment_record_dependencies.py +++ b/tests/test_environment/test_environment_record_dependencies.py @@ -4,9 +4,11 @@ from __future__ import annotations import pytest +from sphinx.util._pathlib import _StrPath + @pytest.mark.sphinx('html', testroot='environment-record-dependencies') def test_record_dependencies_cleared(app): app.builder.read() - assert app.env.dependencies['index'] == set() - assert app.env.dependencies['api'] == {'example_module.py'} + assert 'index' not in app.env.dependencies + assert app.env.dependencies['api'] == {_StrPath('example_module.py')} diff --git a/tests/test_util/test_util_fileutil.py b/tests/test_util/test_util_fileutil.py index 32a20de1b..b29822a41 100644 --- a/tests/test_util/test_util_fileutil.py +++ b/tests/test_util/test_util_fileutil.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from pathlib import Path from unittest import mock import pytest @@ -144,10 +145,10 @@ def test_copy_asset_overwrite(app): def test_template_basename(): - assert _template_basename('asset.txt') is None - assert _template_basename('asset.txt.jinja') == 'asset.txt' - assert _template_basename('sidebar.html.jinja') == 'sidebar.html' + assert _template_basename(Path('asset.txt')) is None + assert _template_basename(Path('asset.txt.jinja')) == Path('asset.txt') + assert _template_basename(Path('sidebar.html.jinja')) == Path('sidebar.html') def test_legacy_template_basename(): - assert _template_basename('asset.txt_t') == 'asset.txt' + assert _template_basename(Path('asset.txt_t')) == Path('asset.txt')