Skip to content

Commit fc651fb

Browse files
authored
Merge pull request #8251 from RonnyPfannschmidt/pathlib-node-path
2 parents 620e819 + 77cb110 commit fc651fb

30 files changed

+320
-196
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,9 @@ repos:
8787
xml\.
8888
)
8989
types: [python]
90+
- id: py-path-deprecated
91+
name: py.path usage is deprecated
92+
language: pygrep
93+
entry: \bpy\.path\.local
94+
exclude: docs
95+
types: [python]

changelog/8251.deprecation.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Deprecate ``Node.fspath`` as we plan to move off `py.path.local <https://py.readthedocs.io/en/latest/path.html>`__ and switch to :mod:``pathlib``.

changelog/8251.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement ``Node.path`` as a ``pathlib.Path``.

doc/en/deprecations.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ Below is a complete list of all pytest features which are considered deprecated.
1919
:class:`PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
2020

2121

22+
``Node.fspath`` in favor of ``pathlib`` and ``Node.path``
23+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
24+
25+
.. deprecated:: 6.3
26+
27+
As pytest tries to move off `py.path.local <https://py.readthedocs.io/en/latest/path.html>`__ we ported most of the node internals to :mod:`pathlib`.
28+
29+
Pytest will provide compatibility for quite a while.
30+
31+
2232
Backward compatibilities in ``Parser.addoption``
2333
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2434

src/_pytest/_code/code.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131

3232
import attr
3333
import pluggy
34-
import py
3534

3635
import _pytest
3736
from _pytest._code.source import findsource
@@ -1230,7 +1229,7 @@ def getfslineno(obj: object) -> Tuple[Union[str, Path], int]:
12301229
if _PLUGGY_DIR.name == "__init__.py":
12311230
_PLUGGY_DIR = _PLUGGY_DIR.parent
12321231
_PYTEST_DIR = Path(_pytest.__file__).parent
1233-
_PY_DIR = Path(py.__file__).parent
1232+
_PY_DIR = Path(__import__("py").__file__).parent
12341233

12351234

12361235
def filter_traceback(entry: TracebackEntry) -> bool:

src/_pytest/cacheprovider.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
from typing import Union
1414

1515
import attr
16-
import py
1716

1817
from .pathlib import resolve_from_str
1918
from .pathlib import rm_rf
2019
from .reports import CollectReport
2120
from _pytest import nodes
2221
from _pytest._io import TerminalWriter
2322
from _pytest.compat import final
23+
from _pytest.compat import LEGACY_PATH
24+
from _pytest.compat import legacy_path
2425
from _pytest.config import Config
2526
from _pytest.config import ExitCode
2627
from _pytest.config import hookimpl
@@ -120,7 +121,7 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None:
120121
stacklevel=3,
121122
)
122123

123-
def makedir(self, name: str) -> py.path.local:
124+
def makedir(self, name: str) -> LEGACY_PATH:
124125
"""Return a directory path object with the given name.
125126
126127
If the directory does not yet exist, it will be created. You can use
@@ -137,7 +138,7 @@ def makedir(self, name: str) -> py.path.local:
137138
raise ValueError("name is not allowed to contain path separators")
138139
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
139140
res.mkdir(exist_ok=True, parents=True)
140-
return py.path.local(res)
141+
return legacy_path(res)
141142

142143
def _getvaluepath(self, key: str) -> Path:
143144
return self._cachedir.joinpath(self._CACHE_PREFIX_VALUES, Path(key))
@@ -218,14 +219,17 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
218219

219220
# Sort any lf-paths to the beginning.
220221
lf_paths = self.lfplugin._last_failed_paths
222+
221223
res.result = sorted(
222224
res.result,
223-
key=lambda x: 0 if Path(str(x.fspath)) in lf_paths else 1,
225+
# use stable sort to priorize last failed
226+
key=lambda x: x.path in lf_paths,
227+
reverse=True,
224228
)
225229
return
226230

227231
elif isinstance(collector, Module):
228-
if Path(str(collector.fspath)) in self.lfplugin._last_failed_paths:
232+
if collector.path in self.lfplugin._last_failed_paths:
229233
out = yield
230234
res = out.get_result()
231235
result = res.result
@@ -246,7 +250,7 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
246250
for x in result
247251
if x.nodeid in lastfailed
248252
# Include any passed arguments (not trivial to filter).
249-
or session.isinitpath(x.fspath)
253+
or session.isinitpath(x.path)
250254
# Keep all sub-collectors.
251255
or isinstance(x, nodes.Collector)
252256
]
@@ -266,7 +270,7 @@ def pytest_make_collect_report(
266270
# test-bearing paths and doesn't try to include the paths of their
267271
# packages, so don't filter them.
268272
if isinstance(collector, Module) and not isinstance(collector, Package):
269-
if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths:
273+
if collector.path not in self.lfplugin._last_failed_paths:
270274
self.lfplugin._skipped_files += 1
271275

272276
return CollectReport(
@@ -415,7 +419,7 @@ def pytest_collection_modifyitems(
415419
self.cached_nodeids.update(item.nodeid for item in items)
416420

417421
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]:
418-
return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True)
422+
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return]
419423

420424
def pytest_sessionfinish(self) -> None:
421425
config = self.config

src/_pytest/compat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import enum
33
import functools
44
import inspect
5+
import os
56
import re
67
import sys
78
from contextlib import contextmanager
@@ -18,6 +19,7 @@
1819
from typing import Union
1920

2021
import attr
22+
import py
2123

2224
from _pytest.outcomes import fail
2325
from _pytest.outcomes import TEST_OUTCOME
@@ -30,6 +32,19 @@
3032
_T = TypeVar("_T")
3133
_S = TypeVar("_S")
3234

35+
#: constant to prepare valuing pylib path replacements/lazy proxies later on
36+
# intended for removal in pytest 8.0 or 9.0
37+
38+
# fmt: off
39+
# intentional space to create a fake difference for the verification
40+
LEGACY_PATH = py.path. local
41+
# fmt: on
42+
43+
44+
def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH:
45+
"""Internal wrapper to prepare lazy proxies for legacy_path instances"""
46+
return LEGACY_PATH(path)
47+
3348

3449
# fmt: off
3550
# Singleton type for NOTSET, as described in:

src/_pytest/config/__init__.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from typing import Union
3333

3434
import attr
35-
import py
3635
from pluggy import HookimplMarker
3736
from pluggy import HookspecMarker
3837
from pluggy import PluginManager
@@ -48,6 +47,8 @@
4847
from _pytest._io import TerminalWriter
4948
from _pytest.compat import final
5049
from _pytest.compat import importlib_metadata
50+
from _pytest.compat import LEGACY_PATH
51+
from _pytest.compat import legacy_path
5152
from _pytest.outcomes import fail
5253
from _pytest.outcomes import Skipped
5354
from _pytest.pathlib import absolutepath
@@ -937,15 +938,15 @@ def __init__(
937938
self.cache: Optional[Cache] = None
938939

939940
@property
940-
def invocation_dir(self) -> py.path.local:
941+
def invocation_dir(self) -> LEGACY_PATH:
941942
"""The directory from which pytest was invoked.
942943
943944
Prefer to use :attr:`invocation_params.dir <InvocationParams.dir>`,
944945
which is a :class:`pathlib.Path`.
945946
946-
:type: py.path.local
947+
:type: LEGACY_PATH
947948
"""
948-
return py.path.local(str(self.invocation_params.dir))
949+
return legacy_path(str(self.invocation_params.dir))
949950

950951
@property
951952
def rootpath(self) -> Path:
@@ -958,14 +959,14 @@ def rootpath(self) -> Path:
958959
return self._rootpath
959960

960961
@property
961-
def rootdir(self) -> py.path.local:
962+
def rootdir(self) -> LEGACY_PATH:
962963
"""The path to the :ref:`rootdir <rootdir>`.
963964
964965
Prefer to use :attr:`rootpath`, which is a :class:`pathlib.Path`.
965966
966-
:type: py.path.local
967+
:type: LEGACY_PATH
967968
"""
968-
return py.path.local(str(self.rootpath))
969+
return legacy_path(str(self.rootpath))
969970

970971
@property
971972
def inipath(self) -> Optional[Path]:
@@ -978,14 +979,14 @@ def inipath(self) -> Optional[Path]:
978979
return self._inipath
979980

980981
@property
981-
def inifile(self) -> Optional[py.path.local]:
982+
def inifile(self) -> Optional[LEGACY_PATH]:
982983
"""The path to the :ref:`configfile <configfiles>`.
983984
984985
Prefer to use :attr:`inipath`, which is a :class:`pathlib.Path`.
985986
986-
:type: Optional[py.path.local]
987+
:type: Optional[LEGACY_PATH]
987988
"""
988-
return py.path.local(str(self.inipath)) if self.inipath else None
989+
return legacy_path(str(self.inipath)) if self.inipath else None
989990

990991
def add_cleanup(self, func: Callable[[], None]) -> None:
991992
"""Add a function to be called when the config object gets out of
@@ -1420,7 +1421,7 @@ def _getini(self, name: str):
14201421
assert self.inipath is not None
14211422
dp = self.inipath.parent
14221423
input_values = shlex.split(value) if isinstance(value, str) else value
1423-
return [py.path.local(str(dp / x)) for x in input_values]
1424+
return [legacy_path(str(dp / x)) for x in input_values]
14241425
elif type == "args":
14251426
return shlex.split(value) if isinstance(value, str) else value
14261427
elif type == "linelist":
@@ -1446,7 +1447,7 @@ def _getconftest_pathlist(self, name: str, path: Path) -> Optional[List[Path]]:
14461447
for relroot in relroots:
14471448
if isinstance(relroot, Path):
14481449
pass
1449-
elif isinstance(relroot, py.path.local):
1450+
elif isinstance(relroot, LEGACY_PATH):
14501451
relroot = Path(relroot)
14511452
else:
14521453
relroot = relroot.replace("/", os.sep)

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@
8989
)
9090

9191

92+
NODE_FSPATH = UnformattedWarning(
93+
PytestDeprecationWarning,
94+
"{type}.fspath is deprecated and will be replaced by {type}.path.\n"
95+
"see https://docs.pytest.org/en/latest/deprecations.html#node-fspath-in-favor-of-pathlib-and-node-path",
96+
)
97+
9298
# You want to make some `__init__` or function "private".
9399
#
94100
# def my_private_function(some, args):
@@ -106,6 +112,8 @@
106112
#
107113
# All other calls will get the default _ispytest=False and trigger
108114
# the warning (possibly error in the future).
115+
116+
109117
def check_ispytest(ispytest: bool) -> None:
110118
if not ispytest:
111119
warn(PRIVATE, stacklevel=3)

src/_pytest/doctest.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
from typing import TYPE_CHECKING
2323
from typing import Union
2424

25-
import py.path
26-
2725
import pytest
2826
from _pytest import outcomes
2927
from _pytest._code.code import ExceptionInfo
3028
from _pytest._code.code import ReprFileLocation
3129
from _pytest._code.code import TerminalRepr
3230
from _pytest._io import TerminalWriter
31+
from _pytest.compat import LEGACY_PATH
32+
from _pytest.compat import legacy_path
3333
from _pytest.compat import safe_getattr
3434
from _pytest.config import Config
3535
from _pytest.config.argparsing import Parser
@@ -122,16 +122,16 @@ def pytest_unconfigure() -> None:
122122

123123
def pytest_collect_file(
124124
fspath: Path,
125-
path: py.path.local,
125+
path: LEGACY_PATH,
126126
parent: Collector,
127127
) -> Optional[Union["DoctestModule", "DoctestTextfile"]]:
128128
config = parent.config
129129
if fspath.suffix == ".py":
130130
if config.option.doctestmodules and not _is_setup_py(fspath):
131-
mod: DoctestModule = DoctestModule.from_parent(parent, fspath=path)
131+
mod: DoctestModule = DoctestModule.from_parent(parent, path=fspath)
132132
return mod
133133
elif _is_doctest(config, fspath, parent):
134-
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, fspath=path)
134+
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, path=fspath)
135135
return txt
136136
return None
137137

@@ -378,7 +378,7 @@ def repr_failure( # type: ignore[override]
378378

379379
def reportinfo(self):
380380
assert self.dtest is not None
381-
return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name
381+
return legacy_path(self.path), self.dtest.lineno, "[doctest] %s" % self.name
382382

383383

384384
def _get_flag_lookup() -> Dict[str, int]:
@@ -425,9 +425,9 @@ def collect(self) -> Iterable[DoctestItem]:
425425
# Inspired by doctest.testfile; ideally we would use it directly,
426426
# but it doesn't support passing a custom checker.
427427
encoding = self.config.getini("doctest_encoding")
428-
text = self.fspath.read_text(encoding)
429-
filename = str(self.fspath)
430-
name = self.fspath.basename
428+
text = self.path.read_text(encoding)
429+
filename = str(self.path)
430+
name = self.path.name
431431
globs = {"__name__": "__main__"}
432432

433433
optionflags = get_optionflags(self)
@@ -534,16 +534,16 @@ def _find(
534534
self, tests, obj, name, module, source_lines, globs, seen
535535
)
536536

537-
if self.fspath.basename == "conftest.py":
537+
if self.path.name == "conftest.py":
538538
module = self.config.pluginmanager._importconftest(
539-
Path(self.fspath), self.config.getoption("importmode")
539+
self.path, self.config.getoption("importmode")
540540
)
541541
else:
542542
try:
543-
module = import_path(self.fspath)
543+
module = import_path(self.path)
544544
except ImportError:
545545
if self.config.getvalue("doctest_ignore_import_errors"):
546-
pytest.skip("unable to import module %r" % self.fspath)
546+
pytest.skip("unable to import module %r" % self.path)
547547
else:
548548
raise
549549
# Uses internal doctest module parsing mechanism.

0 commit comments

Comments
 (0)