Skip to content

Commit 2be1b8f

Browse files
Merge pull request #9118 from RonnyPfannschmidt/fix-4562-deprecate-hookmarkers
deprecate hook configuration via marks/attributes
2 parents 59b8ec3 + ce3e2e9 commit 2be1b8f

File tree

9 files changed

+178
-35
lines changed

9 files changed

+178
-35
lines changed

changelog/4562.deprecation.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Deprecate configuring hook specs/impls using attributes/marks.
2+
3+
Instead use :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec`.
4+
For more details, see the :ref:`docs <legacy-path-hooks-deprecated>`.

doc/en/deprecations.rst

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,50 @@ no matter what argument was used in the constructor. We expect to deprecate the
7878

7979
.. _legacy-path-hooks-deprecated:
8080

81+
Configuring hook specs/impls using markers
82+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
83+
84+
Before pluggy, pytest's plugin library, was its own package and had a clear API,
85+
pytest just used ``pytest.mark`` to configure hooks.
86+
87+
The :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec` decorators
88+
have been available since years and should be used instead.
89+
90+
.. code-block:: python
91+
92+
@pytest.mark.tryfirst
93+
def pytest_runtest_call():
94+
...
95+
96+
97+
# or
98+
def pytest_runtest_call():
99+
...
100+
101+
102+
pytest_runtest_call.tryfirst = True
103+
104+
should be changed to:
105+
106+
.. code-block:: python
107+
108+
@pytest.hookimpl(tryfirst=True)
109+
def pytest_runtest_call():
110+
...
111+
112+
Changed ``hookimpl`` attributes:
113+
114+
* ``tryfirst``
115+
* ``trylast``
116+
* ``optionalhook``
117+
* ``hookwrapper``
118+
119+
Changed ``hookwrapper`` attributes:
120+
121+
* ``firstresult``
122+
* ``historic``
123+
124+
81125
``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
82126
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
83127

extra/setup-py.test/setup.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ filterwarnings = [
3737
# Those are caught/handled by pyupgrade, and not easy to filter with the
3838
# module being the filename (with .py removed).
3939
"default:invalid escape sequence:DeprecationWarning",
40+
# ignore not yet fixed warnings for hook markers
41+
"default:.*not marked using pytest.hook.*",
42+
"ignore:.*not marked using pytest.hook.*::xdist.*",
4043
# ignore use of unregistered marks, because we use many to test the implementation
4144
"ignore::_pytest.warning_types.PytestUnknownMarkWarning",
4245
# https://github.com/benjaminp/six/issues/341

src/_pytest/config/__init__.py

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from functools import lru_cache
1515
from pathlib import Path
1616
from textwrap import dedent
17+
from types import FunctionType
1718
from types import TracebackType
1819
from typing import Any
1920
from typing import Callable
@@ -58,6 +59,7 @@
5859
from _pytest.pathlib import resolve_package_path
5960
from _pytest.stash import Stash
6061
from _pytest.warning_types import PytestConfigWarning
62+
from _pytest.warning_types import warn_explicit_for
6163

6264
if TYPE_CHECKING:
6365

@@ -341,6 +343,38 @@ def _get_directory(path: Path) -> Path:
341343
return path
342344

343345

346+
def _get_legacy_hook_marks(
347+
method: Any,
348+
hook_type: str,
349+
opt_names: Tuple[str, ...],
350+
) -> Dict[str, bool]:
351+
if TYPE_CHECKING:
352+
# abuse typeguard from importlib to avoid massive method type union thats lacking a alias
353+
assert inspect.isroutine(method)
354+
known_marks: set[str] = {m.name for m in getattr(method, "pytestmark", [])}
355+
must_warn: list[str] = []
356+
opts: dict[str, bool] = {}
357+
for opt_name in opt_names:
358+
opt_attr = getattr(method, opt_name, AttributeError)
359+
if opt_attr is not AttributeError:
360+
must_warn.append(f"{opt_name}={opt_attr}")
361+
opts[opt_name] = True
362+
elif opt_name in known_marks:
363+
must_warn.append(f"{opt_name}=True")
364+
opts[opt_name] = True
365+
else:
366+
opts[opt_name] = False
367+
if must_warn:
368+
hook_opts = ", ".join(must_warn)
369+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
370+
type=hook_type,
371+
fullname=method.__qualname__,
372+
hook_opts=hook_opts,
373+
)
374+
warn_explicit_for(cast(FunctionType, method), message)
375+
return opts
376+
377+
344378
@final
345379
class PytestPluginManager(PluginManager):
346380
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
@@ -414,40 +448,29 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
414448
if name == "pytest_plugins":
415449
return
416450

417-
method = getattr(plugin, name)
418451
opts = super().parse_hookimpl_opts(plugin, name)
452+
if opts is not None:
453+
return opts
419454

455+
method = getattr(plugin, name)
420456
# Consider only actual functions for hooks (#3775).
421457
if not inspect.isroutine(method):
422458
return
423-
424459
# Collect unmarked hooks as long as they have the `pytest_' prefix.
425-
if opts is None and name.startswith("pytest_"):
426-
opts = {}
427-
if opts is not None:
428-
# TODO: DeprecationWarning, people should use hookimpl
429-
# https://github.com/pytest-dev/pytest/issues/4562
430-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
431-
432-
for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"):
433-
opts.setdefault(name, hasattr(method, name) or name in known_marks)
434-
return opts
460+
return _get_legacy_hook_marks(
461+
method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
462+
)
435463

436464
def parse_hookspec_opts(self, module_or_class, name: str):
437465
opts = super().parse_hookspec_opts(module_or_class, name)
438466
if opts is None:
439467
method = getattr(module_or_class, name)
440-
441468
if name.startswith("pytest_"):
442-
# todo: deprecate hookspec hacks
443-
# https://github.com/pytest-dev/pytest/issues/4562
444-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
445-
opts = {
446-
"firstresult": hasattr(method, "firstresult")
447-
or "firstresult" in known_marks,
448-
"historic": hasattr(method, "historic")
449-
or "historic" in known_marks,
450-
}
469+
opts = _get_legacy_hook_marks(
470+
method,
471+
"spec",
472+
("firstresult", "historic"),
473+
)
451474
return opts
452475

453476
def register(

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@
9898
"The pytest.Instance collector type is deprecated and is no longer used. "
9999
"See https://docs.pytest.org/en/latest/deprecations.html#the-pytest-instance-collector",
100100
)
101+
HOOK_LEGACY_MARKING = UnformattedWarning(
102+
PytestDeprecationWarning,
103+
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
104+
"Please use the pytest.hook{type}({hook_opts}) decorator instead\n"
105+
" to configure the hooks.\n"
106+
" See https://docs.pytest.org/en/latest/deprecations.html"
107+
"#configuring-hook-specs-impls-using-markers",
108+
)
101109

102110
# You want to make some `__init__` or function "private".
103111
#

src/_pytest/warning_types.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import inspect
2+
import warnings
3+
from types import FunctionType
14
from typing import Any
25
from typing import Generic
36
from typing import Type
@@ -142,3 +145,25 @@ class UnformattedWarning(Generic[_W]):
142145
def format(self, **kwargs: Any) -> _W:
143146
"""Return an instance of the warning category, formatted with given kwargs."""
144147
return self.category(self.template.format(**kwargs))
148+
149+
150+
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
151+
"""
152+
Issue the warning :param:`message` for the definition of the given :param:`method`
153+
154+
this helps to log warnigns for functions defined prior to finding an issue with them
155+
(like hook wrappers being marked in a legacy mechanism)
156+
"""
157+
lineno = method.__code__.co_firstlineno
158+
filename = inspect.getfile(method)
159+
module = method.__module__
160+
mod_globals = method.__globals__
161+
162+
warnings.warn_explicit(
163+
message,
164+
type(message),
165+
filename=filename,
166+
module=module,
167+
registry=mod_globals.setdefault("__warningregistry__", {}),
168+
lineno=lineno,
169+
)

testing/deprecated_test.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,54 @@ def test_external_plugins_integrated(pytester: Pytester, plugin) -> None:
2020
pytester.parseconfig("-p", plugin)
2121

2222

23+
def test_hookspec_via_function_attributes_are_deprecated():
24+
from _pytest.config import PytestPluginManager
25+
26+
pm = PytestPluginManager()
27+
28+
class DeprecatedHookMarkerSpec:
29+
def pytest_bad_hook(self):
30+
pass
31+
32+
pytest_bad_hook.historic = False # type: ignore[attr-defined]
33+
34+
with pytest.warns(
35+
PytestDeprecationWarning,
36+
match=r"Please use the pytest\.hookspec\(historic=False\) decorator",
37+
) as recorder:
38+
pm.add_hookspecs(DeprecatedHookMarkerSpec)
39+
(record,) = recorder
40+
assert (
41+
record.lineno
42+
== DeprecatedHookMarkerSpec.pytest_bad_hook.__code__.co_firstlineno
43+
)
44+
assert record.filename == __file__
45+
46+
47+
def test_hookimpl_via_function_attributes_are_deprecated():
48+
from _pytest.config import PytestPluginManager
49+
50+
pm = PytestPluginManager()
51+
52+
class DeprecatedMarkImplPlugin:
53+
def pytest_runtest_call(self):
54+
pass
55+
56+
pytest_runtest_call.tryfirst = True # type: ignore[attr-defined]
57+
58+
with pytest.warns(
59+
PytestDeprecationWarning,
60+
match=r"Please use the pytest.hookimpl\(tryfirst=True\)",
61+
) as recorder:
62+
pm.register(DeprecatedMarkImplPlugin())
63+
(record,) = recorder
64+
assert (
65+
record.lineno
66+
== DeprecatedMarkImplPlugin.pytest_runtest_call.__code__.co_firstlineno
67+
)
68+
assert record.filename == __file__
69+
70+
2371
def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None:
2472
module = pytester.getmodulecol(
2573
"""

testing/plugins_integration/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ anyio[curio,trio]==3.6.1
22
django==4.1.1
33
pytest-asyncio==0.19.0
44
pytest-bdd==6.0.1
5-
pytest-cov==3.0.0
5+
pytest-cov==4.0.0
66
pytest-django==4.5.2
77
pytest-flakes==4.0.5
88
pytest-html==3.1.1

0 commit comments

Comments
 (0)