Skip to content

Commit 9eb8d8e

Browse files
deprecate hook configuration via marks/attributes
fixes #4562
1 parent 2671077 commit 9eb8d8e

File tree

7 files changed

+151
-22
lines changed

7 files changed

+151
-22
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 <configuring-hook-specs-impls-using-markers>`.

doc/en/deprecations.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,38 @@ 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+
81113
``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
82114
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
83115

pyproject.toml

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

src/_pytest/config/__init__.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from functools import lru_cache
1414
from pathlib import Path
1515
from textwrap import dedent
16+
from types import FunctionType
1617
from types import TracebackType
1718
from typing import Any
1819
from typing import Callable
@@ -57,6 +58,7 @@
5758
from _pytest.pathlib import resolve_package_path
5859
from _pytest.stash import Stash
5960
from _pytest.warning_types import PytestConfigWarning
61+
from _pytest.warning_types import warn_explicit_for
6062

6163
if TYPE_CHECKING:
6264

@@ -338,6 +340,32 @@ def _get_directory(path: Path) -> Path:
338340
return path
339341

340342

343+
def _get_legacy_hook_marks(
344+
method: FunctionType,
345+
hook_type: str,
346+
opt_names: Tuple[str, ...],
347+
) -> Dict[str, bool]:
348+
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
349+
must_warn = False
350+
opts = {}
351+
for opt_name in opt_names:
352+
if hasattr(method, opt_name) or opt_name in known_marks:
353+
opts[opt_name] = True
354+
must_warn = True
355+
else:
356+
opts[opt_name] = False
357+
if must_warn:
358+
359+
hook_opts = ", ".join(f"{name}=True" for name, val in opts.items() if val)
360+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
361+
type=hook_type,
362+
fullname=method.__qualname__,
363+
hook_opts=hook_opts,
364+
)
365+
warn_explicit_for(method, message)
366+
return opts
367+
368+
341369
@final
342370
class PytestPluginManager(PluginManager):
343371
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
@@ -411,40 +439,29 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
411439
if name == "pytest_plugins":
412440
return
413441

414-
method = getattr(plugin, name)
415442
opts = super().parse_hookimpl_opts(plugin, name)
443+
if opts is not None:
444+
return opts
416445

446+
method = getattr(plugin, name)
417447
# Consider only actual functions for hooks (#3775).
418448
if not inspect.isroutine(method):
419449
return
420-
421450
# Collect unmarked hooks as long as they have the `pytest_' prefix.
422-
if opts is None and name.startswith("pytest_"):
423-
opts = {}
424-
if opts is not None:
425-
# TODO: DeprecationWarning, people should use hookimpl
426-
# https://github.com/pytest-dev/pytest/issues/4562
427-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
428-
429-
for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"):
430-
opts.setdefault(name, hasattr(method, name) or name in known_marks)
431-
return opts
451+
return _get_legacy_hook_marks(
452+
method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
453+
)
432454

433455
def parse_hookspec_opts(self, module_or_class, name: str):
434456
opts = super().parse_hookspec_opts(module_or_class, name)
435457
if opts is None:
436458
method = getattr(module_or_class, name)
437-
438459
if name.startswith("pytest_"):
439-
# todo: deprecate hookspec hacks
440-
# https://github.com/pytest-dev/pytest/issues/4562
441-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
442-
opts = {
443-
"firstresult": hasattr(method, "firstresult")
444-
or "firstresult" in known_marks,
445-
"historic": hasattr(method, "historic")
446-
or "historic" in known_marks,
447-
}
460+
opts = _get_legacy_hook_marks(
461+
method,
462+
"spec",
463+
("firstresult", "historic"),
464+
)
448465
return opts
449466

450467
def register(

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@
9797
INSTANCE_COLLECTOR = PytestRemovedIn8Warning(
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",
100+
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",
100108
)
101109

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

src/_pytest/warning_types.py

Lines changed: 19 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
@@ -136,3 +139,19 @@ class UnformattedWarning(Generic[_W]):
136139
def format(self, **kwargs: Any) -> _W:
137140
"""Return an instance of the warning category, formatted with given kwargs."""
138141
return self.category(self.template.format(**kwargs))
142+
143+
144+
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
145+
lineno = method.__code__.co_firstlineno
146+
filename = inspect.getfile(method)
147+
module = method.__module__
148+
mod_globals = method.__globals__
149+
150+
warnings.warn_explicit(
151+
message,
152+
type(message),
153+
filename=filename,
154+
module=module,
155+
registry=mod_globals.setdefault("__warningregistry__", {}),
156+
lineno=lineno,
157+
)

testing/deprecated_test.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,52 @@ 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 = True # type: ignore[attr-defined]
33+
34+
with pytest.warns(
35+
PytestDeprecationWarning, match="instead of pytest.mark"
36+
) as recorder:
37+
pm.add_hookspecs(DeprecatedHookMarkerSpec)
38+
(record,) = recorder
39+
assert (
40+
record.lineno
41+
== DeprecatedHookMarkerSpec.pytest_bad_hook.__code__.co_firstlineno
42+
)
43+
assert record.filename == __file__
44+
45+
46+
def test_hookimpl_via_function_attributes_are_deprecated():
47+
from _pytest.config import PytestPluginManager
48+
49+
pm = PytestPluginManager()
50+
51+
class DeprecatedMarkImplPlugin:
52+
def pytest_runtest_call(self):
53+
pass
54+
55+
pytest_runtest_call.tryfirst = True # type: ignore[attr-defined]
56+
57+
with pytest.warns(
58+
PytestDeprecationWarning, match="instead of pytest.mark"
59+
) as recorder:
60+
pm.register(DeprecatedMarkImplPlugin())
61+
(record,) = recorder
62+
assert (
63+
record.lineno
64+
== DeprecatedMarkImplPlugin.pytest_runtest_call.__code__.co_firstlineno
65+
)
66+
assert record.filename == __file__
67+
68+
2369
def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None:
2470
module = pytester.getmodulecol(
2571
"""

0 commit comments

Comments
 (0)