Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

deprecate hook configuration via marks/attributes #9118

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/4562.deprecation.rst
@@ -0,0 +1,4 @@
Deprecate configuring hook specs/impls using attributes/marks.
RonnyPfannschmidt marked this conversation as resolved.
Show resolved Hide resolved

Instead use :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec`.
For more details, see the :ref:`docs <legacy-path-hooks-deprecated>`.
44 changes: 44 additions & 0 deletions doc/en/deprecations.rst
Expand Up @@ -78,6 +78,50 @@ no matter what argument was used in the constructor. We expect to deprecate the

.. _legacy-path-hooks-deprecated:

Configuring hook specs/impls using markers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Before pluggy, pytest's plugin library, was its own package and had a clear API,
pytest just used ``pytest.mark`` to configure hooks.

The :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec` decorators
have been available since years and should be used instead.

.. code-block:: python
@pytest.mark.tryfirst
def pytest_runtest_call():
...
# or
def pytest_runtest_call():
...
pytest_runtest_call.tryfirst = True
should be changed to:

.. code-block:: python
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_call():
...
Changed ``hookimpl`` attributes:

* ``tryfirst``
* ``trylast``
* ``optionalhook``
* ``hookwrapper``

Changed ``hookwrapper`` attributes:

* ``firstresult``
* ``historic``


``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
12 changes: 0 additions & 12 deletions extra/setup-py.test/setup.py

This file was deleted.

3 changes: 3 additions & 0 deletions pyproject.toml
Expand Up @@ -37,6 +37,9 @@ filterwarnings = [
# Those are caught/handled by pyupgrade, and not easy to filter with the
# module being the filename (with .py removed).
"default:invalid escape sequence:DeprecationWarning",
# ignore not yet fixed warnings for hook markers
"default:.*not marked using pytest.hook.*",
"ignore:.*not marked using pytest.hook.*::xdist.*",
# ignore use of unregistered marks, because we use many to test the implementation
"ignore::_pytest.warning_types.PytestUnknownMarkWarning",
# https://github.com/benjaminp/six/issues/341
Expand Down
67 changes: 45 additions & 22 deletions src/_pytest/config/__init__.py
Expand Up @@ -14,6 +14,7 @@
from functools import lru_cache
from pathlib import Path
from textwrap import dedent
from types import FunctionType
from types import TracebackType
from typing import Any
from typing import Callable
Expand Down Expand Up @@ -58,6 +59,7 @@
from _pytest.pathlib import resolve_package_path
from _pytest.stash import Stash
from _pytest.warning_types import PytestConfigWarning
from _pytest.warning_types import warn_explicit_for

if TYPE_CHECKING:

Expand Down Expand Up @@ -341,6 +343,38 @@ def _get_directory(path: Path) -> Path:
return path


def _get_legacy_hook_marks(
nicoddemus marked this conversation as resolved.
Show resolved Hide resolved
method: Any,
hook_type: str,
opt_names: Tuple[str, ...],
) -> Dict[str, bool]:
if TYPE_CHECKING:
# abuse typeguard from importlib to avoid massive method type union thats lacking a alias
assert inspect.isroutine(method)
known_marks: set[str] = {m.name for m in getattr(method, "pytestmark", [])}
must_warn: list[str] = []
opts: dict[str, bool] = {}
for opt_name in opt_names:
opt_attr = getattr(method, opt_name, AttributeError)
if opt_attr is not AttributeError:
must_warn.append(f"{opt_name}={opt_attr}")
opts[opt_name] = True
elif opt_name in known_marks:
must_warn.append(f"{opt_name}=True")
opts[opt_name] = True
else:
opts[opt_name] = False
if must_warn:
hook_opts = ", ".join(must_warn)
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
type=hook_type,
fullname=method.__qualname__,
hook_opts=hook_opts,
)
warn_explicit_for(cast(FunctionType, method), message)
return opts


@final
class PytestPluginManager(PluginManager):
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
Expand Down Expand Up @@ -414,40 +448,29 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
if name == "pytest_plugins":
return

method = getattr(plugin, name)
opts = super().parse_hookimpl_opts(plugin, name)
if opts is not None:
return opts

method = getattr(plugin, name)
# Consider only actual functions for hooks (#3775).
if not inspect.isroutine(method):
return

# Collect unmarked hooks as long as they have the `pytest_' prefix.
if opts is None and name.startswith("pytest_"):
opts = {}
if opts is not None:
# TODO: DeprecationWarning, people should use hookimpl
# https://github.com/pytest-dev/pytest/issues/4562
known_marks = {m.name for m in getattr(method, "pytestmark", [])}

for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"):
opts.setdefault(name, hasattr(method, name) or name in known_marks)
return opts
return _get_legacy_hook_marks(
method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
)

def parse_hookspec_opts(self, module_or_class, name: str):
opts = super().parse_hookspec_opts(module_or_class, name)
if opts is None:
method = getattr(module_or_class, name)

if name.startswith("pytest_"):
# todo: deprecate hookspec hacks
# https://github.com/pytest-dev/pytest/issues/4562
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
opts = {
"firstresult": hasattr(method, "firstresult")
or "firstresult" in known_marks,
"historic": hasattr(method, "historic")
or "historic" in known_marks,
}
opts = _get_legacy_hook_marks(
method,
"spec",
("firstresult", "historic"),
)
return opts

def register(
Expand Down
8 changes: 8 additions & 0 deletions src/_pytest/deprecated.py
Expand Up @@ -98,6 +98,14 @@
"The pytest.Instance collector type is deprecated and is no longer used. "
"See https://docs.pytest.org/en/latest/deprecations.html#the-pytest-instance-collector",
)
HOOK_LEGACY_MARKING = UnformattedWarning(
PytestDeprecationWarning,
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
"Please use the pytest.hook{type}({hook_opts}) decorator instead\n"
" to configure the hooks.\n"
" See https://docs.pytest.org/en/latest/deprecations.html"
"#configuring-hook-specs-impls-using-markers",
)

# You want to make some `__init__` or function "private".
#
Expand Down
25 changes: 25 additions & 0 deletions src/_pytest/warning_types.py
@@ -1,3 +1,6 @@
import inspect
import warnings
from types import FunctionType
from typing import Any
from typing import Generic
from typing import Type
Expand Down Expand Up @@ -142,3 +145,25 @@ class UnformattedWarning(Generic[_W]):
def format(self, **kwargs: Any) -> _W:
"""Return an instance of the warning category, formatted with given kwargs."""
return self.category(self.template.format(**kwargs))


def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
nicoddemus marked this conversation as resolved.
Show resolved Hide resolved
"""
Issue the warning :param:`message` for the definition of the given :param:`method`
this helps to log warnigns for functions defined prior to finding an issue with them
(like hook wrappers being marked in a legacy mechanism)
"""
lineno = method.__code__.co_firstlineno
filename = inspect.getfile(method)
module = method.__module__
mod_globals = method.__globals__

warnings.warn_explicit(
message,
type(message),
filename=filename,
module=module,
registry=mod_globals.setdefault("__warningregistry__", {}),
lineno=lineno,
)
48 changes: 48 additions & 0 deletions testing/deprecated_test.py
Expand Up @@ -20,6 +20,54 @@ def test_external_plugins_integrated(pytester: Pytester, plugin) -> None:
pytester.parseconfig("-p", plugin)


def test_hookspec_via_function_attributes_are_deprecated():
from _pytest.config import PytestPluginManager

pm = PytestPluginManager()

class DeprecatedHookMarkerSpec:
def pytest_bad_hook(self):
pass

pytest_bad_hook.historic = False # type: ignore[attr-defined]

with pytest.warns(
PytestDeprecationWarning,
match=r"Please use the pytest\.hookspec\(historic=False\) decorator",
) as recorder:
pm.add_hookspecs(DeprecatedHookMarkerSpec)
(record,) = recorder
assert (
record.lineno
== DeprecatedHookMarkerSpec.pytest_bad_hook.__code__.co_firstlineno
)
assert record.filename == __file__


def test_hookimpl_via_function_attributes_are_deprecated():
from _pytest.config import PytestPluginManager

pm = PytestPluginManager()

class DeprecatedMarkImplPlugin:
def pytest_runtest_call(self):
pass

pytest_runtest_call.tryfirst = True # type: ignore[attr-defined]

with pytest.warns(
PytestDeprecationWarning,
match=r"Please use the pytest.hookimpl\(tryfirst=True\)",
) as recorder:
pm.register(DeprecatedMarkImplPlugin())
(record,) = recorder
assert (
record.lineno
== DeprecatedMarkImplPlugin.pytest_runtest_call.__code__.co_firstlineno
)
assert record.filename == __file__


def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None:
module = pytester.getmodulecol(
"""
Expand Down
2 changes: 1 addition & 1 deletion testing/plugins_integration/requirements.txt
Expand Up @@ -2,7 +2,7 @@ anyio[curio,trio]==3.6.1
django==4.1.1
pytest-asyncio==0.19.0
pytest-bdd==6.0.1
pytest-cov==3.0.0
pytest-cov==4.0.0
pytest-django==4.5.2
pytest-flakes==4.0.5
pytest-html==3.1.1
Expand Down