Skip to content

Commit f7d839d

Browse files
deprecate hook configuration via marks/attributes
fixes #4562
1 parent 4483925 commit f7d839d

File tree

5 files changed

+117
-13
lines changed

5 files changed

+117
-13
lines changed

changelog/4562.deprecation.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Deprecate configureing hook specs/impls using attribute/marks.
2+
Instead ``use pytest.hookimpl`` and ``pytest.hookspec``.

doc/en/deprecations.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,36 @@ Deprecated Features
1818
Below is a complete list of all pytest features which are considered deprecated. Using those features will issue
1919
:class:`PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
2020

21+
configuring hook specs/impls using markers
22+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
23+
24+
Before pluggy was a own package and had a clear api,
25+
pytest just used ``pytest.mark`` to configure hooks.
26+
27+
The :ref:`pytest.hookimpl` and :ref:`pytest.hookspec` decorators
28+
have been available since years and should be used.
29+
30+
.. code-block:: python
31+
32+
@pytest.mark.tryfirst
33+
def pytest_runtest_call():
34+
...
35+
36+
37+
# or
38+
def pytest_runtest_call():
39+
...
40+
41+
42+
pytest_runtest_call.tryfirst = True
43+
44+
# becomes
45+
46+
47+
@pytest.hookimpl(tryfirst=True)
48+
def pytest_runtest_call():
49+
...
50+
2151
2252
``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
2353
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/_pytest/config/__init__.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,40 +392,57 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
392392
if name == "pytest_plugins":
393393
return
394394

395-
method = getattr(plugin, name)
396395
opts = super().parse_hookimpl_opts(plugin, name)
396+
if opts is not None:
397+
return opts
397398

399+
method = getattr(plugin, name)
398400
# Consider only actual functions for hooks (#3775).
399401
if not inspect.isroutine(method):
400402
return
401-
402403
# Collect unmarked hooks as long as they have the `pytest_' prefix.
403-
if opts is None and name.startswith("pytest_"):
404-
opts = {}
405-
if opts is not None:
406-
# TODO: DeprecationWarning, people should use hookimpl
407-
# https://github.com/pytest-dev/pytest/issues/4562
408-
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
409404

410-
for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"):
411-
opts.setdefault(name, hasattr(method, name) or name in known_marks)
405+
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
406+
opts = {
407+
name: hasattr(method, name) or name in known_marks
408+
for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper")
409+
}
410+
if any(opts.values()):
411+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
412+
type="spec", fullname=method.__qualname__
413+
)
414+
warnings.warn_explicit(
415+
message,
416+
type(message),
417+
filename=inspect.getfile(method),
418+
lineno=method.__code__.co_firstlineno,
419+
)
412420
return opts
413421

414422
def parse_hookspec_opts(self, module_or_class, name: str):
415423
opts = super().parse_hookspec_opts(module_or_class, name)
416424
if opts is None:
417425
method = getattr(module_or_class, name)
418-
419426
if name.startswith("pytest_"):
420-
# todo: deprecate hookspec hacks
421-
# https://github.com/pytest-dev/pytest/issues/4562
427+
422428
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
423429
opts = {
424430
"firstresult": hasattr(method, "firstresult")
425431
or "firstresult" in known_marks,
426432
"historic": hasattr(method, "historic")
427433
or "historic" in known_marks,
428434
}
435+
if any(opts.values()):
436+
437+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
438+
type="spec", fullname=method.__qualname__
439+
)
440+
warnings.warn_explicit(
441+
message,
442+
type(message),
443+
filename=inspect.getfile(method),
444+
lineno=method.__code__.co_firstlineno,
445+
)
429446
return opts
430447

431448
def register(

src/_pytest/deprecated.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@
106106
" Replace pytest.warns(None) by simply pytest.warns()."
107107
)
108108

109+
110+
HOOK_LEGACY_MARKING = UnformattedWarning(
111+
PytestDeprecationWarning,
112+
"The hook {type} {fullname} is not marked using pytest.hook{type}.\n"
113+
" please use the pytest.hook{type}(...) decorator instead of pytest.mark \n"
114+
" to correctly mark hooks as pytest hooks.\n"
115+
" see URL",
116+
)
117+
109118
# You want to make some `__init__` or function "private".
110119
#
111120
# def my_private_function(some, args):

testing/deprecated_test.py

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

5353

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

0 commit comments

Comments
 (0)