Skip to content

implement Node.path as pathlib.Path #8251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 7, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -87,3 +87,9 @@ repos:
xml\.
)
types: [python]
- id: py-path-deprecated
name: py.path usage is deprecated
language: pygrep
entry: \bpy\.path\.local
exclude: docs
types: [python]
1 change: 1 addition & 0 deletions changelog/8251.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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``.
1 change: 1 addition & 0 deletions changelog/8251.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement ``Node.path`` as a ``pathlib.Path``.
10 changes: 10 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
@@ -19,6 +19,16 @@ Below is a complete list of all pytest features which are considered deprecated.
:class:`PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.


``Node.fspath`` in favor of ``pathlib`` and ``Node.path``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. deprecated:: 6.3

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`.

Pytest will provide compatibility for quite a while.


Backward compatibilities in ``Parser.addoption``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

3 changes: 1 addition & 2 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
@@ -31,7 +31,6 @@

import attr
import pluggy
import py

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


def filter_traceback(entry: TracebackEntry) -> bool:
20 changes: 12 additions & 8 deletions src/_pytest/cacheprovider.py
Original file line number Diff line number Diff line change
@@ -13,14 +13,15 @@
from typing import Union

import attr
import py

from .pathlib import resolve_from_str
from .pathlib import rm_rf
from .reports import CollectReport
from _pytest import nodes
from _pytest._io import TerminalWriter
from _pytest.compat import final
from _pytest.compat import LEGACY_PATH
from _pytest.compat import legacy_path
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl
@@ -120,7 +121,7 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None:
stacklevel=3,
)

def makedir(self, name: str) -> py.path.local:
def makedir(self, name: str) -> LEGACY_PATH:
"""Return a directory path object with the given name.

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:
raise ValueError("name is not allowed to contain path separators")
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
res.mkdir(exist_ok=True, parents=True)
return py.path.local(res)
return legacy_path(res)

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

# Sort any lf-paths to the beginning.
lf_paths = self.lfplugin._last_failed_paths

res.result = sorted(
res.result,
key=lambda x: 0 if Path(str(x.fspath)) in lf_paths else 1,
# use stable sort to priorize last failed
key=lambda x: x.path in lf_paths,
reverse=True,
)
return

elif isinstance(collector, Module):
if Path(str(collector.fspath)) in self.lfplugin._last_failed_paths:
if collector.path in self.lfplugin._last_failed_paths:
out = yield
res = out.get_result()
result = res.result
@@ -246,7 +250,7 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
for x in result
if x.nodeid in lastfailed
# Include any passed arguments (not trivial to filter).
or session.isinitpath(x.fspath)
or session.isinitpath(x.path)
# Keep all sub-collectors.
or isinstance(x, nodes.Collector)
]
@@ -266,7 +270,7 @@ def pytest_make_collect_report(
# test-bearing paths and doesn't try to include the paths of their
# packages, so don't filter them.
if isinstance(collector, Module) and not isinstance(collector, Package):
if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths:
if collector.path not in self.lfplugin._last_failed_paths:
self.lfplugin._skipped_files += 1

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

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

def pytest_sessionfinish(self) -> None:
config = self.config
15 changes: 15 additions & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
import enum
import functools
import inspect
import os
import re
import sys
from contextlib import contextmanager
@@ -18,6 +19,7 @@
from typing import Union

import attr
import py

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

#: constant to prepare valuing pylib path replacements/lazy proxies later on
# intended for removal in pytest 8.0 or 9.0

# fmt: off
# intentional space to create a fake difference for the verification
LEGACY_PATH = py.path. local
# fmt: on


def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH:
"""Internal wrapper to prepare lazy proxies for legacy_path instances"""
return LEGACY_PATH(path)


# fmt: off
# Singleton type for NOTSET, as described in:
25 changes: 13 additions & 12 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
@@ -32,7 +32,6 @@
from typing import Union

import attr
import py
from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluginManager
@@ -48,6 +47,8 @@
from _pytest._io import TerminalWriter
from _pytest.compat import final
from _pytest.compat import importlib_metadata
from _pytest.compat import LEGACY_PATH
from _pytest.compat import legacy_path
from _pytest.outcomes import fail
from _pytest.outcomes import Skipped
from _pytest.pathlib import absolutepath
@@ -937,15 +938,15 @@ def __init__(
self.cache: Optional[Cache] = None

@property
def invocation_dir(self) -> py.path.local:
def invocation_dir(self) -> LEGACY_PATH:
"""The directory from which pytest was invoked.

Prefer to use :attr:`invocation_params.dir <InvocationParams.dir>`,
which is a :class:`pathlib.Path`.

:type: py.path.local
:type: LEGACY_PATH
"""
return py.path.local(str(self.invocation_params.dir))
return legacy_path(str(self.invocation_params.dir))

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

@property
def rootdir(self) -> py.path.local:
def rootdir(self) -> LEGACY_PATH:
"""The path to the :ref:`rootdir <rootdir>`.

Prefer to use :attr:`rootpath`, which is a :class:`pathlib.Path`.

:type: py.path.local
:type: LEGACY_PATH
"""
return py.path.local(str(self.rootpath))
return legacy_path(str(self.rootpath))

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

@property
def inifile(self) -> Optional[py.path.local]:
def inifile(self) -> Optional[LEGACY_PATH]:
"""The path to the :ref:`configfile <configfiles>`.

Prefer to use :attr:`inipath`, which is a :class:`pathlib.Path`.

:type: Optional[py.path.local]
:type: Optional[LEGACY_PATH]
"""
return py.path.local(str(self.inipath)) if self.inipath else None
return legacy_path(str(self.inipath)) if self.inipath else None

def add_cleanup(self, func: Callable[[], None]) -> None:
"""Add a function to be called when the config object gets out of
@@ -1420,7 +1421,7 @@ def _getini(self, name: str):
assert self.inipath is not None
dp = self.inipath.parent
input_values = shlex.split(value) if isinstance(value, str) else value
return [py.path.local(str(dp / x)) for x in input_values]
return [legacy_path(str(dp / x)) for x in input_values]
elif type == "args":
return shlex.split(value) if isinstance(value, str) else value
elif type == "linelist":
@@ -1446,7 +1447,7 @@ def _getconftest_pathlist(self, name: str, path: Path) -> Optional[List[Path]]:
for relroot in relroots:
if isinstance(relroot, Path):
pass
elif isinstance(relroot, py.path.local):
elif isinstance(relroot, LEGACY_PATH):
relroot = Path(relroot)
else:
relroot = relroot.replace("/", os.sep)
8 changes: 8 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
@@ -89,6 +89,12 @@
)


NODE_FSPATH = UnformattedWarning(
PytestDeprecationWarning,
"{type}.fspath is deprecated and will be replaced by {type}.path.\n"
"see https://docs.pytest.org/en/latest/deprecations.html#node-fspath-in-favor-of-pathlib-and-node-path",
)

# You want to make some `__init__` or function "private".
#
# def my_private_function(some, args):
@@ -106,6 +112,8 @@
#
# All other calls will get the default _ispytest=False and trigger
# the warning (possibly error in the future).


def check_ispytest(ispytest: bool) -> None:
if not ispytest:
warn(PRIVATE, stacklevel=3)
26 changes: 13 additions & 13 deletions src/_pytest/doctest.py
Original file line number Diff line number Diff line change
@@ -22,14 +22,14 @@
from typing import TYPE_CHECKING
from typing import Union

import py.path

import pytest
from _pytest import outcomes
from _pytest._code.code import ExceptionInfo
from _pytest._code.code import ReprFileLocation
from _pytest._code.code import TerminalRepr
from _pytest._io import TerminalWriter
from _pytest.compat import LEGACY_PATH
from _pytest.compat import legacy_path
from _pytest.compat import safe_getattr
from _pytest.config import Config
from _pytest.config.argparsing import Parser
@@ -122,16 +122,16 @@ def pytest_unconfigure() -> None:

def pytest_collect_file(
fspath: Path,
path: py.path.local,
path: LEGACY_PATH,
parent: Collector,
) -> Optional[Union["DoctestModule", "DoctestTextfile"]]:
config = parent.config
if fspath.suffix == ".py":
if config.option.doctestmodules and not _is_setup_py(fspath):
mod: DoctestModule = DoctestModule.from_parent(parent, fspath=path)
mod: DoctestModule = DoctestModule.from_parent(parent, path=fspath)
return mod
elif _is_doctest(config, fspath, parent):
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, fspath=path)
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, path=fspath)
return txt
return None

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

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


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

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

if self.fspath.basename == "conftest.py":
if self.path.name == "conftest.py":
module = self.config.pluginmanager._importconftest(
Path(self.fspath), self.config.getoption("importmode")
self.path, self.config.getoption("importmode")
)
else:
try:
module = import_path(self.fspath)
module = import_path(self.path)
except ImportError:
if self.config.getvalue("doctest_ignore_import_errors"):
pytest.skip("unable to import module %r" % self.fspath)
pytest.skip("unable to import module %r" % self.path)
else:
raise
# Uses internal doctest module parsing mechanism.
Loading