Skip to content

Commit

Permalink
Merge pull request #8251 from RonnyPfannschmidt/pathlib-node-path
Browse files Browse the repository at this point in the history
  • Loading branch information
nicoddemus committed Mar 7, 2021
2 parents 620e819 + 77cb110 commit fc651fb
Show file tree
Hide file tree
Showing 30 changed files with 320 additions and 196 deletions.
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Expand Up @@ -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
@@ -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
@@ -0,0 +1 @@
Implement ``Node.path`` as a ``pathlib.Path``.
10 changes: 10 additions & 0 deletions doc/en/deprecations.rst
Expand Up @@ -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``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
3 changes: 1 addition & 2 deletions src/_pytest/_code/code.py
Expand Up @@ -31,7 +31,6 @@

import attr
import pluggy
import py

import _pytest
from _pytest._code.source import findsource
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 12 additions & 8 deletions src/_pytest/cacheprovider.py
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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)
]
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/_pytest/compat.py
Expand Up @@ -2,6 +2,7 @@
import enum
import functools
import inspect
import os
import re
import sys
from contextlib import contextmanager
Expand All @@ -18,6 +19,7 @@
from typing import Union

import attr
import py

from _pytest.outcomes import fail
from _pytest.outcomes import TEST_OUTCOME
Expand All @@ -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:
Expand Down
25 changes: 13 additions & 12 deletions src/_pytest/config/__init__.py
Expand Up @@ -32,7 +32,6 @@
from typing import Union

import attr
import py
from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluginManager
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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]:
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/_pytest/deprecated.py
Expand Up @@ -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):
Expand All @@ -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
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit fc651fb

Please sign in to comment.