Skip to content

Commit 22dad53

Browse files
implement Node.path as pathlib.Path
* reorganize lastfailed node sort Co-authored-by: Bruno Oliveira <[email protected]>
1 parent 19a2f74 commit 22dad53

19 files changed

+194
-77
lines changed

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/cacheprovider.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,17 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
218218

219219
# Sort any lf-paths to the beginning.
220220
lf_paths = self.lfplugin._last_failed_paths
221+
221222
res.result = sorted(
222223
res.result,
223-
key=lambda x: 0 if Path(str(x.fspath)) in lf_paths else 1,
224+
# use stable sort to priorize last failed
225+
key=lambda x: x.path in lf_paths,
226+
reverse=True,
224227
)
225228
return
226229

227230
elif isinstance(collector, Module):
228-
if Path(str(collector.fspath)) in self.lfplugin._last_failed_paths:
231+
if collector.path in self.lfplugin._last_failed_paths:
229232
out = yield
230233
res = out.get_result()
231234
result = res.result
@@ -246,7 +249,7 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
246249
for x in result
247250
if x.nodeid in lastfailed
248251
# Include any passed arguments (not trivial to filter).
249-
or session.isinitpath(x.fspath)
252+
or session.isinitpath(x.path)
250253
# Keep all sub-collectors.
251254
or isinstance(x, nodes.Collector)
252255
]
@@ -266,7 +269,7 @@ def pytest_make_collect_report(
266269
# test-bearing paths and doesn't try to include the paths of their
267270
# packages, so don't filter them.
268271
if isinstance(collector, Module) and not isinstance(collector, Package):
269-
if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths:
272+
if collector.path not in self.lfplugin._last_failed_paths:
270273
self.lfplugin._skipped_files += 1
271274

272275
return CollectReport(
@@ -415,7 +418,7 @@ def pytest_collection_modifyitems(
415418
self.cached_nodeids.update(item.nodeid for item in items)
416419

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

420423
def pytest_sessionfinish(self) -> None:
421424
config = self.config

src/_pytest/compat.py

Lines changed: 12 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,16 @@
3032
_T = TypeVar("_T")
3133
_S = TypeVar("_S")
3234

35+
#: constant to prepare valuing py.path.local replacements/lazy proxies later on
36+
# intended for removal in pytest 8.0 or 9.0
37+
38+
LEGACY_PATH = py.path.local
39+
40+
41+
def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH:
42+
"""Internal wrapper to prepare lazy proxies for py.path.local instances"""
43+
return py.path.local(path)
44+
3345

3446
# fmt: off
3547
# Singleton type for NOTSET, as described in:

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 TODO;URL for details on replacing py.path.local with pathlib.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: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from _pytest._code.code import ReprFileLocation
3131
from _pytest._code.code import TerminalRepr
3232
from _pytest._io import TerminalWriter
33+
from _pytest.compat import legacy_path
3334
from _pytest.compat import safe_getattr
3435
from _pytest.config import Config
3536
from _pytest.config.argparsing import Parser
@@ -128,10 +129,10 @@ def pytest_collect_file(
128129
config = parent.config
129130
if fspath.suffix == ".py":
130131
if config.option.doctestmodules and not _is_setup_py(fspath):
131-
mod: DoctestModule = DoctestModule.from_parent(parent, fspath=path)
132+
mod: DoctestModule = DoctestModule.from_parent(parent, path=fspath)
132133
return mod
133134
elif _is_doctest(config, fspath, parent):
134-
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, fspath=path)
135+
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, path=fspath)
135136
return txt
136137
return None
137138

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

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

383384

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

433434
optionflags = get_optionflags(self)
@@ -534,16 +535,16 @@ def _find(
534535
self, tests, obj, name, module, source_lines, globs, seen
535536
)
536537

537-
if self.fspath.basename == "conftest.py":
538+
if self.path.name == "conftest.py":
538539
module = self.config.pluginmanager._importconftest(
539-
Path(self.fspath), self.config.getoption("importmode")
540+
self.path, self.config.getoption("importmode")
540541
)
541542
else:
542543
try:
543-
module = import_path(self.fspath)
544+
module = import_path(self.path)
544545
except ImportError:
545546
if self.config.getvalue("doctest_ignore_import_errors"):
546-
pytest.skip("unable to import module %r" % self.fspath)
547+
pytest.skip("unable to import module %r" % self.path)
547548
else:
548549
raise
549550
# Uses internal doctest module parsing mechanism.

src/_pytest/fixtures.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from typing import Union
2929

3030
import attr
31-
import py
3231

3332
import _pytest
3433
from _pytest import nodes
@@ -46,13 +45,16 @@
4645
from _pytest.compat import getimfunc
4746
from _pytest.compat import getlocation
4847
from _pytest.compat import is_generator
48+
from _pytest.compat import LEGACY_PATH
49+
from _pytest.compat import legacy_path
4950
from _pytest.compat import NOTSET
5051
from _pytest.compat import safe_getattr
5152
from _pytest.config import _PluggyPlugin
5253
from _pytest.config import Config
5354
from _pytest.config.argparsing import Parser
5455
from _pytest.deprecated import check_ispytest
5556
from _pytest.deprecated import FILLFUNCARGS
57+
from _pytest.deprecated import NODE_FSPATH
5658
from _pytest.deprecated import YIELD_FIXTURE
5759
from _pytest.mark import Mark
5860
from _pytest.mark import ParameterSet
@@ -256,12 +258,12 @@ def get_parametrized_fixture_keys(item: nodes.Item, scopenum: int) -> Iterator[_
256258
if scopenum == 0: # session
257259
key: _Key = (argname, param_index)
258260
elif scopenum == 1: # package
259-
key = (argname, param_index, item.fspath.dirpath())
261+
key = (argname, param_index, item.path.parent)
260262
elif scopenum == 2: # module
261-
key = (argname, param_index, item.fspath)
263+
key = (argname, param_index, item.path)
262264
elif scopenum == 3: # class
263265
item_cls = item.cls # type: ignore[attr-defined]
264-
key = (argname, param_index, item.fspath, item_cls)
266+
key = (argname, param_index, item.path, item_cls)
265267
yield key
266268

267269

@@ -519,12 +521,17 @@ def module(self):
519521
return self._pyfuncitem.getparent(_pytest.python.Module).obj
520522

521523
@property
522-
def fspath(self) -> py.path.local:
523-
"""The file system path of the test module which collected this test."""
524+
def fspath(self) -> LEGACY_PATH:
525+
"""(deprecated) The file system path of the test module which collected this test."""
526+
warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2)
527+
return legacy_path(self.path)
528+
529+
@property
530+
def path(self) -> Path:
524531
if self.scope not in ("function", "class", "module", "package"):
525532
raise AttributeError(f"module not available in {self.scope}-scoped context")
526533
# TODO: Remove ignore once _pyfuncitem is properly typed.
527-
return self._pyfuncitem.fspath # type: ignore
534+
return self._pyfuncitem.path # type: ignore
528535

529536
@property
530537
def keywords(self) -> MutableMapping[str, Any]:
@@ -1040,7 +1047,7 @@ def finish(self, request: SubRequest) -> None:
10401047
if exc:
10411048
raise exc
10421049
finally:
1043-
hook = self._fixturemanager.session.gethookproxy(request.node.fspath)
1050+
hook = self._fixturemanager.session.gethookproxy(request.node.path)
10441051
hook.pytest_fixture_post_finalizer(fixturedef=self, request=request)
10451052
# Even if finalization fails, we invalidate the cached fixture
10461053
# value and remove all finalizers because they may be bound methods
@@ -1075,7 +1082,7 @@ def execute(self, request: SubRequest) -> _FixtureValue:
10751082
self.finish(request)
10761083
assert self.cached_result is None
10771084

1078-
hook = self._fixturemanager.session.gethookproxy(request.node.fspath)
1085+
hook = self._fixturemanager.session.gethookproxy(request.node.path)
10791086
result = hook.pytest_fixture_setup(fixturedef=self, request=request)
10801087
return result
10811088

@@ -1623,6 +1630,11 @@ def parsefactories(
16231630
self._holderobjseen.add(holderobj)
16241631
autousenames = []
16251632
for name in dir(holderobj):
1633+
# ugly workaround for one of the fspath deprecated property of node
1634+
# todo: safely generalize
1635+
if isinstance(holderobj, nodes.Node) and name == "fspath":
1636+
continue
1637+
16261638
# The attribute can be an arbitrary descriptor, so the attribute
16271639
# access below can raise. safe_getatt() ignores such exceptions.
16281640
obj = safe_getattr(holderobj, name, None)

src/_pytest/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,12 @@ class Session(nodes.FSCollector):
464464

465465
def __init__(self, config: Config) -> None:
466466
super().__init__(
467-
config.rootdir, parent=None, config=config, session=self, nodeid=""
467+
path=config.rootpath,
468+
fspath=config.rootdir,
469+
parent=None,
470+
config=config,
471+
session=self,
472+
nodeid="",
468473
)
469474
self.testsfailed = 0
470475
self.testscollected = 0
@@ -688,7 +693,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
688693
if col:
689694
if isinstance(col[0], Package):
690695
pkg_roots[str(parent)] = col[0]
691-
node_cache1[Path(col[0].fspath)] = [col[0]]
696+
node_cache1[col[0].path] = [col[0]]
692697

693698
# If it's a directory argument, recurse and look for any Subpackages.
694699
# Let the Package collector deal with subnodes, don't collect here.
@@ -717,7 +722,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
717722
continue
718723

719724
for x in self._collectfile(path):
720-
key2 = (type(x), Path(x.fspath))
725+
key2 = (type(x), x.path)
721726
if key2 in node_cache2:
722727
yield node_cache2[key2]
723728
else:

0 commit comments

Comments
 (0)