diff --git a/changelog/12069.trivial.rst b/changelog/12069.trivial.rst new file mode 100644 index 000000000..25c0db1c1 --- /dev/null +++ b/changelog/12069.trivial.rst @@ -0,0 +1,8 @@ +Delayed the deprecation of the following features to ``9.0.0``: + +* :ref:`node-ctor-fspath-deprecation`. +* :ref:`legacy-path-hooks-deprecated`. + +It was discovered after ``8.1.0`` was released that the warnings about the impeding removal were not being displayed, so the team decided to revert the removal. + +This was the reason for ``8.1.0`` being yanked. diff --git a/doc/en/conf.py b/doc/en/conf.py index cf889eb7a..8059c359f 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -200,6 +200,7 @@ nitpick_ignore = [ ("py:class", "_tracing.TagTracerSub"), ("py:class", "warnings.WarningMessage"), # Undocumented type aliases + ("py:class", "LEGACY_PATH"), ("py:class", "_PluggyPlugin"), # TypeVars ("py:class", "_pytest._code.code.E"), diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index b9a59d791..cd6d1e60a 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -19,7 +19,45 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. -.. _legacy-path-hooks-deprecated: +.. _node-ctor-fspath-deprecation: + +``fspath`` argument for Node constructors replaced with ``pathlib.Path`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 7.0 + +In order to support the transition from ``py.path.local`` to :mod:`pathlib`, +the ``fspath`` argument to :class:`~_pytest.nodes.Node` constructors like +:func:`pytest.Function.from_parent()` and :func:`pytest.Class.from_parent()` +is now deprecated. + +Plugins which construct nodes should pass the ``path`` argument, of type +:class:`pathlib.Path`, instead of the ``fspath`` argument. + +Plugins which implement custom items and collectors are encouraged to replace +``fspath`` parameters (``py.path.local``) with ``path`` parameters +(``pathlib.Path``), and drop any other usage of the ``py`` library if possible. + +If possible, plugins with custom items should use :ref:`cooperative +constructors ` to avoid hardcoding +arguments they only pass on to the superclass. + +.. note:: + The name of the :class:`~_pytest.nodes.Node` arguments and attributes (the + new attribute being ``path``) is **the opposite** of the situation for + hooks, :ref:`outlined below ` (the old + argument being ``path``). + + This is an unfortunate artifact due to historical reasons, which should be + resolved in future versions as we slowly get rid of the :pypi:`py` + dependency (see :issue:`9283` for a longer discussion). + +Due to the ongoing migration of methods like :meth:`~pytest.Item.reportinfo` +which still is expected to return a ``py.path.local`` object, nodes still have +both ``fspath`` (``py.path.local``) and ``path`` (``pathlib.Path``) attributes, +no matter what argument was used in the constructor. We expect to deprecate the +``fspath`` attribute in a future release. + Configuring hook specs/impls using markers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -62,6 +100,33 @@ Changed ``hookwrapper`` attributes: * ``historic`` +.. _legacy-path-hooks-deprecated: + +``py.path.local`` arguments for hooks replaced with ``pathlib.Path`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 7.0 + +In order to support the transition from ``py.path.local`` to :mod:`pathlib`, the following hooks now receive additional arguments: + +* :hook:`pytest_ignore_collect(collection_path: pathlib.Path) ` as equivalent to ``path`` +* :hook:`pytest_collect_file(file_path: pathlib.Path) ` as equivalent to ``path`` +* :hook:`pytest_pycollect_makemodule(module_path: pathlib.Path) ` as equivalent to ``path`` +* :hook:`pytest_report_header(start_path: pathlib.Path) ` as equivalent to ``startdir`` +* :hook:`pytest_report_collectionfinish(start_path: pathlib.Path) ` as equivalent to ``startdir`` + +The accompanying ``py.path.local`` based paths have been deprecated: plugins which manually invoke those hooks should only pass the new ``pathlib.Path`` arguments, and users should change their hook implementations to use the new ``pathlib.Path`` arguments. + +.. note:: + The name of the :class:`~_pytest.nodes.Node` arguments and attributes, + :ref:`outlined above ` (the new attribute + being ``path``) is **the opposite** of the situation for hooks (the old + argument being ``path``). + + This is an unfortunate artifact due to historical reasons, which should be + resolved in future versions as we slowly get rid of the :pypi:`py` + dependency (see :issue:`9283` for a longer discussion). + Directly constructing internal classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -208,73 +273,6 @@ an appropriate period of deprecation has passed. Some breaking changes which could not be deprecated are also listed. -.. _node-ctor-fspath-deprecation: - -``fspath`` argument for Node constructors replaced with ``pathlib.Path`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.0 - -In order to support the transition from ``py.path.local`` to :mod:`pathlib`, -the ``fspath`` argument to :class:`~_pytest.nodes.Node` constructors like -:func:`pytest.Function.from_parent()` and :func:`pytest.Class.from_parent()` -is now deprecated. - -Plugins which construct nodes should pass the ``path`` argument, of type -:class:`pathlib.Path`, instead of the ``fspath`` argument. - -Plugins which implement custom items and collectors are encouraged to replace -``fspath`` parameters (``py.path.local``) with ``path`` parameters -(``pathlib.Path``), and drop any other usage of the ``py`` library if possible. - -If possible, plugins with custom items should use :ref:`cooperative -constructors ` to avoid hardcoding -arguments they only pass on to the superclass. - -.. note:: - The name of the :class:`~_pytest.nodes.Node` arguments and attributes (the - new attribute being ``path``) is **the opposite** of the situation for - hooks, :ref:`outlined below ` (the old - argument being ``path``). - - This is an unfortunate artifact due to historical reasons, which should be - resolved in future versions as we slowly get rid of the :pypi:`py` - dependency (see :issue:`9283` for a longer discussion). - -Due to the ongoing migration of methods like :meth:`~pytest.Item.reportinfo` -which still is expected to return a ``py.path.local`` object, nodes still have -both ``fspath`` (``py.path.local``) and ``path`` (``pathlib.Path``) attributes, -no matter what argument was used in the constructor. We expect to deprecate the -``fspath`` attribute in a future release. - - -``py.path.local`` arguments for hooks replaced with ``pathlib.Path`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.0 -.. versionremoved:: 8.0 - -In order to support the transition from ``py.path.local`` to :mod:`pathlib`, the following hooks now receive additional arguments: - -* :hook:`pytest_ignore_collect(collection_path: pathlib.Path) ` as equivalent to ``path`` -* :hook:`pytest_collect_file(file_path: pathlib.Path) ` as equivalent to ``path`` -* :hook:`pytest_pycollect_makemodule(module_path: pathlib.Path) ` as equivalent to ``path`` -* :hook:`pytest_report_header(start_path: pathlib.Path) ` as equivalent to ``startdir`` -* :hook:`pytest_report_collectionfinish(start_path: pathlib.Path) ` as equivalent to ``startdir`` - -The accompanying ``py.path.local`` based paths have been deprecated: plugins which manually invoke those hooks should only pass the new ``pathlib.Path`` arguments, and users should change their hook implementations to use the new ``pathlib.Path`` arguments. - -.. note:: - The name of the :class:`~_pytest.nodes.Node` arguments and attributes, - :ref:`outlined above ` (the new attribute - being ``path``) is **the opposite** of the situation for hooks (the old - argument being ``path``). - - This is an unfortunate artifact due to historical reasons, which should be - resolved in future versions as we slowly get rid of the :pypi:`py` - dependency (see :issue:`9283` for a longer discussion). - - .. _nose-deprecation: Support for tests written for nose diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index d39f96c4c..8d49d6fb6 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,5 +1,6 @@ # mypy: allow-untyped-defs """Python version compatibility code.""" + from __future__ import annotations import dataclasses @@ -16,6 +17,22 @@ from typing import Callable from typing import Final from typing import NoReturn +import py + + +#: 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: 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: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 7ed79483c..bf2cfc399 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -38,12 +38,14 @@ from typing import TYPE_CHECKING from typing import Union import warnings +import pluggy from pluggy import HookimplMarker from pluggy import HookimplOpts from pluggy import HookspecMarker from pluggy import HookspecOpts from pluggy import PluginManager +from .compat import PathAwareHookProxy from .exceptions import PrintHelp as PrintHelp from .exceptions import UsageError as UsageError from .findpaths import determine_setup @@ -1068,7 +1070,7 @@ class Config: self._store = self.stash self.trace = self.pluginmanager.trace.root.get("config") - self.hook = self.pluginmanager.hook # type: ignore[assignment] + self.hook: pluggy.HookRelay = PathAwareHookProxy(self.pluginmanager.hook) # type: ignore[assignment] self._inicache: Dict[str, Any] = {} self._override_ini: Sequence[str] = () self._opt2dest: Dict[str, str] = {} diff --git a/src/_pytest/config/compat.py b/src/_pytest/config/compat.py new file mode 100644 index 000000000..2856d85d1 --- /dev/null +++ b/src/_pytest/config/compat.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import functools +from pathlib import Path +from typing import Any +from typing import Mapping +import warnings + +import pluggy + +from ..compat import LEGACY_PATH +from ..compat import legacy_path +from ..deprecated import HOOK_LEGACY_PATH_ARG + + +# hookname: (Path, LEGACY_PATH) +imply_paths_hooks: Mapping[str, tuple[str, str]] = { + "pytest_ignore_collect": ("collection_path", "path"), + "pytest_collect_file": ("file_path", "path"), + "pytest_pycollect_makemodule": ("module_path", "path"), + "pytest_report_header": ("start_path", "startdir"), + "pytest_report_collectionfinish": ("start_path", "startdir"), +} + + +def _check_path(path: Path, fspath: LEGACY_PATH) -> 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" + ) + + +class PathAwareHookProxy: + """ + this helper wraps around hook callers + until pluggy supports fixingcalls, this one will do + + it currently doesn't return full hook caller proxies for fixed hooks, + this may have to be changed later depending on bugs + """ + + def __init__(self, hook_relay: pluggy.HookRelay) -> None: + self._hook_relay = hook_relay + + def __dir__(self) -> list[str]: + return dir(self._hook_relay) + + def __getattr__(self, key: str) -> pluggy.HookCaller: + hook: pluggy.HookCaller = getattr(self._hook_relay, key) + if key not in imply_paths_hooks: + self.__dict__[key] = hook + return hook + else: + path_var, fspath_var = imply_paths_hooks[key] + + @functools.wraps(hook) + def fixed_hook(**kw: Any) -> Any: + path_value: Path | None = kw.pop(path_var, None) + fspath_value: LEGACY_PATH | None = kw.pop(fspath_var, None) + if fspath_value is not None: + warnings.warn( + HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg=fspath_var, pathlib_path_arg=path_var + ), + stacklevel=2, + ) + if path_value is not None: + if fspath_value is not None: + _check_path(path_value, fspath_value) + else: + fspath_value = legacy_path(path_value) + else: + assert fspath_value is not None + path_value = Path(fspath_value) + + kw[path_var] = path_value + kw[fspath_var] = fspath_value + return hook(**kw) + + fixed_hook.name = hook.name # type: ignore[attr-defined] + fixed_hook.spec = hook.spec # type: ignore[attr-defined] + fixed_hook.__name__ = key + self.__dict__[key] = fixed_hook + return fixed_hook # type: ignore[return-value] diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 56271c957..10811d158 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -36,6 +36,21 @@ YIELD_FIXTURE = PytestDeprecationWarning( PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") +HOOK_LEGACY_PATH_ARG = UnformattedWarning( + PytestRemovedIn9Warning, + "The ({pylib_path_arg}: py.path.local) argument is deprecated, please use ({pathlib_path_arg}: pathlib.Path)\n" + "see https://docs.pytest.org/en/latest/deprecations.html" + "#py-path-local-arguments-for-hooks-replaced-with-pathlib-path", +) + +NODE_CTOR_FSPATH_ARG = UnformattedWarning( + PytestRemovedIn9Warning, + "The (fspath: py.path.local) argument to {node_type_name} is deprecated. " + "Please use the (path: pathlib.Path) argument instead.\n" + "See https://docs.pytest.org/en/latest/deprecations.html" + "#fspath-argument-for-node-constructors-replaced-with-pathlib-path", +) + HOOK_LEGACY_MARKING = UnformattedWarning( PytestDeprecationWarning, "The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n" diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 58f4986ec..4bee76f1e 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from _pytest._code.code import ExceptionInfo from _pytest._code.code import ExceptionRepr + from _pytest.compat import LEGACY_PATH from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode @@ -296,7 +297,9 @@ def pytest_collection_finish(session: "Session") -> None: @hookspec(firstresult=True) -def pytest_ignore_collect(collection_path: Path, config: "Config") -> Optional[bool]: +def pytest_ignore_collect( + collection_path: Path, path: "LEGACY_PATH", config: "Config" +) -> Optional[bool]: """Return True to prevent considering this path for collection. This hook is consulted for all files and directories prior to calling @@ -310,10 +313,8 @@ def pytest_ignore_collect(collection_path: Path, config: "Config") -> Optional[b .. versionchanged:: 7.0.0 The ``collection_path`` parameter was added as a :class:`pathlib.Path` - equivalent of the ``path`` parameter. - - .. versionchanged:: 8.0.0 - The ``path`` parameter has been removed. + equivalent of the ``path`` parameter. The ``path`` parameter + has been deprecated. Use in conftest plugins ======================= @@ -354,7 +355,9 @@ def pytest_collect_directory(path: Path, parent: "Collector") -> "Optional[Colle """ -def pytest_collect_file(file_path: Path, parent: "Collector") -> "Optional[Collector]": +def pytest_collect_file( + file_path: Path, path: "LEGACY_PATH", parent: "Collector" +) -> "Optional[Collector]": """Create a :class:`~pytest.Collector` for the given path, or None if not relevant. For best results, the returned collector should be a subclass of @@ -367,10 +370,8 @@ def pytest_collect_file(file_path: Path, parent: "Collector") -> "Optional[Colle .. versionchanged:: 7.0.0 The ``file_path`` parameter was added as a :class:`pathlib.Path` - equivalent of the ``path`` parameter. - - .. versionchanged:: 8.0.0 - The ``path`` parameter was removed. + equivalent of the ``path`` parameter. The ``path`` parameter + has been deprecated. Use in conftest plugins ======================= @@ -467,7 +468,9 @@ def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectRepor @hookspec(firstresult=True) -def pytest_pycollect_makemodule(module_path: Path, parent) -> Optional["Module"]: +def pytest_pycollect_makemodule( + module_path: Path, path: "LEGACY_PATH", parent +) -> Optional["Module"]: """Return a :class:`pytest.Module` collector or None for the given path. This hook will be called for each matching test module path. @@ -483,8 +486,7 @@ def pytest_pycollect_makemodule(module_path: Path, parent) -> Optional["Module"] The ``module_path`` parameter was added as a :class:`pathlib.Path` equivalent of the ``path`` parameter. - .. versionchanged:: 8.0.0 - The ``path`` parameter has been removed in favor of ``module_path``. + The ``path`` parameter has been deprecated in favor of ``fspath``. Use in conftest plugins ======================= @@ -992,7 +994,7 @@ def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> No def pytest_report_header( # type:ignore[empty-body] - config: "Config", start_path: Path + config: "Config", start_path: Path, startdir: "LEGACY_PATH" ) -> Union[str, List[str]]: """Return a string or list of strings to be displayed as header info for terminal reporting. @@ -1009,10 +1011,8 @@ def pytest_report_header( # type:ignore[empty-body] .. versionchanged:: 7.0.0 The ``start_path`` parameter was added as a :class:`pathlib.Path` - equivalent of the ``startdir`` parameter. - - .. versionchanged:: 8.0.0 - The ``startdir`` parameter has been removed. + equivalent of the ``startdir`` parameter. The ``startdir`` parameter + has been deprecated. Use in conftest plugins ======================= @@ -1024,6 +1024,7 @@ def pytest_report_header( # type:ignore[empty-body] def pytest_report_collectionfinish( # type:ignore[empty-body] config: "Config", start_path: Path, + startdir: "LEGACY_PATH", items: Sequence["Item"], ) -> Union[str, List[str]]: """Return a string or list of strings to be displayed after collection @@ -1047,10 +1048,8 @@ def pytest_report_collectionfinish( # type:ignore[empty-body] .. versionchanged:: 7.0.0 The ``start_path`` parameter was added as a :class:`pathlib.Path` - equivalent of the ``startdir`` parameter. - - .. versionchanged:: 8.0.0 - The ``startdir`` parameter has been removed. + equivalent of the ``startdir`` parameter. The ``startdir`` parameter + has been deprecated. Use in conftest plugins ======================= diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index b56f3a6fb..b28c89767 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -1,7 +1,7 @@ # mypy: allow-untyped-defs """Add backward compatibility support for the legacy py path type.""" + import dataclasses -import os from pathlib import Path import shlex import subprocess @@ -14,9 +14,9 @@ from typing import Union from iniconfig import SectionWrapper -import py - from _pytest.cacheprovider import Cache +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config import PytestPluginManager @@ -39,20 +39,6 @@ if TYPE_CHECKING: import pexpect -#: 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) - - @final class Testdir: """ diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1de86be86..3b9ac93cf 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -37,6 +37,7 @@ from _pytest.config import hookimpl from _pytest.config import PytestPluginManager from _pytest.config import UsageError from _pytest.config.argparsing import Parser +from _pytest.config.compat import PathAwareHookProxy from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit from _pytest.pathlib import absolutepath @@ -557,6 +558,7 @@ class Session(nodes.Collector): super().__init__( name="", path=config.rootpath, + fspath=None, parent=None, config=config, session=self, @@ -694,7 +696,7 @@ class Session(nodes.Collector): proxy: pluggy.HookRelay if remove_mods: # One or more conftests are not in use at this path. - proxy = FSHookProxy(pm, remove_mods) # type: ignore[arg-type,assignment] + proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) # type: ignore[arg-type,assignment] else: # All plugins are active for this fspath. proxy = self.config.hook diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2381b65ea..cff15001c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -3,6 +3,7 @@ import abc from functools import cached_property from inspect import signature import os +import pathlib from pathlib import Path from typing import Any from typing import Callable @@ -29,8 +30,11 @@ from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback +from _pytest.compat import LEGACY_PATH from _pytest.config import Config from _pytest.config import ConftestImportFailure +from _pytest.config.compat import _check_path +from _pytest.deprecated import NODE_CTOR_FSPATH_ARG from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords @@ -55,6 +59,29 @@ tracebackcutdir = Path(_pytest.__file__).parent _T = TypeVar("_T") + + +def _imply_path( + node_type: Type["Node"], + path: Optional[Path], + fspath: Optional[LEGACY_PATH], +) -> Path: + if fspath is not None: + warnings.warn( + NODE_CTOR_FSPATH_ARG.format( + node_type_name=node_type.__name__, + ), + stacklevel=6, + ) + if path is not None: + if fspath is not None: + _check_path(path, fspath) + return path + else: + assert fspath is not None + return Path(fspath) + + _NodeType = TypeVar("_NodeType", bound="Node") @@ -110,6 +137,13 @@ class Node(abc.ABC, metaclass=NodeMeta): leaf nodes. """ + # Implemented in the legacypath plugin. + #: A ``LEGACY_PATH`` copy of the :attr:`path` attribute. Intended for usage + #: for methods not migrated to ``pathlib.Path`` yet, such as + #: :meth:`Item.reportinfo `. Will be deprecated in + #: a future release, prefer using :attr:`path` instead. + fspath: LEGACY_PATH + # Use __slots__ to make attribute access faster. # Note that __dict__ is still available. __slots__ = ( @@ -129,6 +163,7 @@ class Node(abc.ABC, metaclass=NodeMeta): parent: "Optional[Node]" = None, config: Optional[Config] = None, session: "Optional[Session]" = None, + fspath: Optional[LEGACY_PATH] = None, path: Optional[Path] = None, nodeid: Optional[str] = None, ) -> None: @@ -154,11 +189,10 @@ class Node(abc.ABC, metaclass=NodeMeta): raise TypeError("session or parent must be provided") self.session = parent.session - if path is None: + if path is None and fspath is None: path = getattr(parent, "path", None) - assert path is not None #: Filesystem path where this node was collected from (can be None). - self.path = path + self.path: pathlib.Path = _imply_path(type(self), path, fspath=fspath) # The explicit annotation is to avoid publicly exposing NodeKeywords. #: Keywords/markers collected from all scopes. @@ -529,6 +563,7 @@ class FSCollector(Collector, abc.ABC): def __init__( self, + fspath: Optional[LEGACY_PATH] = None, path_or_parent: Optional[Union[Path, Node]] = None, path: Optional[Path] = None, name: Optional[str] = None, @@ -544,8 +579,8 @@ class FSCollector(Collector, abc.ABC): elif isinstance(path_or_parent, Path): assert path is None path = path_or_parent - assert path is not None + path = _imply_path(type(self), path, fspath=fspath) if name is None: name = path.name if parent is not None and parent.path != path: @@ -585,11 +620,12 @@ class FSCollector(Collector, abc.ABC): cls, parent, *, + fspath: Optional[LEGACY_PATH] = None, path: Optional[Path] = None, **kw, ) -> "Self": """The public constructor.""" - return super().from_parent(parent=parent, path=path, **kw) + return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) class File(FSCollector, abc.ABC): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a28c3befb..fce2078cd 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -48,6 +48,7 @@ from _pytest.compat import getimfunc 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 NOTSET from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass @@ -665,6 +666,7 @@ class Package(nodes.Directory): def __init__( self, + fspath: Optional[LEGACY_PATH], parent: nodes.Collector, # NOTE: following args are unused: config=None, @@ -676,6 +678,7 @@ class Package(nodes.Directory): # super().__init__(self, fspath, parent=parent) session = parent.session super().__init__( + fspath=fspath, path=path, parent=parent, config=config, diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index a5f513063..2be4d6dfc 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,5 +1,10 @@ # mypy: allow-untyped-defs +from pathlib import Path +import re +import sys + from _pytest import deprecated +from _pytest.compat import legacy_path from _pytest.pytester import Pytester import pytest from pytest import PytestDeprecationWarning @@ -85,6 +90,56 @@ def test_private_is_deprecated() -> None: PrivateInit(10, _ispytest=True) +@pytest.mark.parametrize("hooktype", ["hook", "ihook"]) +def test_hookproxy_warnings_for_pathlib(tmp_path, hooktype, request): + path = legacy_path(tmp_path) + + PATH_WARN_MATCH = r".*path: py\.path\.local\) argument is deprecated, please use \(collection_path: pathlib\.Path.*" + if hooktype == "ihook": + hooks = request.node.ihook + else: + hooks = request.config.hook + + with pytest.warns(PytestDeprecationWarning, match=PATH_WARN_MATCH) as r: + l1 = sys._getframe().f_lineno + hooks.pytest_ignore_collect( + config=request.config, path=path, collection_path=tmp_path + ) + l2 = sys._getframe().f_lineno + + (record,) = r + assert record.filename == __file__ + assert l1 < record.lineno < l2 + + hooks.pytest_ignore_collect(config=request.config, collection_path=tmp_path) + + # Passing entirely *different* paths is an outright error. + with pytest.raises(ValueError, match=r"path.*fspath.*need to be equal"): + with pytest.warns(PytestDeprecationWarning, match=PATH_WARN_MATCH) as r: + hooks.pytest_ignore_collect( + config=request.config, path=path, collection_path=Path("/bla/bla") + ) + + +def test_node_ctor_fspath_argument_is_deprecated(pytester: Pytester) -> None: + mod = pytester.getmodulecol("") + + class MyFile(pytest.File): + def collect(self): + raise NotImplementedError() + + with pytest.warns( + pytest.PytestDeprecationWarning, + match=re.escape( + "The (fspath: py.path.local) argument to MyFile is deprecated." + ), + ): + MyFile.from_parent( + parent=mod.parent, + fspath=legacy_path("bla"), + ) + + def test_fixture_disallow_on_marked_functions(): """Test that applying @pytest.fixture to a marked function warns (#3364).""" with pytest.warns( diff --git a/testing/test_legacypath.py b/testing/test_legacypath.py index 850f14c58..49e620c11 100644 --- a/testing/test_legacypath.py +++ b/testing/test_legacypath.py @@ -1,8 +1,8 @@ # mypy: allow-untyped-defs from pathlib import Path +from _pytest.compat import LEGACY_PATH from _pytest.fixtures import TopRequest -from _pytest.legacypath import LEGACY_PATH from _pytest.legacypath import TempdirFactory from _pytest.legacypath import Testdir import pytest @@ -16,7 +16,7 @@ def test_item_fspath(pytester: pytest.Pytester) -> None: items2, hookrec = pytester.inline_genitems(item.nodeid) (item2,) = items2 assert item2.name == item.name - assert item2.fspath == item.fspath # type: ignore[attr-defined] + assert item2.fspath == item.fspath assert item2.path == item.path diff --git a/testing/test_nodes.py b/testing/test_nodes.py index e019f163c..a3caf471f 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -6,6 +6,7 @@ from typing import Type import warnings from _pytest import nodes +from _pytest.compat import legacy_path from _pytest.outcomes import OutcomeException from _pytest.pytester import Pytester from _pytest.warning_types import PytestWarning @@ -44,9 +45,9 @@ def test_subclassing_both_item_and_collector_deprecated( warnings.simplefilter("error") class SoWrong(nodes.Item, nodes.File): - def __init__(self, path, parent): + def __init__(self, fspath, parent): """Legacy ctor with legacy call # don't wana see""" - super().__init__(parent, path) + super().__init__(fspath, parent) def collect(self): raise NotImplementedError() @@ -55,7 +56,9 @@ def test_subclassing_both_item_and_collector_deprecated( raise NotImplementedError() with pytest.warns(PytestWarning) as rec: - SoWrong.from_parent(request.session, path=tmp_path / "broken.txt", wrong=10) + SoWrong.from_parent( + request.session, fspath=legacy_path(tmp_path / "broken.txt") + ) messages = [str(x.message) for x in rec] assert any( re.search(".*SoWrong.* not using a cooperative constructor.*", x)