`_StrPath is dead; long live _StrPath` (#12690)

This commit is contained in:
Adam Turner 2024-07-26 17:33:55 +01:00 committed by GitHub
parent a80a11da0e
commit b511537597
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 161 additions and 22 deletions

View File

@ -68,10 +68,6 @@ Incompatible changes
* #12096: Do not overwrite user-supplied files when copying assets * #12096: Do not overwrite user-supplied files when copying assets
unless forced with ``force=True``. unless forced with ``force=True``.
Patch by Adam Turner. Patch by Adam Turner.
* #12650: Remove support for string methods on :py:class:`~pathlib.Path` objects.
Use :py:func:`os.fspath` to convert :py:class:`~pathlib.Path` objects to strings,
or :py:class:`~pathlib.Path`'s methods to work with path objects.
Patch by Adam Turner.
* #12646: Remove :py:func:`!sphinx.util.inspect.isNewType`. * #12646: Remove :py:func:`!sphinx.util.inspect.isNewType`.
Patch by Adam Turner. Patch by Adam Turner.
* Remove the long-deprecated (since Sphinx 2) alias * Remove the long-deprecated (since Sphinx 2) alias
@ -88,6 +84,11 @@ Deprecated
to ``sphinx.ext.intersphinx.validate_intersphinx_mapping``. to ``sphinx.ext.intersphinx.validate_intersphinx_mapping``.
The old name will be removed in Sphinx 10. The old name will be removed in Sphinx 10.
Patch by Adam Turner. Patch by Adam Turner.
* #12650, #12686, #12690: Extend the deprecation for string methods on
:py:class:`~pathlib.Path` objects to Sphinx 9.
Use :py:func:`os.fspath` to convert :py:class:`~pathlib.Path` objects to strings,
or :py:class:`~pathlib.Path`'s methods to work with path objects.
Patch by Adam Turner.
Features added Features added
-------------- --------------

View File

@ -179,12 +179,12 @@ nitpick_ignore = {
('js:func', 'number'), ('js:func', 'number'),
('js:func', 'string'), ('js:func', 'string'),
('py:attr', 'srcline'), ('py:attr', 'srcline'),
('py:class', '_StrPath'), # sphinx.environment.BuildEnvironment.doc2path
('py:class', 'Element'), # sphinx.domains.Domain ('py:class', 'Element'), # sphinx.domains.Domain
('py:class', 'Documenter'), # sphinx.application.Sphinx.add_autodocumenter ('py:class', 'Documenter'), # sphinx.application.Sphinx.add_autodocumenter
('py:class', 'IndexEntry'), # sphinx.domains.IndexEntry ('py:class', 'IndexEntry'), # sphinx.domains.IndexEntry
('py:class', 'Node'), # sphinx.domains.Domain ('py:class', 'Node'), # sphinx.domains.Domain
('py:class', 'NullTranslations'), # gettext.NullTranslations ('py:class', 'NullTranslations'), # gettext.NullTranslations
('py:class', 'Path'), # sphinx.environment.BuildEnvironment.doc2path
('py:class', 'RoleFunction'), # sphinx.domains.Domain ('py:class', 'RoleFunction'), # sphinx.domains.Domain
('py:class', 'RSTState'), # sphinx.utils.parsing.nested_parse_to_nodes ('py:class', 'RSTState'), # sphinx.utils.parsing.nested_parse_to_nodes
('py:class', 'Theme'), # sphinx.application.TemplateBridge ('py:class', 'Theme'), # sphinx.application.TemplateBridge
@ -213,6 +213,7 @@ nitpick_ignore = {
('py:class', 'sphinx.roles.XRefRole'), ('py:class', 'sphinx.roles.XRefRole'),
('py:class', 'sphinx.search.SearchLanguage'), ('py:class', 'sphinx.search.SearchLanguage'),
('py:class', 'sphinx.theming.Theme'), ('py:class', 'sphinx.theming.Theme'),
('py:class', 'sphinx.util._pathlib._StrPath'), # sphinx.project.Project.doc2path
('py:class', 'sphinxcontrib.websupport.errors.DocumentNotFoundError'), ('py:class', 'sphinxcontrib.websupport.errors.DocumentNotFoundError'),
('py:class', 'sphinxcontrib.websupport.errors.UserNotAuthorizedError'), ('py:class', 'sphinxcontrib.websupport.errors.UserNotAuthorizedError'),
('py:exc', 'docutils.nodes.SkipNode'), ('py:exc', 'docutils.nodes.SkipNode'),

View File

@ -13,7 +13,6 @@ from collections import deque
from collections.abc import Callable, Collection, Sequence # NoQA: TCH003 from collections.abc import Callable, Collection, Sequence # NoQA: TCH003
from io import StringIO from io import StringIO
from os import path from os import path
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, Literal from typing import IO, TYPE_CHECKING, Any, Literal
from docutils.nodes import TextElement # NoQA: TCH002 from docutils.nodes import TextElement # NoQA: TCH002
@ -32,6 +31,7 @@ from sphinx.locale import __
from sphinx.project import Project from sphinx.project import Project
from sphinx.registry import SphinxComponentRegistry from sphinx.registry import SphinxComponentRegistry
from sphinx.util import docutils, logging from sphinx.util import docutils, logging
from sphinx.util._pathlib import _StrPath
from sphinx.util.build_phase import BuildPhase from sphinx.util.build_phase import BuildPhase
from sphinx.util.console import bold from sphinx.util.console import bold
from sphinx.util.display import progress_message from sphinx.util.display import progress_message
@ -173,9 +173,9 @@ class Sphinx:
self.registry = SphinxComponentRegistry() self.registry = SphinxComponentRegistry()
# validate provided directories # validate provided directories
self.srcdir = Path(srcdir).resolve() self.srcdir = _StrPath(srcdir).resolve()
self.outdir = Path(outdir).resolve() self.outdir = _StrPath(outdir).resolve()
self.doctreedir = Path(doctreedir).resolve() self.doctreedir = _StrPath(doctreedir).resolve()
if not path.isdir(self.srcdir): if not path.isdir(self.srcdir):
raise ApplicationError(__('Cannot find source directory (%s)') % raise ApplicationError(__('Cannot find source directory (%s)') %
@ -231,7 +231,7 @@ class Sphinx:
self.confdir = self.srcdir self.confdir = self.srcdir
self.config = Config({}, confoverrides or {}) self.config = Config({}, confoverrides or {})
else: else:
self.confdir = Path(confdir).resolve() self.confdir = _StrPath(confdir).resolve()
self.config = Config.read(self.confdir, confoverrides or {}, self.tags) self.config = Config.read(self.confdir, confoverrides or {}, self.tags)
# set up translation infrastructure # set up translation infrastructure

View File

@ -28,13 +28,13 @@ from sphinx.util.nodes import get_node_line
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any from typing import Any
from requests import Response from requests import Response
from sphinx.application import Sphinx from sphinx.application import Sphinx
from sphinx.config import Config from sphinx.config import Config
from sphinx.util._pathlib import _StrPath
from sphinx.util.typing import ExtensionMetadata from sphinx.util.typing import ExtensionMetadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -150,7 +150,7 @@ class CheckExternalLinksBuilder(DummyBuilder):
self.json_outfile.write(json.dumps(data)) self.json_outfile.write(json.dumps(data))
self.json_outfile.write('\n') self.json_outfile.write('\n')
def write_entry(self, what: str, docname: str, filename: Path, line: int, def write_entry(self, what: str, docname: str, filename: _StrPath, line: int,
uri: str) -> None: uri: str) -> None:
self.txt_outfile.write(f'{filename}:{line}: [{what}] {uri}\n') self.txt_outfile.write(f'{filename}:{line}: [{what}] {uri}\n')
@ -226,7 +226,7 @@ class HyperlinkCollector(SphinxPostTransform):
class Hyperlink(NamedTuple): class Hyperlink(NamedTuple):
uri: str uri: str
docname: str docname: str
docpath: Path docpath: _StrPath
lineno: int lineno: int

View File

@ -36,6 +36,7 @@ if TYPE_CHECKING:
from sphinx.domains import Domain from sphinx.domains import Domain
from sphinx.events import EventManager from sphinx.events import EventManager
from sphinx.project import Project from sphinx.project import Project
from sphinx.util._pathlib import _StrPath
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -413,7 +414,7 @@ class BuildEnvironment:
""" """
return self.project.path2doc(filename) return self.project.path2doc(filename)
def doc2path(self, docname: str, base: bool = True) -> Path: def doc2path(self, docname: str, base: bool = True) -> _StrPath:
"""Return the filename for the document name. """Return the filename for the document name.
If *base* is True, return absolute path under self.srcdir. If *base* is True, return absolute path under self.srcdir.

View File

@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from sphinx.locale import __ from sphinx.locale import __
from sphinx.util import logging from sphinx.util import logging
from sphinx.util._pathlib import _StrPath
from sphinx.util.matching import get_matching_files from sphinx.util.matching import get_matching_files
from sphinx.util.osutil import path_stabilize from sphinx.util.osutil import path_stabilize
@ -24,7 +25,7 @@ class Project:
def __init__(self, srcdir: str | os.PathLike[str], source_suffix: Iterable[str]) -> None: def __init__(self, srcdir: str | os.PathLike[str], source_suffix: Iterable[str]) -> None:
#: Source directory. #: Source directory.
self.srcdir = Path(srcdir) self.srcdir = _StrPath(srcdir)
#: source_suffix. Same as :confval:`source_suffix`. #: source_suffix. Same as :confval:`source_suffix`.
self.source_suffix = tuple(source_suffix) self.source_suffix = tuple(source_suffix)
@ -106,7 +107,7 @@ class Project:
# the file does not have a docname # the file does not have a docname
return None return None
def doc2path(self, docname: str, absolute: bool) -> Path: def doc2path(self, docname: str, absolute: bool) -> _StrPath:
"""Return the filename for the document name. """Return the filename for the document name.
If *absolute* is True, return as an absolute path. If *absolute* is True, return as an absolute path.
@ -119,5 +120,5 @@ class Project:
filename = Path(docname + self._first_source_suffix) filename = Path(docname + self._first_source_suffix)
if absolute: if absolute:
return self.srcdir / filename return _StrPath(self.srcdir / filename)
return filename return _StrPath(filename)

132
sphinx/util/_pathlib.py Normal file
View File

@ -0,0 +1,132 @@
"""What follows is awful and will be gone in Sphinx 9.
Instances of _StrPath should not be constructed except in Sphinx itself.
Consumers of Sphinx APIs should prefer using ``pathlib.Path`` objects
where possible. _StrPath objects can be treated as equivalent to ``Path``,
save that ``_StrPath.replace`` is overriden with ``str.replace``.
To continue treating path-like objects as strings, use ``os.fspath``,
or explicit string coercion.
In Sphinx 9, ``Path`` objects will be expected and returned in all instances
that ``_StrPath`` is currently used.
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path, PosixPath, PurePath, WindowsPath
from typing import Any
from sphinx.deprecation import RemovedInSphinx90Warning
_STR_METHODS = frozenset(str.__dict__)
_PATH_NAME = Path().__class__.__name__
_MSG = (
'Sphinx 8 will drop support for representing paths as strings. '
'Use "pathlib.Path" or "os.fspath" instead.'
)
# https://docs.python.org/3/library/stdtypes.html#typesseq-common
# https://docs.python.org/3/library/stdtypes.html#string-methods
if sys.platform == 'win32':
class _StrPath(WindowsPath):
def replace( # type: ignore[override]
self, old: str, new: str, count: int = -1, /,
) -> str:
# replace exists in both Path and str;
# in Path it makes filesystem changes, so we use the safer str version
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__().replace(old, new, count) # NoQA: PLC2801
def __getattr__(self, item: str) -> Any:
if item in _STR_METHODS:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return getattr(self.__str__(), item)
msg = f'{_PATH_NAME!r} has no attribute {item!r}'
raise AttributeError(msg)
def __add__(self, other: str) -> str:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__() + other
def __bool__(self) -> bool:
if not self.__str__():
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return False
return True
def __contains__(self, item: str) -> bool:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return item in self.__str__()
def __eq__(self, other: object) -> bool:
if isinstance(other, PurePath):
return super().__eq__(other)
if isinstance(other, str):
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__() == other
return NotImplemented
def __hash__(self) -> int:
return super().__hash__()
def __getitem__(self, item: int | slice) -> str:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__()[item]
def __len__(self) -> int:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return len(self.__str__())
else:
class _StrPath(PosixPath):
def replace( # type: ignore[override]
self, old: str, new: str, count: int = -1, /,
) -> str:
# replace exists in both Path and str;
# in Path it makes filesystem changes, so we use the safer str version
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__().replace(old, new, count) # NoQA: PLC2801
def __getattr__(self, item: str) -> Any:
if item in _STR_METHODS:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return getattr(self.__str__(), item)
msg = f'{_PATH_NAME!r} has no attribute {item!r}'
raise AttributeError(msg)
def __add__(self, other: str) -> str:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__() + other
def __bool__(self) -> bool:
if not self.__str__():
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return False
return True
def __contains__(self, item: str) -> bool:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return item in self.__str__()
def __eq__(self, other: object) -> bool:
if isinstance(other, PurePath):
return super().__eq__(other)
if isinstance(other, str):
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__() == other
return NotImplemented
def __hash__(self) -> int:
return super().__hash__()
def __getitem__(self, item: int | slice) -> str:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return self.__str__()[item]
def __len__(self) -> int:
warnings.warn(_MSG, RemovedInSphinx90Warning, stacklevel=2)
return len(self.__str__())

View File

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from sphinx.builders.html import validate_html_extra_path, validate_html_static_path from sphinx.builders.html import validate_html_extra_path, validate_html_static_path
from sphinx.deprecation import RemovedInSphinx90Warning
from sphinx.errors import ConfigError from sphinx.errors import ConfigError
from sphinx.util.console import strip_colors from sphinx.util.console import strip_colors
from sphinx.util.inventory import InventoryFile from sphinx.util.inventory import InventoryFile
@ -329,6 +330,7 @@ def test_validate_html_extra_path(app):
app.outdir, # outdir app.outdir, # outdir
app.outdir / '_static', # inside outdir app.outdir / '_static', # inside outdir
] ]
with pytest.warns(RemovedInSphinx90Warning, match='Use "pathlib.Path" or "os.fspath" instead'):
validate_html_extra_path(app, app.config) validate_html_extra_path(app, app.config)
assert app.config.html_extra_path == ['_static'] assert app.config.html_extra_path == ['_static']
@ -342,6 +344,7 @@ def test_validate_html_static_path(app):
app.outdir, # outdir app.outdir, # outdir
app.outdir / '_static', # inside outdir app.outdir / '_static', # inside outdir
] ]
with pytest.warns(RemovedInSphinx90Warning, match='Use "pathlib.Path" or "os.fspath" instead'):
validate_html_static_path(app, app.config) validate_html_static_path(app, app.config)
assert app.config.html_static_path == ['_static'] assert app.config.html_static_path == ['_static']

View File

@ -10,7 +10,6 @@ import time
import wsgiref.handlers import wsgiref.handlers
from base64 import b64encode from base64 import b64encode
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from pathlib import Path
from queue import Queue from queue import Queue
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest import mock from unittest import mock
@ -29,6 +28,7 @@ from sphinx.builders.linkcheck import (
compile_linkcheck_allowed_redirects, compile_linkcheck_allowed_redirects,
) )
from sphinx.util import requests from sphinx.util import requests
from sphinx.util._pathlib import _StrPath
from sphinx.util.console import strip_colors from sphinx.util.console import strip_colors
from tests.utils import CERT_FILE, serve_application from tests.utils import CERT_FILE, serve_application
@ -1061,7 +1061,7 @@ def test_connection_contention(get_adapter, app, capsys):
wqueue: Queue[CheckRequest] = Queue() wqueue: Queue[CheckRequest] = Queue()
rqueue: Queue[CheckResult] = Queue() rqueue: Queue[CheckResult] = Queue()
for _ in range(link_count): for _ in range(link_count):
wqueue.put(CheckRequest(0, Hyperlink(f"http://{address}", "test", Path("test.rst"), 1))) wqueue.put(CheckRequest(0, Hyperlink(f"http://{address}", "test", _StrPath("test.rst"), 1)))
begin = time.time() begin = time.time()
checked: list[CheckResult] = [] checked: list[CheckResult] = []