Skip to content

Commit 80ea04e

Browse files
deprecate hook configuration via marks/attributes
fixes #4562
1 parent c9e312e commit 80ea04e

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
@@ -59,6 +59,38 @@ drop any other usage of the ``py`` library if possible.
5959

6060
.. _legacy-path-hooks-deprecated:
6161

62+
configuring hook specs/impls using markers
63+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
64+
65+
Before pluggy, pytest's plugin library, was its own package and had a clear API,
66+
pytest just used ``pytest.mark`` to configure hooks.
67+
68+
The :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec` decorators
69+
have been available since years and should be used instead.
70+
71+
.. code-block:: python
72+
73+
@pytest.mark.tryfirst
74+
def pytest_runtest_call():
75+
...
76+
77+
78+
# or
79+
def pytest_runtest_call():
80+
...
81+
82+
83+
pytest_runtest_call.tryfirst = True
84+
85+
should be changed to:
86+
87+
.. code-block:: python
88+
89+
@pytest.hookimpl(tryfirst=True)
90+
def pytest_runtest_call():
91+
...
92+
93+
6294
``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
6395
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6496

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ filterwarnings = [
4040
# Those are caught/handled by pyupgrade, and not easy to filter with the
4141
# module being the filename (with .py removed).
4242
"default:invalid escape sequence:DeprecationWarning",
43+
# ignore not yet fixed warnings for hook markers
44+
"default:.*not marked using pytest.hook.*",
45+
"ignore:.*not marked using pytest.hook.*::xdist.*",
4346
# ignore use of unregistered marks, because we use many to test the implementation
4447
"ignore::_pytest.warning_types.PytestUnknownMarkWarning",
4548
# 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
@@ -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

@@ -331,6 +333,32 @@ def _prepareconfig(
331333
raise
332334

333335

336+
def _get_legacy_hook_marks(
337+
method: FunctionType,
338+
hook_type: str,
339+
opt_names: Tuple[str, ...],
340+
) -> Dict[str, bool]:
341+
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
342+
must_warn = False
343+
opts = {}
344+
for opt_name in opt_names:
345+
if hasattr(method, opt_name) or opt_name in known_marks:
346+
opts[opt_name] = True
347+
must_warn = True
348+
else:
349+
opts[opt_name] = False
350+
if must_warn:
351+
352+
hook_opts = ", ".join(f"{name}=True" for name, val in opts.items() if val)
353+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
354+
type=hook_type,
355+
fullname=method.__qualname__,
356+
hook_opts=hook_opts,
357+
)
358+
warn_explicit_for(method, message)
359+
return opts
360+
361+
334362
@final
335363
class PytestPluginManager(PluginManager):
336364
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
@@ -394,40 +422,29 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
394422
if name == "pytest_plugins":
395423
return
396424

397-
method = getattr(plugin, name)
398425
opts = super().parse_hookimpl_opts(plugin, name)
426+
if opts is not None:
427+
return opts
399428

429+
method = getattr(plugin, name)
400430
# Consider only actual functions for hooks (#3775).
401431
if not inspect.isroutine(method):
402432
return
403-
404433
# Collect unmarked hooks as long as they have the `pytest_' prefix.
405-
if opts is None and name.startswith("pytest_"):
406-
opts = {}
407-
if opts is not None:
408-
# TODO: DeprecationWarning, people should use hookimpl
409-
# https://github.com/pytest-dev/pytest/issues/4562
410-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
411-
412-
for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"):
413-
opts.setdefault(name, hasattr(method, name) or name in known_marks)
414-
return opts
434+
return _get_legacy_hook_marks(
435+
method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
436+
)
415437

416438
def parse_hookspec_opts(self, module_or_class, name: str):
417439
opts = super().parse_hookspec_opts(module_or_class, name)
418440
if opts is None:
419441
method = getattr(module_or_class, name)
420-
421442
if name.startswith("pytest_"):
422-
# todo: deprecate hookspec hacks
423-
# https://github.com/pytest-dev/pytest/issues/4562
424-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
425-
opts = {
426-
"firstresult": hasattr(method, "firstresult")
427-
or "firstresult" in known_marks,
428-
"historic": hasattr(method, "historic")
429-
or "historic" in known_marks,
430-
}
443+
opts = _get_legacy_hook_marks(
444+
method,
445+
"spec",
446+
("firstresult", "historic"),
447+
)
431448
return opts
432449

433450
def register(

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@
122122
INSTANCE_COLLECTOR = PytestDeprecationWarning(
123123
"The pytest.Instance collector type is deprecated and is no longer used. "
124124
"See https://docs.pytest.org/en/latest/deprecations.html#the-pytest-instance-collector",
125+
126+
HOOK_LEGACY_MARKING = UnformattedWarning(
127+
PytestDeprecationWarning,
128+
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
129+
"Please use the pytest.hook{type}({hook_opts}) decorator instead\n"
130+
" to configure the hooks.\n"
131+
" See https://docs.pytest.org/en/latest/deprecations.html"
132+
"#configuring-hook-specs-impls-using-markers",
125133
)
126134

127135
# 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
@@ -130,3 +133,19 @@ class UnformattedWarning(Generic[_W]):
130133
def format(self, **kwargs: Any) -> _W:
131134
"""Return an instance of the warning category, formatted with given kwargs."""
132135
return self.category(self.template.format(**kwargs))
136+
137+
138+
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
139+
lineno = method.__code__.co_firstlineno
140+
filename = inspect.getfile(method)
141+
module = method.__module__
142+
mod_globals = method.__globals__
143+
144+
warnings.warn_explicit(
145+
message,
146+
type(message),
147+
filename=filename,
148+
module=module,
149+
registry=mod_globals.setdefault("__warningregistry__", {}),
150+
lineno=lineno,
151+
)

testing/deprecated_test.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,52 @@ def test_fillfixtures_is_deprecated() -> None:
5252
_pytest.fixtures.fillfixtures(mock.Mock())
5353

5454

55+
def test_hookspec_via_function_attributes_are_deprecated():
56+
from _pytest.config import PytestPluginManager
57+
58+
pm = PytestPluginManager()
59+
60+
class DeprecatedHookMarkerSpec:
61+
def pytest_bad_hook(self):
62+
pass
63+
64+
pytest_bad_hook.historic = True # type: ignore[attr-defined]
65+
66+
with pytest.warns(
67+
PytestDeprecationWarning, match="instead of pytest.mark"
68+
) as recorder:
69+
pm.add_hookspecs(DeprecatedHookMarkerSpec)
70+
(record,) = recorder
71+
assert (
72+
record.lineno
73+
== DeprecatedHookMarkerSpec.pytest_bad_hook.__code__.co_firstlineno
74+
)
75+
assert record.filename == __file__
76+
77+
78+
def test_hookimpl_via_function_attributes_are_deprecated():
79+
from _pytest.config import PytestPluginManager
80+
81+
pm = PytestPluginManager()
82+
83+
class DeprecatedMarkImplPlugin:
84+
def pytest_runtest_call(self):
85+
pass
86+
87+
pytest_runtest_call.tryfirst = True # type: ignore[attr-defined]
88+
89+
with pytest.warns(
90+
PytestDeprecationWarning, match="instead of pytest.mark"
91+
) as recorder:
92+
pm.register(DeprecatedMarkImplPlugin())
93+
(record,) = recorder
94+
assert (
95+
record.lineno
96+
== DeprecatedMarkImplPlugin.pytest_runtest_call.__code__.co_firstlineno
97+
)
98+
assert record.filename == __file__
99+
100+
55101
def test_minus_k_dash_is_deprecated(pytester: Pytester) -> None:
56102
threepass = pytester.makepyfile(
57103
test_threepass="""

0 commit comments

Comments
 (0)