From 22dad53a248f50f50b5e000d63a8d3c798868d98 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 17 Jan 2021 21:20:29 +0100 Subject: [PATCH 1/2] implement Node.path as pathlib.Path * reorganize lastfailed node sort Co-authored-by: Bruno Oliveira --- changelog/8251.deprecation.rst | 1 + changelog/8251.feature.rst | 1 + doc/en/deprecations.rst | 10 +++ src/_pytest/cacheprovider.py | 13 ++-- src/_pytest/compat.py | 12 ++++ src/_pytest/deprecated.py | 8 +++ src/_pytest/doctest.py | 21 ++++--- src/_pytest/fixtures.py | 30 ++++++--- src/_pytest/main.py | 11 +++- src/_pytest/nodes.py | 85 ++++++++++++++++++++------ src/_pytest/pytester.py | 5 +- src/_pytest/python.py | 22 ++++--- testing/plugins_integration/pytest.ini | 1 + testing/python/collect.py | 6 +- testing/python/fixtures.py | 4 +- testing/test_collection.py | 32 +++++----- testing/test_mark.py | 3 +- testing/test_runner.py | 4 +- testing/test_terminal.py | 2 +- 19 files changed, 194 insertions(+), 77 deletions(-) create mode 100644 changelog/8251.deprecation.rst create mode 100644 changelog/8251.feature.rst diff --git a/changelog/8251.deprecation.rst b/changelog/8251.deprecation.rst new file mode 100644 index 00000000000..1d988bfc83b --- /dev/null +++ b/changelog/8251.deprecation.rst @@ -0,0 +1 @@ +Deprecate ``Node.fspath`` as we plan to move off `py.path.local `__ and switch to :mod:``pathlib``. diff --git a/changelog/8251.feature.rst b/changelog/8251.feature.rst new file mode 100644 index 00000000000..49aede797a0 --- /dev/null +++ b/changelog/8251.feature.rst @@ -0,0 +1 @@ +Implement ``Node.path`` as a ``pathlib.Path``. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index a3d7fd49a33..6ecb37b385a 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -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 `. +``Node.fspath`` in favor of ``pathlib`` and ``Node.path`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 6.3 + +As pytest tries to move off `py.path.local `__ we ported most of the node internals to :mod:`pathlib`. + +Pytest will provide compatibility for quite a while. + + Backward compatibilities in ``Parser.addoption`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 585cebf6c9d..03e20bea18c 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -218,14 +218,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 +249,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 +269,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 +418,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 diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index b354fcb3f63..b9cbf85e04f 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -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,16 @@ _T = TypeVar("_T") _S = TypeVar("_S") +#: constant to prepare valuing py.path.local replacements/lazy proxies later on +# intended for removal in pytest 8.0 or 9.0 + +LEGACY_PATH = py.path.local + + +def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH: + """Internal wrapper to prepare lazy proxies for py.path.local instances""" + return py.path.local(path) + # fmt: off # Singleton type for NOTSET, as described in: diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 5efc004ac94..c203eadc1ad 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -89,6 +89,12 @@ ) +NODE_FSPATH = UnformattedWarning( + PytestDeprecationWarning, + "{type}.fspath is deprecated and will be replaced by {type}.path.\n" + "see TODO;URL for details on replacing py.path.local with pathlib.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) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 255ca80b913..4942a8f793b 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -30,6 +30,7 @@ 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 safe_getattr from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -128,10 +129,10 @@ def pytest_collect_file( 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 +379,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 +426,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 +535,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. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0521d736118..722400ff7aa 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -28,7 +28,6 @@ from typing import Union import attr -import py import _pytest from _pytest import nodes @@ -46,6 +45,8 @@ from _pytest.compat import getimfunc from _pytest.compat import getlocation from _pytest.compat import is_generator +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.compat import NOTSET from _pytest.compat import safe_getattr from _pytest.config import _PluggyPlugin @@ -53,6 +54,7 @@ from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.deprecated import FILLFUNCARGS +from _pytest.deprecated import NODE_FSPATH from _pytest.deprecated import YIELD_FIXTURE from _pytest.mark import Mark from _pytest.mark import ParameterSet @@ -256,12 +258,12 @@ def get_parametrized_fixture_keys(item: nodes.Item, scopenum: int) -> Iterator[_ if scopenum == 0: # session key: _Key = (argname, param_index) elif scopenum == 1: # package - key = (argname, param_index, item.fspath.dirpath()) + key = (argname, param_index, item.path.parent) elif scopenum == 2: # module - key = (argname, param_index, item.fspath) + key = (argname, param_index, item.path) elif scopenum == 3: # class item_cls = item.cls # type: ignore[attr-defined] - key = (argname, param_index, item.fspath, item_cls) + key = (argname, param_index, item.path, item_cls) yield key @@ -519,12 +521,17 @@ def module(self): return self._pyfuncitem.getparent(_pytest.python.Module).obj @property - def fspath(self) -> py.path.local: - """The file system path of the test module which collected this test.""" + def fspath(self) -> LEGACY_PATH: + """(deprecated) The file system path of the test module which collected this test.""" + warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2) + return legacy_path(self.path) + + @property + def path(self) -> Path: if self.scope not in ("function", "class", "module", "package"): raise AttributeError(f"module not available in {self.scope}-scoped context") # TODO: Remove ignore once _pyfuncitem is properly typed. - return self._pyfuncitem.fspath # type: ignore + return self._pyfuncitem.path # type: ignore @property def keywords(self) -> MutableMapping[str, Any]: @@ -1040,7 +1047,7 @@ def finish(self, request: SubRequest) -> None: if exc: raise exc finally: - hook = self._fixturemanager.session.gethookproxy(request.node.fspath) + hook = self._fixturemanager.session.gethookproxy(request.node.path) hook.pytest_fixture_post_finalizer(fixturedef=self, request=request) # Even if finalization fails, we invalidate the cached fixture # value and remove all finalizers because they may be bound methods @@ -1075,7 +1082,7 @@ def execute(self, request: SubRequest) -> _FixtureValue: self.finish(request) assert self.cached_result is None - hook = self._fixturemanager.session.gethookproxy(request.node.fspath) + hook = self._fixturemanager.session.gethookproxy(request.node.path) result = hook.pytest_fixture_setup(fixturedef=self, request=request) return result @@ -1623,6 +1630,11 @@ def parsefactories( self._holderobjseen.add(holderobj) autousenames = [] for name in dir(holderobj): + # ugly workaround for one of the fspath deprecated property of node + # todo: safely generalize + if isinstance(holderobj, nodes.Node) and name == "fspath": + continue + # The attribute can be an arbitrary descriptor, so the attribute # access below can raise. safe_getatt() ignores such exceptions. obj = safe_getattr(holderobj, name, None) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5036601f9bb..3dc00fa691e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -464,7 +464,12 @@ class Session(nodes.FSCollector): def __init__(self, config: Config) -> None: super().__init__( - config.rootdir, parent=None, config=config, session=self, nodeid="" + path=config.rootpath, + fspath=config.rootdir, + parent=None, + config=config, + session=self, + nodeid="", ) self.testsfailed = 0 self.testscollected = 0 @@ -688,7 +693,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: if col: if isinstance(col[0], Package): pkg_roots[str(parent)] = col[0] - node_cache1[Path(col[0].fspath)] = [col[0]] + node_cache1[col[0].path] = [col[0]] # If it's a directory argument, recurse and look for any Subpackages. # Let the Package collector deal with subnodes, don't collect here. @@ -717,7 +722,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: continue for x in self._collectfile(path): - key2 = (type(x), Path(x.fspath)) + key2 = (type(x), x.path) if key2 in node_cache2: yield node_cache2[key2] else: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2a96d55ad05..47752d34c61 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -23,9 +23,12 @@ from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr from _pytest.compat import cached_property +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.config import ConftestImportFailure from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH +from _pytest.deprecated import NODE_FSPATH from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords @@ -79,6 +82,26 @@ def iterparentnodeids(nodeid: str) -> Iterator[str]: pos = at + len(sep) +def _imply_path( + path: Optional[Path], fspath: Optional[LEGACY_PATH] +) -> Tuple[Path, LEGACY_PATH]: + if path is not None: + if fspath is not None: + if Path(fspath) != path: + raise ValueError( + f"Path({fspath!r}) != {path!r}\n" + "if both path and fspath are given they need to be equal" + ) + assert Path(fspath) == path, f"{fspath} != {path}" + else: + fspath = legacy_path(path) + return path, fspath + + else: + assert fspath is not None + return Path(fspath), fspath + + _NodeType = TypeVar("_NodeType", bound="Node") @@ -110,7 +133,7 @@ class Node(metaclass=NodeMeta): "parent", "config", "session", - "fspath", + "path", "_nodeid", "_store", "__dict__", @@ -123,6 +146,7 @@ def __init__( config: Optional[Config] = None, session: "Optional[Session]" = None, fspath: Optional[py.path.local] = None, + path: Optional[Path] = None, nodeid: Optional[str] = None, ) -> None: #: A unique name within the scope of the parent node. @@ -148,7 +172,7 @@ def __init__( self.session = parent.session #: Filesystem path where this node was collected from (can be None). - self.fspath = fspath or getattr(parent, "fspath", None) + self.path = _imply_path(path or getattr(parent, "path", None), fspath=fspath)[0] # The explicit annotation is to avoid publicly exposing NodeKeywords. #: Keywords/markers collected from all scopes. @@ -174,6 +198,17 @@ def __init__( # own use. Currently only intended for internal plugins. self._store = Store() + @property + def fspath(self): + """(deprecated) returns a py.path.local copy of self.path""" + warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2) + return py.path.local(self.path) + + @fspath.setter + def fspath(self, value: py.path.local): + warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2) + self.path = Path(value) + @classmethod def from_parent(cls, parent: "Node", **kw): """Public constructor for Nodes. @@ -195,7 +230,7 @@ def from_parent(cls, parent: "Node", **kw): @property def ihook(self): """fspath-sensitive hook proxy used to call pytest hooks.""" - return self.session.gethookproxy(self.fspath) + return self.session.gethookproxy(self.path) def __repr__(self) -> str: return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None)) @@ -476,9 +511,9 @@ def repr_failure( # type: ignore[override] return self._repr_failure_py(excinfo, style=tbstyle) def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: - if hasattr(self, "fspath"): + if hasattr(self, "path"): traceback = excinfo.traceback - ntraceback = traceback.cut(path=Path(self.fspath)) + ntraceback = traceback.cut(path=self.path) if ntraceback == traceback: ntraceback = ntraceback.cut(excludepath=tracebackcutdir) excinfo.traceback = ntraceback.filter() @@ -497,36 +532,52 @@ def _check_initialpaths_for_relpath( class FSCollector(Collector): def __init__( self, - fspath: py.path.local, + fspath: Optional[py.path.local], + path: Optional[Path], parent=None, config: Optional[Config] = None, session: Optional["Session"] = None, nodeid: Optional[str] = None, ) -> None: + path, fspath = _imply_path(path, fspath=fspath) name = fspath.basename - if parent is not None: - rel = fspath.relto(parent.fspath) - if rel: - name = rel + if parent is not None and parent.path != path: + try: + rel = path.relative_to(parent.path) + except ValueError: + pass + else: + name = str(rel) name = name.replace(os.sep, SEP) - self.fspath = fspath + self.path = Path(fspath) session = session or parent.session if nodeid is None: - nodeid = self.fspath.relto(session.config.rootdir) - - if not nodeid: + try: + nodeid = str(self.path.relative_to(session.config.rootpath)) + except ValueError: nodeid = _check_initialpaths_for_relpath(session, fspath) + if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) - super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) + super().__init__( + name, parent, config, session, nodeid=nodeid, fspath=fspath, path=path + ) @classmethod - def from_parent(cls, parent, *, fspath, **kw): + def from_parent( + cls, + parent, + *, + fspath: Optional[py.path.local] = None, + path: Optional[Path] = None, + **kw, + ): """The public constructor.""" - return super().from_parent(parent=parent, fspath=fspath, **kw) + path, fspath = _imply_path(path, fspath=fspath) + return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) def gethookproxy(self, fspath: "os.PathLike[str]"): warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 853dfbe9489..f2a6d2aab92 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -61,6 +61,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import importorskip from _pytest.outcomes import skip +from _pytest.pathlib import bestrelpath from _pytest.pathlib import make_numbered_dir from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -976,10 +977,10 @@ def getpathnode(self, path: Union[str, "os.PathLike[str]"]): :param py.path.local path: Path to the file. """ - path = py.path.local(path) + path = Path(path) config = self.parseconfigure(path) session = Session.from_config(config) - x = session.fspath.bestrelpath(path) + x = bestrelpath(session.path, path) config.hook.pytest_sessionstart(session=session) res = session.perform_collect([x], genitems=False)[0] config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index c19d2ed4fb4..7d518dbbf4b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -577,7 +577,7 @@ def _importtestmodule(self): # We assume we are only called once per module. importmode = self.config.getoption("--import-mode") try: - mod = import_path(self.fspath, mode=importmode) + mod = import_path(self.path, mode=importmode) except SyntaxError as e: raise self.CollectError( ExceptionInfo.from_current().getrepr(style="short") @@ -603,10 +603,10 @@ def _importtestmodule(self): ) formatted_tb = str(exc_repr) raise self.CollectError( - "ImportError while importing test module '{fspath}'.\n" + "ImportError while importing test module '{path}'.\n" "Hint: make sure your test modules/packages have valid Python names.\n" "Traceback:\n" - "{traceback}".format(fspath=self.fspath, traceback=formatted_tb) + "{traceback}".format(path=self.path, traceback=formatted_tb) ) from e except skip.Exception as e: if e.allow_module_level: @@ -624,18 +624,26 @@ def _importtestmodule(self): class Package(Module): def __init__( self, - fspath: py.path.local, + fspath: Optional[py.path.local], parent: nodes.Collector, # NOTE: following args are unused: config=None, session=None, nodeid=None, + path=Optional[Path], ) -> None: # NOTE: Could be just the following, but kept as-is for compat. # nodes.FSCollector.__init__(self, fspath, parent=parent) + path, fspath = nodes._imply_path(path, fspath=fspath) session = parent.session nodes.FSCollector.__init__( - self, fspath, parent=parent, config=config, session=session, nodeid=nodeid + self, + fspath=fspath, + path=path, + parent=parent, + config=config, + session=session, + nodeid=nodeid, ) self.name = os.path.basename(str(fspath.dirname)) @@ -704,12 +712,12 @@ def _collectfile( return ihook.pytest_collect_file(fspath=fspath, path=path, parent=self) # type: ignore[no-any-return] def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - this_path = Path(self.fspath).parent + this_path = self.path.parent init_module = this_path / "__init__.py" if init_module.is_file() and path_matches_patterns( init_module, self.config.getini("python_files") ): - yield Module.from_parent(self, fspath=py.path.local(init_module)) + yield Module.from_parent(self, path=init_module) pkg_prefixes: Set[Path] = set() for direntry in visit(str(this_path), recurse=self._recurse): path = Path(direntry.path) diff --git a/testing/plugins_integration/pytest.ini b/testing/plugins_integration/pytest.ini index f6c77b0dee5..b42b07d145a 100644 --- a/testing/plugins_integration/pytest.ini +++ b/testing/plugins_integration/pytest.ini @@ -2,3 +2,4 @@ addopts = --strict-markers filterwarnings = error::pytest.PytestWarning + ignore:.*.fspath is deprecated and will be replaced by .*.path.*:pytest.PytestDeprecationWarning diff --git a/testing/python/collect.py b/testing/python/collect.py index 4256851e254..bb4c937c01e 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1125,7 +1125,8 @@ def pytest_pycollect_makeitem(collector, name, obj): def test_func_reportinfo(self, pytester: Pytester) -> None: item = pytester.getitem("def test_func(): pass") fspath, lineno, modpath = item.reportinfo() - assert fspath == item.fspath + with pytest.warns(DeprecationWarning): + assert fspath == item.fspath assert lineno == 0 assert modpath == "test_func" @@ -1140,7 +1141,8 @@ def test_hello(self): pass classcol = pytester.collect_by_name(modcol, "TestClass") assert isinstance(classcol, Class) fspath, lineno, msg = classcol.reportinfo() - assert fspath == modcol.fspath + with pytest.warns(DeprecationWarning): + assert fspath == modcol.fspath assert lineno == 1 assert msg == "TestClass" diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 3d5099c5399..e62143e5c18 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -966,7 +966,9 @@ def test_request_getmodulepath(self, pytester: Pytester) -> None: modcol = pytester.getmodulecol("def test_somefunc(): pass") (item,) = pytester.genitems([modcol]) req = fixtures.FixtureRequest(item, _ispytest=True) - assert req.fspath == modcol.fspath + assert req.path == modcol.path + with pytest.warns(pytest.PytestDeprecationWarning): + assert req.fspath == modcol.fspath def test_request_fixturenames(self, pytester: Pytester) -> None: pytester.makepyfile( diff --git a/testing/test_collection.py b/testing/test_collection.py index 055d27476a6..248071111f3 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -464,13 +464,13 @@ def test_collect_topdir(self, pytester: Pytester) -> None: config = pytester.parseconfig(id) topdir = pytester.path rcol = Session.from_config(config) - assert topdir == rcol.fspath + assert topdir == rcol.path # rootid = rcol.nodeid # root2 = rcol.perform_collect([rcol.nodeid], genitems=False)[0] # assert root2 == rcol, rootid colitems = rcol.perform_collect([rcol.nodeid], genitems=False) assert len(colitems) == 1 - assert colitems[0].fspath == p + assert colitems[0].path == p def get_reported_items(self, hookrec: HookRecorder) -> List[Item]: """Return pytest.Item instances reported by the pytest_collectreport hook""" @@ -494,10 +494,10 @@ def test_collect_protocol_single_function(self, pytester: Pytester) -> None: topdir = pytester.path # noqa hookrec.assert_contains( [ - ("pytest_collectstart", "collector.fspath == topdir"), - ("pytest_make_collect_report", "collector.fspath == topdir"), - ("pytest_collectstart", "collector.fspath == p"), - ("pytest_make_collect_report", "collector.fspath == p"), + ("pytest_collectstart", "collector.path == topdir"), + ("pytest_make_collect_report", "collector.path == topdir"), + ("pytest_collectstart", "collector.path == p"), + ("pytest_make_collect_report", "collector.path == p"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.result[0].name == 'test_func'"), ] @@ -547,7 +547,7 @@ def pytest_collect_file(path, parent): assert len(items) == 2 hookrec.assert_contains( [ - ("pytest_collectstart", "collector.fspath == collector.session.fspath"), + ("pytest_collectstart", "collector.path == collector.session.path"), ( "pytest_collectstart", "collector.__class__.__name__ == 'SpecialFile'", @@ -570,7 +570,7 @@ def test_collect_subdir_event_ordering(self, pytester: Pytester) -> None: pprint.pprint(hookrec.calls) hookrec.assert_contains( [ - ("pytest_collectstart", "collector.fspath == test_aaa"), + ("pytest_collectstart", "collector.path == test_aaa"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid.startswith('aaa/test_aaa.py')"), ] @@ -592,10 +592,10 @@ def test_collect_two_commandline_args(self, pytester: Pytester) -> None: pprint.pprint(hookrec.calls) hookrec.assert_contains( [ - ("pytest_collectstart", "collector.fspath == test_aaa"), + ("pytest_collectstart", "collector.path == test_aaa"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid == 'aaa/test_aaa.py'"), - ("pytest_collectstart", "collector.fspath == test_bbb"), + ("pytest_collectstart", "collector.path == test_bbb"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid == 'bbb/test_bbb.py'"), ] @@ -609,7 +609,9 @@ def test_serialization_byid(self, pytester: Pytester) -> None: items2, hookrec = pytester.inline_genitems(item.nodeid) (item2,) = items2 assert item2.name == item.name - assert item2.fspath == item.fspath + with pytest.warns(DeprecationWarning): + assert item2.fspath == item.fspath + assert item2.path == item.path def test_find_byid_without_instance_parents(self, pytester: Pytester) -> None: p = pytester.makepyfile( @@ -1347,14 +1349,10 @@ def test_fscollector_from_parent(pytester: Pytester, request: FixtureRequest) -> """ class MyCollector(pytest.File): - def __init__(self, fspath, parent, x): - super().__init__(fspath, parent) + def __init__(self, *k, x, **kw): + super().__init__(*k, **kw) self.x = x - @classmethod - def from_parent(cls, parent, *, fspath, x): - return super().from_parent(parent=parent, fspath=fspath, x=x) - collector = MyCollector.from_parent( parent=request.session, fspath=py.path.local(pytester.path) / "foo", x=10 ) diff --git a/testing/test_mark.py b/testing/test_mark.py index 420faf91ec9..77991f9e273 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1048,11 +1048,12 @@ class TestBarClass(BaseTests): # assert skipped_k == failed_k == 0 -def test_addmarker_order() -> None: +def test_addmarker_order(pytester) -> None: session = mock.Mock() session.own_markers = [] session.parent = None session.nodeid = "" + session.path = pytester.path node = Node.from_parent(session, name="Test") node.add_marker("foo") node.add_marker("bar") diff --git a/testing/test_runner.py b/testing/test_runner.py index abb87c6d3d4..a34cd98f964 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -447,9 +447,9 @@ class TestClass(object): assert not rep.skipped assert rep.passed locinfo = rep.location - assert locinfo[0] == col.fspath.basename + assert locinfo[0] == col.path.name assert not locinfo[1] - assert locinfo[2] == col.fspath.basename + assert locinfo[2] == col.path.name res = rep.result assert len(res) == 2 assert res[0].name == "test_func1" diff --git a/testing/test_terminal.py b/testing/test_terminal.py index e536f70989c..53bced8e68e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -133,7 +133,7 @@ def test_show_runtest_logstart(self, pytester: Pytester, linecomp) -> None: item.config.pluginmanager.register(tr) location = item.reportinfo() tr.config.hook.pytest_runtest_logstart( - nodeid=item.nodeid, location=location, fspath=str(item.fspath) + nodeid=item.nodeid, location=location, fspath=str(item.path) ) linecomp.assert_contains_lines(["*test_show_runtest_logstart.py*"]) From 77cb110258c09d29eff90078722115ca8a0a1619 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 22 Feb 2021 09:43:52 +0100 Subject: [PATCH 2/2] drop usage of py.path.local calls Co-authored-by: Bruno Oliveira --- .pre-commit-config.yaml | 6 ++++ src/_pytest/_code/code.py | 3 +- src/_pytest/cacheprovider.py | 7 ++-- src/_pytest/compat.py | 11 +++--- src/_pytest/config/__init__.py | 25 ++++++------- src/_pytest/deprecated.py | 2 +- src/_pytest/doctest.py | 5 ++- src/_pytest/hookspec.py | 22 ++++++------ src/_pytest/main.py | 6 ++-- src/_pytest/nodes.py | 28 +++++++-------- src/_pytest/pytester.py | 65 +++++++++++++++++----------------- src/_pytest/python.py | 20 +++++------ src/_pytest/reports.py | 4 +-- src/_pytest/tmpdir.py | 23 ++++++------ testing/test_collection.py | 10 +++--- testing/test_main.py | 2 +- testing/test_nodes.py | 9 +++-- testing/test_parseopt.py | 9 +++-- testing/test_pathlib.py | 7 ++-- testing/test_reports.py | 5 ++- 20 files changed, 138 insertions(+), 131 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b324b1f484e..24196151952 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index b8521756067..331aaabc780 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -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: diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 03e20bea18c..a7ec7989184 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -13,7 +13,6 @@ from typing import Union import attr -import py from .pathlib import resolve_from_str from .pathlib import rm_rf @@ -21,6 +20,8 @@ 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)) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index b9cbf85e04f..4236618e8b4 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -32,15 +32,18 @@ _T = TypeVar("_T") _S = TypeVar("_S") -#: constant to prepare valuing py.path.local replacements/lazy proxies later on +#: constant to prepare valuing pylib path replacements/lazy proxies later on # intended for removal in pytest 8.0 or 9.0 -LEGACY_PATH = py.path.local +# 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 py.path.local instances""" - return py.path.local(path) + """Internal wrapper to prepare lazy proxies for legacy_path instances""" + return LEGACY_PATH(path) # fmt: off diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c029c29a3a2..3f138efa750 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -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 `, 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 `. 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 `. 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) diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index c203eadc1ad..596574877bf 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -92,7 +92,7 @@ NODE_FSPATH = UnformattedWarning( PytestDeprecationWarning, "{type}.fspath is deprecated and will be replaced by {type}.path.\n" - "see TODO;URL for details on replacing py.path.local with pathlib.Path", + "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". diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 4942a8f793b..41d295daaba 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -22,14 +22,13 @@ 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 @@ -123,7 +122,7 @@ 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 diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index b0b8fd53d85..7d5f767db7e 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -11,7 +11,6 @@ from typing import TYPE_CHECKING from typing import Union -import py.path from pluggy import HookspecMarker from _pytest.deprecated import WARNING_CAPTURED_HOOK @@ -42,6 +41,7 @@ from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.terminal import TerminalReporter + from _pytest.compat import LEGACY_PATH hookspec = HookspecMarker("pytest") @@ -263,7 +263,7 @@ def pytest_collection_finish(session: "Session") -> None: @hookspec(firstresult=True) def pytest_ignore_collect( - fspath: Path, path: py.path.local, config: "Config" + fspath: Path, path: "LEGACY_PATH", config: "Config" ) -> Optional[bool]: """Return True to prevent considering this path for collection. @@ -273,7 +273,7 @@ def pytest_ignore_collect( Stops at first non-None result, see :ref:`firstresult`. :param pathlib.Path fspath: The path to analyze. - :param py.path.local path: The path to analyze. + :param LEGACY_PATH path: The path to analyze. :param _pytest.config.Config config: The pytest config object. .. versionchanged:: 6.3.0 @@ -283,14 +283,14 @@ def pytest_ignore_collect( def pytest_collect_file( - fspath: Path, path: py.path.local, parent: "Collector" + fspath: Path, path: "LEGACY_PATH", parent: "Collector" ) -> "Optional[Collector]": """Create a Collector for the given path, or None if not relevant. The new node needs to have the specified ``parent`` as a parent. :param pathlib.Path fspath: The path to analyze. - :param py.path.local path: The path to collect. + :param LEGACY_PATH path: The path to collect. .. versionchanged:: 6.3.0 The ``fspath`` parameter was added as a :class:`pathlib.Path` @@ -335,7 +335,7 @@ def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectRepor @hookspec(firstresult=True) def pytest_pycollect_makemodule( - fspath: Path, path: py.path.local, parent + fspath: Path, path: "LEGACY_PATH", parent ) -> Optional["Module"]: """Return a Module collector or None for the given path. @@ -346,7 +346,7 @@ def pytest_pycollect_makemodule( Stops at first non-None result, see :ref:`firstresult`. :param pathlib.Path fspath: The path of the module to collect. - :param py.path.local path: The path of the module to collect. + :param legacy_path path: The path of the module to collect. .. versionchanged:: 6.3.0 The ``fspath`` parameter was added as a :class:`pathlib.Path` @@ -676,13 +676,13 @@ def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> No def pytest_report_header( - config: "Config", startpath: Path, startdir: py.path.local + config: "Config", startpath: Path, startdir: "LEGACY_PATH" ) -> Union[str, List[str]]: """Return a string or list of strings to be displayed as header info for terminal reporting. :param _pytest.config.Config config: The pytest config object. :param Path startpath: The starting dir. - :param py.path.local startdir: The starting dir. + :param LEGACY_PATH startdir: The starting dir. .. note:: @@ -706,7 +706,7 @@ def pytest_report_header( def pytest_report_collectionfinish( config: "Config", startpath: Path, - startdir: py.path.local, + startdir: "LEGACY_PATH", items: Sequence["Item"], ) -> Union[str, List[str]]: """Return a string or list of strings to be displayed after collection @@ -718,7 +718,7 @@ def pytest_report_collectionfinish( :param _pytest.config.Config config: The pytest config object. :param Path startpath: The starting path. - :param py.path.local startdir: The starting dir. + :param LEGACY_PATH startdir: The starting dir. :param items: List of pytest items that are going to be executed; this list should not be modified. .. note:: diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 3dc00fa691e..3e7213489ff 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -21,11 +21,11 @@ from typing import Union import attr -import py import _pytest._code from _pytest import nodes from _pytest.compat import final +from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.config import directory_arg from _pytest.config import ExitCode @@ -543,7 +543,7 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool: if direntry.name == "__pycache__": return False fspath = Path(direntry.path) - path = py.path.local(fspath) + path = legacy_path(fspath) ihook = self.gethookproxy(fspath.parent) if ihook.pytest_ignore_collect(fspath=fspath, path=path, config=self.config): return False @@ -555,7 +555,7 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool: def _collectfile( self, fspath: Path, handle_dupes: bool = True ) -> Sequence[nodes.Collector]: - path = py.path.local(fspath) + path = legacy_path(fspath) assert ( fspath.is_file() ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 47752d34c61..9d93659e1ad 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -16,8 +16,6 @@ from typing import TypeVar from typing import Union -import py - import _pytest._code from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo @@ -145,7 +143,7 @@ def __init__( parent: "Optional[Node]" = None, config: Optional[Config] = None, session: "Optional[Session]" = None, - fspath: Optional[py.path.local] = None, + fspath: Optional[LEGACY_PATH] = None, path: Optional[Path] = None, nodeid: Optional[str] = None, ) -> None: @@ -199,13 +197,13 @@ def __init__( self._store = Store() @property - def fspath(self): - """(deprecated) returns a py.path.local copy of self.path""" + def fspath(self) -> LEGACY_PATH: + """(deprecated) returns a legacy_path copy of self.path""" warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2) - return py.path.local(self.path) + return legacy_path(self.path) @fspath.setter - def fspath(self, value: py.path.local): + def fspath(self, value: LEGACY_PATH) -> None: warnings.warn(NODE_FSPATH.format(type=type(self).__name__), stacklevel=2) self.path = Path(value) @@ -464,7 +462,7 @@ def get_fslocation_from_item(node: "Node") -> Tuple[Union[str, Path], Optional[i * "obj": a Python object that the node wraps. * "fspath": just a path - :rtype: A tuple of (str|py.path.local, int) with filename and line number. + :rtype: A tuple of (str|Path, int) with filename and line number. """ # See Item.location. location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None) @@ -520,10 +518,10 @@ def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: def _check_initialpaths_for_relpath( - session: "Session", fspath: py.path.local + session: "Session", fspath: LEGACY_PATH ) -> Optional[str]: for initial_path in session._initialpaths: - initial_path_ = py.path.local(initial_path) + initial_path_ = legacy_path(initial_path) if fspath.common(initial_path_) == initial_path_: return fspath.relto(initial_path_) return None @@ -532,7 +530,7 @@ def _check_initialpaths_for_relpath( class FSCollector(Collector): def __init__( self, - fspath: Optional[py.path.local], + fspath: Optional[LEGACY_PATH], path: Optional[Path], parent=None, config: Optional[Config] = None, @@ -571,7 +569,7 @@ def from_parent( cls, parent, *, - fspath: Optional[py.path.local] = None, + fspath: Optional[LEGACY_PATH] = None, path: Optional[Path] = None, **kw, ): @@ -638,8 +636,10 @@ def add_report_section(self, when: str, key: str, content: str) -> None: if content: self._report_sections.append((when, key, content)) - def reportinfo(self) -> Tuple[Union[py.path.local, str], Optional[int], str]: - return self.fspath, None, "" + def reportinfo(self) -> Tuple[Union[LEGACY_PATH, str], Optional[int], str]: + + # TODO: enable Path objects in reportinfo + return legacy_path(self.path), None, "" @cached_property def location(self) -> Tuple[str, Optional[int], str]: diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index f2a6d2aab92..699738e1282 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -34,7 +34,6 @@ from weakref import WeakKeyDictionary import attr -import py from iniconfig import IniConfig from iniconfig import SectionWrapper @@ -42,6 +41,8 @@ from _pytest._code import Source from _pytest.capture import _get_multicapture from _pytest.compat import final +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.compat import NOTSET from _pytest.compat import NotSetType from _pytest.config import _PluggyPlugin @@ -475,7 +476,7 @@ def pytester(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> "Pyt def testdir(pytester: "Pytester") -> "Testdir": """ Identical to :fixture:`pytester`, and provides an instance whose methods return - legacy ``py.path.local`` objects instead when applicable. + legacy ``LEGACY_PATH`` objects instead when applicable. New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`. """ @@ -934,10 +935,10 @@ def copy_example(self, name: Optional[str] = None) -> Path: example_path = example_dir.joinpath(name) if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file(): - # TODO: py.path.local.copy can copy files to existing directories, + # TODO: legacy_path.copy can copy files to existing directories, # while with shutil.copytree the destination directory cannot exist, - # we will need to roll our own in order to drop py.path.local completely - py.path.local(example_path).copy(py.path.local(self.path)) + # we will need to roll our own in order to drop legacy_path completely + legacy_path(example_path).copy(legacy_path(self.path)) return self.path elif example_path.is_file(): result = self.path.joinpath(example_path.name) @@ -958,12 +959,12 @@ def getnode( :param _pytest.config.Config config: A pytest config. See :py:meth:`parseconfig` and :py:meth:`parseconfigure` for creating it. - :param py.path.local arg: + :param os.PathLike[str] arg: Path to the file. """ session = Session.from_config(config) assert "::" not in str(arg) - p = py.path.local(arg) + p = legacy_path(arg) config.hook.pytest_sessionstart(session=session) res = session.perform_collect([str(p)], genitems=False)[0] config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) @@ -975,7 +976,7 @@ def getpathnode(self, path: Union[str, "os.PathLike[str]"]): This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to create the (configured) pytest Config instance. - :param py.path.local path: Path to the file. + :param os.PathLike[str] path: Path to the file. """ path = Path(path) config = self.parseconfigure(path) @@ -1520,10 +1521,10 @@ def assert_contains_lines(self, lines2: Sequence[str]) -> None: @attr.s(repr=False, str=False, init=False) class Testdir: """ - Similar to :class:`Pytester`, but this class works with legacy py.path.local objects instead. + Similar to :class:`Pytester`, but this class works with legacy legacy_path objects instead. All methods just forward to an internal :class:`Pytester` instance, converting results - to `py.path.local` objects as necessary. + to `legacy_path` objects as necessary. """ __test__ = False @@ -1537,13 +1538,13 @@ def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None: self._pytester = pytester @property - def tmpdir(self) -> py.path.local: + def tmpdir(self) -> LEGACY_PATH: """Temporary directory where tests are executed.""" - return py.path.local(self._pytester.path) + return legacy_path(self._pytester.path) @property - def test_tmproot(self) -> py.path.local: - return py.path.local(self._pytester._test_tmproot) + def test_tmproot(self) -> LEGACY_PATH: + return legacy_path(self._pytester._test_tmproot) @property def request(self): @@ -1573,7 +1574,7 @@ def finalize(self) -> None: """See :meth:`Pytester._finalize`.""" return self._pytester._finalize() - def makefile(self, ext, *args, **kwargs) -> py.path.local: + def makefile(self, ext, *args, **kwargs) -> LEGACY_PATH: """See :meth:`Pytester.makefile`.""" if ext and not ext.startswith("."): # pytester.makefile is going to throw a ValueError in a way that @@ -1583,47 +1584,47 @@ def makefile(self, ext, *args, **kwargs) -> py.path.local: # allowed this, we will prepend "." as a workaround to avoid breaking # testdir usage that worked before ext = "." + ext - return py.path.local(str(self._pytester.makefile(ext, *args, **kwargs))) + return legacy_path(self._pytester.makefile(ext, *args, **kwargs)) - def makeconftest(self, source) -> py.path.local: + def makeconftest(self, source) -> LEGACY_PATH: """See :meth:`Pytester.makeconftest`.""" - return py.path.local(str(self._pytester.makeconftest(source))) + return legacy_path(self._pytester.makeconftest(source)) - def makeini(self, source) -> py.path.local: + def makeini(self, source) -> LEGACY_PATH: """See :meth:`Pytester.makeini`.""" - return py.path.local(str(self._pytester.makeini(source))) + return legacy_path(self._pytester.makeini(source)) def getinicfg(self, source: str) -> SectionWrapper: """See :meth:`Pytester.getinicfg`.""" return self._pytester.getinicfg(source) - def makepyprojecttoml(self, source) -> py.path.local: + def makepyprojecttoml(self, source) -> LEGACY_PATH: """See :meth:`Pytester.makepyprojecttoml`.""" - return py.path.local(str(self._pytester.makepyprojecttoml(source))) + return legacy_path(self._pytester.makepyprojecttoml(source)) - def makepyfile(self, *args, **kwargs) -> py.path.local: + def makepyfile(self, *args, **kwargs) -> LEGACY_PATH: """See :meth:`Pytester.makepyfile`.""" - return py.path.local(str(self._pytester.makepyfile(*args, **kwargs))) + return legacy_path(self._pytester.makepyfile(*args, **kwargs)) - def maketxtfile(self, *args, **kwargs) -> py.path.local: + def maketxtfile(self, *args, **kwargs) -> LEGACY_PATH: """See :meth:`Pytester.maketxtfile`.""" - return py.path.local(str(self._pytester.maketxtfile(*args, **kwargs))) + return legacy_path(self._pytester.maketxtfile(*args, **kwargs)) def syspathinsert(self, path=None) -> None: """See :meth:`Pytester.syspathinsert`.""" return self._pytester.syspathinsert(path) - def mkdir(self, name) -> py.path.local: + def mkdir(self, name) -> LEGACY_PATH: """See :meth:`Pytester.mkdir`.""" - return py.path.local(str(self._pytester.mkdir(name))) + return legacy_path(self._pytester.mkdir(name)) - def mkpydir(self, name) -> py.path.local: + def mkpydir(self, name) -> LEGACY_PATH: """See :meth:`Pytester.mkpydir`.""" - return py.path.local(str(self._pytester.mkpydir(name))) + return legacy_path(self._pytester.mkpydir(name)) - def copy_example(self, name=None) -> py.path.local: + def copy_example(self, name=None) -> LEGACY_PATH: """See :meth:`Pytester.copy_example`.""" - return py.path.local(str(self._pytester.copy_example(name))) + return legacy_path(self._pytester.copy_example(name)) def getnode(self, config: Config, arg) -> Optional[Union[Item, Collector]]: """See :meth:`Pytester.getnode`.""" diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 7d518dbbf4b..ccd685f54a9 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -26,8 +26,6 @@ from typing import TYPE_CHECKING from typing import Union -import py - import _pytest from _pytest import fixtures from _pytest import nodes @@ -45,6 +43,8 @@ from _pytest.compat import getlocation from _pytest.compat import is_async_function from _pytest.compat import is_generator +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.compat import NOTSET from _pytest.compat import REGEX_TYPE from _pytest.compat import safe_getattr @@ -189,7 +189,7 @@ def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: def pytest_collect_file( - fspath: Path, path: py.path.local, parent: nodes.Collector + fspath: Path, path: LEGACY_PATH, parent: nodes.Collector ) -> Optional["Module"]: if fspath.suffix == ".py": if not parent.session.isinitpath(fspath): @@ -210,7 +210,7 @@ def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool: return any(fnmatch_ex(pattern, path) for pattern in patterns) -def pytest_pycollect_makemodule(fspath: Path, path: py.path.local, parent) -> "Module": +def pytest_pycollect_makemodule(fspath: Path, path: LEGACY_PATH, parent) -> "Module": if fspath.name == "__init__.py": pkg: Package = Package.from_parent(parent, fspath=path) return pkg @@ -321,7 +321,7 @@ def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> parts.reverse() return ".".join(parts) - def reportinfo(self) -> Tuple[Union[py.path.local, str], int, str]: + def reportinfo(self) -> Tuple[Union[LEGACY_PATH, str], int, str]: # XXX caching? obj = self.obj compat_co_firstlineno = getattr(obj, "compat_co_firstlineno", None) @@ -330,12 +330,12 @@ def reportinfo(self) -> Tuple[Union[py.path.local, str], int, str]: file_path = sys.modules[obj.__module__].__file__ if file_path.endswith(".pyc"): file_path = file_path[:-1] - fspath: Union[py.path.local, str] = file_path + fspath: Union[LEGACY_PATH, str] = file_path lineno = compat_co_firstlineno else: path, lineno = getfslineno(obj) if isinstance(path, Path): - fspath = py.path.local(path) + fspath = legacy_path(path) else: fspath = path modpath = self.getmodpath() @@ -624,7 +624,7 @@ def _importtestmodule(self): class Package(Module): def __init__( self, - fspath: Optional[py.path.local], + fspath: Optional[LEGACY_PATH], parent: nodes.Collector, # NOTE: following args are unused: config=None, @@ -675,7 +675,7 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool: if direntry.name == "__pycache__": return False fspath = Path(direntry.path) - path = py.path.local(fspath) + path = legacy_path(fspath) ihook = self.session.gethookproxy(fspath.parent) if ihook.pytest_ignore_collect(fspath=fspath, path=path, config=self.config): return False @@ -687,7 +687,7 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool: def _collectfile( self, fspath: Path, handle_dupes: bool = True ) -> Sequence[nodes.Collector]: - path = py.path.local(fspath) + path = legacy_path(fspath) assert ( fspath.is_file() ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 303f731ddaa..657e0683378 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -15,7 +15,6 @@ from typing import Union import attr -import py from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo @@ -30,6 +29,7 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import final +from _pytest.compat import LEGACY_PATH from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item @@ -500,7 +500,7 @@ def serialize_exception_longrepr(rep: BaseReport) -> Dict[str, Any]: else: d["longrepr"] = report.longrepr for name in d: - if isinstance(d[name], (py.path.local, Path)): + if isinstance(d[name], (LEGACY_PATH, Path)): d[name] = str(d[name]) elif name == "result": d[name] = None # for now diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 47729ae5fee..d30e1e57feb 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -6,13 +6,14 @@ from typing import Optional import attr -import py from .pathlib import ensure_reset_dir from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from _pytest.compat import final +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture @@ -133,7 +134,7 @@ def getbasetemp(self) -> Path: @final @attr.s(init=False) class TempdirFactory: - """Backward comptibility wrapper that implements :class:``py.path.local`` + """Backward comptibility wrapper that implements :class:``_pytest.compat.LEGACY_PATH`` for :class:``TempPathFactory``.""" _tmppath_factory = attr.ib(type=TempPathFactory) @@ -144,13 +145,13 @@ def __init__( check_ispytest(_ispytest) self._tmppath_factory = tmppath_factory - def mktemp(self, basename: str, numbered: bool = True) -> py.path.local: - """Same as :meth:`TempPathFactory.mktemp`, but returns a ``py.path.local`` object.""" - return py.path.local(self._tmppath_factory.mktemp(basename, numbered).resolve()) + def mktemp(self, basename: str, numbered: bool = True) -> LEGACY_PATH: + """Same as :meth:`TempPathFactory.mktemp`, but returns a ``_pytest.compat.LEGACY_PATH`` object.""" + return legacy_path(self._tmppath_factory.mktemp(basename, numbered).resolve()) - def getbasetemp(self) -> py.path.local: + def getbasetemp(self) -> LEGACY_PATH: """Backward compat wrapper for ``_tmppath_factory.getbasetemp``.""" - return py.path.local(self._tmppath_factory.getbasetemp().resolve()) + return legacy_path(self._tmppath_factory.getbasetemp().resolve()) def get_user() -> Optional[str]: @@ -202,7 +203,7 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: @fixture -def tmpdir(tmp_path: Path) -> py.path.local: +def tmpdir(tmp_path: Path) -> LEGACY_PATH: """Return a temporary directory path object which is unique to each test function invocation, created as a sub directory of the base temporary directory. @@ -212,11 +213,11 @@ def tmpdir(tmp_path: Path) -> py.path.local: ``--basetemp`` is used then it is cleared each session. See :ref:`base temporary directory`. - The returned object is a `py.path.local`_ path object. + The returned object is a `legacy_path`_ object. - .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html + .. _legacy_path: https://py.readthedocs.io/en/latest/path.html """ - return py.path.local(tmp_path) + return legacy_path(tmp_path) @fixture diff --git a/testing/test_collection.py b/testing/test_collection.py index 248071111f3..5f0b5902ab8 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -6,8 +6,6 @@ from pathlib import Path from typing import List -import py.path - import pytest from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest @@ -369,9 +367,10 @@ def pytest_ignore_collect(path, config): def test_collectignore_exclude_on_option(self, pytester: Pytester) -> None: pytester.makeconftest( """ - import py + # potentially avoid dependency on pylib + from _pytest.compat import legacy_path from pathlib import Path - collect_ignore = [py.path.local('hello'), 'test_world.py', Path('bye')] + collect_ignore = [legacy_path('hello'), 'test_world.py', Path('bye')] def pytest_addoption(parser): parser.addoption("--XX", action="store_true", default=False) def pytest_configure(config): @@ -1347,6 +1346,7 @@ def test_fscollector_from_parent(pytester: Pytester, request: FixtureRequest) -> Context: https://github.com/pytest-dev/pytest-cpp/pull/47 """ + from _pytest.compat import legacy_path class MyCollector(pytest.File): def __init__(self, *k, x, **kw): @@ -1354,7 +1354,7 @@ def __init__(self, *k, x, **kw): self.x = x collector = MyCollector.from_parent( - parent=request.session, fspath=py.path.local(pytester.path) / "foo", x=10 + parent=request.session, fspath=legacy_path(pytester.path) / "foo", x=10 ) assert collector.x == 10 diff --git a/testing/test_main.py b/testing/test_main.py index 2ed111895cd..4450029342e 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -205,7 +205,7 @@ def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> N def test_module_full_path_without_drive(pytester: Pytester) -> None: """Collect and run test using full path except for the drive letter (#7628). - Passing a full path without a drive letter would trigger a bug in py.path.local + Passing a full path without a drive letter would trigger a bug in legacy_path where it would keep the full path without the drive letter around, instead of resolving to the full path, resulting in fixtures node ids not matching against test node ids correctly. """ diff --git a/testing/test_nodes.py b/testing/test_nodes.py index 59d9f409eac..dde161777cd 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -3,10 +3,9 @@ from typing import List from typing import Type -import py - import pytest from _pytest import nodes +from _pytest.compat import legacy_path from _pytest.pytester import Pytester from _pytest.warning_types import PytestWarning @@ -77,7 +76,7 @@ class FakeSession1: session = cast(pytest.Session, FakeSession1) - assert nodes._check_initialpaths_for_relpath(session, py.path.local(cwd)) == "" + assert nodes._check_initialpaths_for_relpath(session, legacy_path(cwd)) == "" sub = cwd / "file" @@ -86,9 +85,9 @@ class FakeSession2: session = cast(pytest.Session, FakeSession2) - assert nodes._check_initialpaths_for_relpath(session, py.path.local(sub)) == "file" + assert nodes._check_initialpaths_for_relpath(session, legacy_path(sub)) == "file" - outside = py.path.local("/outside") + outside = legacy_path("/outside") assert nodes._check_initialpaths_for_relpath(session, outside) is None diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index c33337b67b3..6ba9269e564 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -4,9 +4,8 @@ import subprocess import sys -import py - import pytest +from _pytest.compat import legacy_path from _pytest.config import argparsing as parseopt from _pytest.config.exceptions import UsageError from _pytest.monkeypatch import MonkeyPatch @@ -124,11 +123,11 @@ def test_parse(self, parser: parseopt.Parser) -> None: assert not getattr(args, parseopt.FILE_OR_DIR) def test_parse2(self, parser: parseopt.Parser) -> None: - args = parser.parse([py.path.local()]) - assert getattr(args, parseopt.FILE_OR_DIR)[0] == py.path.local() + args = parser.parse([legacy_path(".")]) + assert getattr(args, parseopt.FILE_OR_DIR)[0] == legacy_path(".") def test_parse_known_args(self, parser: parseopt.Parser) -> None: - parser.parse_known_args([py.path.local()]) + parser.parse_known_args([legacy_path(".")]) parser.addoption("--hello", action="store_true") ns = parser.parse_known_args(["x", "--y", "--hello", "this"]) assert ns.hello diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 48149084ece..d71e44e36b6 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -7,9 +7,8 @@ from types import ModuleType from typing import Generator -import py - import pytest +from _pytest.compat import legacy_path from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import bestrelpath from _pytest.pathlib import commonpath @@ -28,14 +27,14 @@ class TestFNMatcherPort: """Test that our port of py.common.FNMatcher (fnmatch_ex) produces the - same results as the original py.path.local.fnmatch method.""" + same results as the original legacy_path.fnmatch method.""" @pytest.fixture(params=["pathlib", "py.path"]) def match(self, request): if request.param == "py.path": def match_(pattern, path): - return py.path.local(path).fnmatch(pattern) + return legacy_path(path).fnmatch(pattern) else: assert request.param == "pathlib" diff --git a/testing/test_reports.py b/testing/test_reports.py index b376f6198ae..3da63c2c873 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,11 +1,10 @@ from typing import Sequence from typing import Union -import py.path - import pytest from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr +from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.pytester import Pytester from _pytest.reports import CollectReport @@ -237,7 +236,7 @@ def test_a(): reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3 test_a_call = reports[1] - test_a_call.path1 = py.path.local(pytester.path) # type: ignore[attr-defined] + test_a_call.path1 = legacy_path(pytester.path) # type: ignore[attr-defined] test_a_call.path2 = pytester.path # type: ignore[attr-defined] data = test_a_call._to_json() assert data["path1"] == str(pytester.path)