Skip to content

Commit 6993f10

Browse files
RonnyPfannschmidtCursor AICursor Grok 4.5
committed
feat(config): replace TypedDict options with Hook*Configuration
Markers attach HookspecConfiguration/HookimplConfiguration objects. Registration discovers those privately; parse_hookimpl_opts and parse_hookspec_opts remain a deprecated pytest concession that returns legacy dicts and is only called when a subclass overrides them and no modern configuration attribute was found. Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Cursor Grok 4.5 <grok@cursor.com>
1 parent 7489fdb commit 6993f10

14 files changed

Lines changed: 681 additions & 160 deletions

changelog/704.removal.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Hook options are now :class:`pluggy.HookspecConfiguration` /
2+
:class:`pluggy.HookimplConfiguration` objects (markers attach these instead of
3+
dicts). ``PluginManager.parse_hookimpl_opts`` /
4+
``parse_hookspec_opts`` remain as a deprecated pytest/support concession that
5+
returns legacy dicts and are only invoked during registration when a subclass
6+
overrides them and no modern configuration attribute was found.
7+
``HookspecOpts`` / ``HookimplOpts`` TypedDicts remain importable for
8+
pytest/typing compatibility.

docs/api_reference.rst

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,10 @@ API Reference
4040
.. autoclass:: pluggy.HookImpl()
4141
:members:
4242

43-
.. autoclass:: pluggy.HookspecOpts()
44-
:show-inheritance:
43+
.. autoclass:: pluggy.HookspecConfiguration()
4544
:members:
4645

47-
.. autoclass:: pluggy.HookimplOpts()
48-
:show-inheritance:
46+
.. autoclass:: pluggy.HookimplConfiguration()
4947
:members:
5048

5149

docs/index.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,13 @@ and particular plugins in it:
767767

768768
Parsing mark options
769769
^^^^^^^^^^^^^^^^^^^^
770-
You can retrieve the *options* applied to a particular
771-
*hookspec* or *hookimpl* as per :ref:`marking_hooks` using the
770+
Markers attach :class:`~pluggy.HookspecConfiguration` /
771+
:class:`~pluggy.HookimplConfiguration` objects to functions. The
772772
:py:meth:`~pluggy.PluginManager.parse_hookspec_opts()` and
773-
:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` respectively.
773+
:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` methods remain as a
774+
**deprecated** pytest/support concession that returns legacy dict-shaped
775+
options; registration only calls them when a subclass overrides them and no
776+
modern configuration attribute was found.
774777

775778

776779
.. _calling:

src/pluggy/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"PluginValidationError",
55
"HookCaller",
66
"HookCallError",
7+
"HookspecConfiguration",
8+
"HookimplConfiguration",
79
"HookspecOpts",
810
"HookimplOpts",
911
"HookImpl",
@@ -14,15 +16,17 @@
1416
"PluggyWarning",
1517
"PluggyTeardownRaisedWarning",
1618
]
19+
from ._config import HookimplConfiguration
20+
from ._config import HookspecConfiguration
1721
from ._hooks import HookCaller
1822
from ._hooks import HookImpl
1923
from ._hooks import HookimplMarker
20-
from ._hooks import HookimplOpts
2124
from ._hooks import HookRelay
2225
from ._hooks import HookspecMarker
23-
from ._hooks import HookspecOpts
2426
from ._manager import PluginManager
2527
from ._manager import PluginValidationError
28+
from ._pytest_compat import HookimplOpts
29+
from ._pytest_compat import HookspecOpts
2630
from ._result import HookCallError
2731
from ._result import Result
2832
from ._warnings import PluggyTeardownRaisedWarning

src/pluggy/_caller.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
from typing import TypeAlias
1616
import warnings
1717

18-
from ._config import HookimplOpts
19-
from ._config import HookspecOpts
18+
from ._config import HookimplConfiguration
19+
from ._config import HookspecConfiguration
2020
from ._decorators import _Namespace
2121
from ._decorators import HookSpec
2222
from ._implementation import _Plugin
@@ -69,7 +69,7 @@ def __init__(
6969
name: str,
7070
hook_execute: _HookExec,
7171
specmodule_or_class: _Namespace | None = None,
72-
spec_opts: HookspecOpts | None = None,
72+
spec_opts: HookspecConfiguration | None = None,
7373
) -> None:
7474
""":meta private:"""
7575
#: Name of the hook getting called.
@@ -98,15 +98,15 @@ def has_spec(self) -> bool:
9898
def set_specification(
9999
self,
100100
specmodule_or_class: _Namespace,
101-
spec_opts: HookspecOpts,
101+
spec_opts: HookspecConfiguration,
102102
) -> None:
103103
if self.spec is not None:
104104
raise ValueError(
105105
f"Hook {self.spec.name!r} is already registered "
106106
f"within namespace {self.spec.namespace}"
107107
)
108108
self.spec = HookSpec(specmodule_or_class, self.name, spec_opts)
109-
if spec_opts.get("historic"):
109+
if spec_opts.historic:
110110
self._call_history = []
111111

112112
def is_historic(self) -> bool:
@@ -183,7 +183,7 @@ def __call__(self, **kwargs: object) -> Any:
183183
"Cannot directly call a historic hook - use call_historic instead."
184184
)
185185
self._verify_all_args_are_provided(kwargs)
186-
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
186+
firstresult = self.spec.opts.firstresult if self.spec else False
187187
# Copy because plugins may register other plugins during iteration (#438).
188188
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
189189

@@ -224,14 +224,7 @@ def call_extra(
224224
"Cannot directly call a historic hook - use call_historic instead."
225225
)
226226
self._verify_all_args_are_provided(kwargs)
227-
opts: HookimplOpts = {
228-
"wrapper": False,
229-
"hookwrapper": False,
230-
"optionalhook": False,
231-
"trylast": False,
232-
"tryfirst": False,
233-
"specname": None,
234-
}
227+
opts = HookimplConfiguration()
235228
hookimpls = self._hookimpls.copy()
236229
for method in methods:
237230
hookimpl = HookImpl(None, "<temp>", method, opts)
@@ -245,7 +238,7 @@ def call_extra(
245238
):
246239
i -= 1
247240
hookimpls.insert(i + 1, hookimpl)
248-
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
241+
firstresult = self.spec.opts.firstresult if self.spec else False
249242
return self._hookexec(self.name, hookimpls, kwargs, firstresult)
250243

251244
def _maybe_apply_history(self, method: HookImpl) -> None:

src/pluggy/_config.py

Lines changed: 160 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,163 @@
55
from __future__ import annotations
66

77
from collections.abc import Mapping
8-
from typing import TypedDict
9-
10-
11-
class HookspecOpts(TypedDict):
12-
"""Options for a hook specification."""
13-
14-
#: Whether the hook is :ref:`first result only <firstresult>`.
15-
firstresult: bool
16-
#: Whether the hook is :ref:`historic <historic>`.
17-
historic: bool
18-
#: Whether the hook :ref:`warns when implemented <warn_on_impl>`.
19-
warn_on_impl: Warning | None
20-
#: Whether the hook warns when :ref:`certain arguments are requested
21-
#: <warn_on_impl>`.
22-
#:
23-
#: .. versionadded:: 1.5
24-
warn_on_impl_args: Mapping[str, Warning] | None
25-
26-
27-
class HookimplOpts(TypedDict):
28-
"""Options for a hook implementation."""
29-
30-
#: Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
31-
wrapper: bool
32-
#: Whether the hook implementation is an :ref:`old-style wrapper
33-
#: <old_style_hookwrappers>`.
34-
hookwrapper: bool
35-
#: Whether validation against a hook specification is :ref:`optional
36-
#: <optionalhook>`.
37-
optionalhook: bool
38-
#: Whether to try to order this hook implementation :ref:`first
39-
#: <callorder>`.
40-
tryfirst: bool
41-
#: Whether to try to order this hook implementation :ref:`last
42-
#: <callorder>`.
43-
trylast: bool
44-
#: The name of the hook specification to match, see :ref:`specname`.
45-
specname: str | None
46-
47-
48-
def normalize_hookimpl_opts(opts: HookimplOpts) -> None:
49-
opts.setdefault("tryfirst", False)
50-
opts.setdefault("trylast", False)
51-
opts.setdefault("wrapper", False)
52-
opts.setdefault("hookwrapper", False)
53-
opts.setdefault("optionalhook", False)
54-
opts.setdefault("specname", None)
8+
from typing import Any
9+
from typing import Final
10+
from typing import final
11+
12+
13+
@final
14+
class HookspecConfiguration:
15+
"""Configuration for a hook specification."""
16+
17+
__slots__ = (
18+
"firstresult",
19+
"historic",
20+
"warn_on_impl",
21+
"warn_on_impl_args",
22+
)
23+
firstresult: Final[bool]
24+
historic: Final[bool]
25+
warn_on_impl: Final[Warning | None]
26+
warn_on_impl_args: Final[Mapping[str, Warning] | None]
27+
28+
def __init__(
29+
self,
30+
firstresult: bool = False,
31+
historic: bool = False,
32+
warn_on_impl: Warning | None = None,
33+
warn_on_impl_args: Mapping[str, Warning] | None = None,
34+
) -> None:
35+
if historic and firstresult:
36+
raise ValueError("cannot have a historic firstresult hook")
37+
#: Whether the hook is :ref:`first result only <firstresult>`.
38+
self.firstresult = firstresult
39+
#: Whether the hook is :ref:`historic <historic>`.
40+
self.historic = historic
41+
#: Whether the hook :ref:`warns when implemented <warn_on_impl>`.
42+
self.warn_on_impl = warn_on_impl
43+
#: Whether the hook warns when :ref:`certain arguments are requested
44+
#: <warn_on_impl>`.
45+
self.warn_on_impl_args = warn_on_impl_args
46+
47+
def __repr__(self) -> str:
48+
attrs = [
49+
f"{slot}={getattr(self, slot)!r}"
50+
for slot in self.__slots__
51+
if getattr(self, slot)
52+
]
53+
return f"HookspecConfiguration({', '.join(attrs)})"
54+
55+
56+
@final
57+
class HookimplConfiguration:
58+
"""Configuration for a hook implementation."""
59+
60+
__slots__ = (
61+
"wrapper",
62+
"hookwrapper",
63+
"optionalhook",
64+
"tryfirst",
65+
"trylast",
66+
"specname",
67+
)
68+
wrapper: Final[bool]
69+
hookwrapper: Final[bool]
70+
optionalhook: Final[bool]
71+
tryfirst: Final[bool]
72+
trylast: Final[bool]
73+
specname: Final[str | None]
74+
75+
def __init__(
76+
self,
77+
wrapper: bool = False,
78+
hookwrapper: bool = False,
79+
optionalhook: bool = False,
80+
tryfirst: bool = False,
81+
trylast: bool = False,
82+
specname: str | None = None,
83+
) -> None:
84+
#: Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
85+
self.wrapper = wrapper
86+
#: Whether the hook implementation is an :ref:`old-style wrapper
87+
#: <old_style_hookwrappers>`.
88+
self.hookwrapper = hookwrapper
89+
#: Whether validation against a hook specification is :ref:`optional
90+
#: <optionalhook>`.
91+
self.optionalhook = optionalhook
92+
#: Whether to try to order this hook implementation :ref:`first
93+
#: <callorder>`.
94+
self.tryfirst = tryfirst
95+
#: Whether to try to order this hook implementation :ref:`last
96+
#: <callorder>`.
97+
self.trylast = trylast
98+
#: The name of the hook specification to match, see :ref:`specname`.
99+
self.specname = specname
100+
101+
def __repr__(self) -> str:
102+
attrs = [
103+
f"{slot}={getattr(self, slot)!r}"
104+
for slot in self.__slots__
105+
if getattr(self, slot)
106+
]
107+
return f"HookimplConfiguration({', '.join(attrs)})"
108+
109+
110+
def hookspec_config_from_mapping(
111+
opts: Mapping[str, Any],
112+
) -> HookspecConfiguration:
113+
"""Build a :class:`HookspecConfiguration` from a mapping.
114+
115+
Intended for pytest/support migration only — not the public options API.
116+
Prefer constructing :class:`HookspecConfiguration` directly.
117+
"""
118+
return HookspecConfiguration(
119+
firstresult=bool(opts.get("firstresult", False)),
120+
historic=bool(opts.get("historic", False)),
121+
warn_on_impl=opts.get("warn_on_impl"),
122+
warn_on_impl_args=opts.get("warn_on_impl_args"),
123+
)
124+
125+
126+
def hookimpl_config_from_mapping(
127+
opts: Mapping[str, Any],
128+
) -> HookimplConfiguration:
129+
"""Build a :class:`HookimplConfiguration` from a mapping.
130+
131+
Intended for pytest/support migration only — not the public options API.
132+
Prefer constructing :class:`HookimplConfiguration` directly.
133+
"""
134+
return HookimplConfiguration(
135+
wrapper=bool(opts.get("wrapper", False)),
136+
hookwrapper=bool(opts.get("hookwrapper", False)),
137+
optionalhook=bool(opts.get("optionalhook", False)),
138+
tryfirst=bool(opts.get("tryfirst", False)),
139+
trylast=bool(opts.get("trylast", False)),
140+
specname=opts.get("specname"),
141+
)
142+
143+
144+
def hookspec_config_to_mapping(
145+
config: HookspecConfiguration,
146+
) -> dict[str, Any]:
147+
"""Serialize configuration to a legacy mapping (pytest/support only)."""
148+
return {
149+
"firstresult": config.firstresult,
150+
"historic": config.historic,
151+
"warn_on_impl": config.warn_on_impl,
152+
"warn_on_impl_args": config.warn_on_impl_args,
153+
}
154+
155+
156+
def hookimpl_config_to_mapping(
157+
config: HookimplConfiguration,
158+
) -> dict[str, Any]:
159+
"""Serialize configuration to a legacy mapping (pytest/support only)."""
160+
return {
161+
"wrapper": config.wrapper,
162+
"hookwrapper": config.hookwrapper,
163+
"optionalhook": config.optionalhook,
164+
"tryfirst": config.tryfirst,
165+
"trylast": config.trylast,
166+
"specname": config.specname,
167+
}

0 commit comments

Comments
 (0)