deprecate hook configuration via marks/attributes

fixes #4562
This commit is contained in:
Ronny Pfannschmidt 2021-10-03 16:01:40 +02:00
parent 5bd41befa8
commit 0fdacb6db5
7 changed files with 165 additions and 22 deletions

View File

@ -0,0 +1,4 @@
Deprecate configuring hook specs/impls using attributes/marks.
Instead use :py:func:`pytest.hookimpl` and :py:func:`pytest.hookspec`.
For more details, see the :ref:`docs <legacy-path-hooks-deprecated>`.

View File

@ -78,6 +78,50 @@ no matter what argument was used in the constructor. We expect to deprecate the
.. _legacy-path-hooks-deprecated: .. _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`` ``py.path.local`` arguments for hooks replaced with ``pathlib.Path``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -37,6 +37,9 @@ filterwarnings = [
# Those are caught/handled by pyupgrade, and not easy to filter with the # Those are caught/handled by pyupgrade, and not easy to filter with the
# module being the filename (with .py removed). # module being the filename (with .py removed).
"default:invalid escape sequence:DeprecationWarning", "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 use of unregistered marks, because we use many to test the implementation
"ignore::_pytest.warning_types.PytestUnknownMarkWarning", "ignore::_pytest.warning_types.PytestUnknownMarkWarning",
# https://github.com/benjaminp/six/issues/341 # https://github.com/benjaminp/six/issues/341

View File

@ -14,6 +14,7 @@ import warnings
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from types import FunctionType
from types import TracebackType from types import TracebackType
from typing import Any from typing import Any
from typing import Callable from typing import Callable
@ -58,6 +59,7 @@ from _pytest.pathlib import ImportMode
from _pytest.pathlib import resolve_package_path from _pytest.pathlib import resolve_package_path
from _pytest.stash import Stash from _pytest.stash import Stash
from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestConfigWarning
from _pytest.warning_types import warn_explicit_for
if TYPE_CHECKING: if TYPE_CHECKING:
@ -341,6 +343,32 @@ def _get_directory(path: Path) -> Path:
return path return path
def _get_legacy_hook_marks(
method: object, # using object to avoid function type excess
hook_type: str,
opt_names: Tuple[str, ...],
) -> Dict[str, bool]:
known_marks = {m.name for m in getattr(method, "pytestmark", [])}
must_warn = False
opts = {}
for opt_name in opt_names:
if hasattr(method, opt_name) or opt_name in known_marks:
opts[opt_name] = True
must_warn = True
else:
opts[opt_name] = False
if must_warn:
hook_opts = ", ".join(f"{name}=True" for name, val in opts.items() if val)
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
type=hook_type,
fullname=method.__qualname__, # type: ignore
hook_opts=hook_opts,
)
warn_explicit_for(cast(FunctionType, method), message)
return opts
@final @final
class PytestPluginManager(PluginManager): class PytestPluginManager(PluginManager):
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with """A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
@ -414,40 +442,29 @@ class PytestPluginManager(PluginManager):
if name == "pytest_plugins": if name == "pytest_plugins":
return return
method = getattr(plugin, name)
opts = super().parse_hookimpl_opts(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). # Consider only actual functions for hooks (#3775).
if not inspect.isroutine(method): if not inspect.isroutine(method):
return return
# Collect unmarked hooks as long as they have the `pytest_' prefix. # Collect unmarked hooks as long as they have the `pytest_' prefix.
if opts is None and name.startswith("pytest_"): return _get_legacy_hook_marks(
opts = {} method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
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
def parse_hookspec_opts(self, module_or_class, name: str): def parse_hookspec_opts(self, module_or_class, name: str):
opts = super().parse_hookspec_opts(module_or_class, name) opts = super().parse_hookspec_opts(module_or_class, name)
if opts is None: if opts is None:
method = getattr(module_or_class, name) method = getattr(module_or_class, name)
if name.startswith("pytest_"): if name.startswith("pytest_"):
# todo: deprecate hookspec hacks opts = _get_legacy_hook_marks(
# https://github.com/pytest-dev/pytest/issues/4562 method,
known_marks = {m.name for m in getattr(method, "pytestmark", [])} "spec",
opts = { ("firstresult", "historic"),
"firstresult": hasattr(method, "firstresult") )
or "firstresult" in known_marks,
"historic": hasattr(method, "historic")
or "historic" in known_marks,
}
return opts return opts
def register( def register(

View File

@ -98,6 +98,14 @@ INSTANCE_COLLECTOR = PytestRemovedIn8Warning(
"The pytest.Instance collector type is deprecated and is no longer used. " "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", "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". # You want to make some `__init__` or function "private".
# #

View File

@ -1,3 +1,6 @@
import inspect
import warnings
from types import FunctionType
from typing import Any from typing import Any
from typing import Generic from typing import Generic
from typing import Type from typing import Type
@ -142,3 +145,19 @@ class UnformattedWarning(Generic[_W]):
def format(self, **kwargs: Any) -> _W: def format(self, **kwargs: Any) -> _W:
"""Return an instance of the warning category, formatted with given kwargs.""" """Return an instance of the warning category, formatted with given kwargs."""
return self.category(self.template.format(**kwargs)) return self.category(self.template.format(**kwargs))
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
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,
)

View File

@ -20,6 +20,54 @@ def test_external_plugins_integrated(pytester: Pytester, plugin) -> None:
pytester.parseconfig("-p", plugin) 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 = True # type: ignore[attr-defined]
with pytest.warns(
PytestDeprecationWarning,
match=r"Please use the pytest\.hookspec\(historic=True\) 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: def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None:
module = pytester.getmodulecol( module = pytester.getmodulecol(
""" """