Skip to content

Commit dec8c94

Browse files
feat(project): add ProjectSpec hub for markers and PluginManager
Complete design step 06: ProjectSpec bundles hookspec/hookimpl markers and plugin manager creation under a single project name, with get_hookspec_config / get_hookimpl_config helpers. Markers and PluginManager accept str | ProjectSpec (strings still work; markers and manager now expose project_name as a property delegating to the spec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a2063ae commit dec8c94

7 files changed

Lines changed: 310 additions & 12 deletions

File tree

changelog/708.feature.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
New :class:`pluggy.ProjectSpec` hub bundles a project's
2+
:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and
3+
:meth:`~pluggy.ProjectSpec.create_plugin_manager` under one project name.
4+
:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and
5+
:class:`~pluggy.PluginManager` now accept either a project name string or
6+
a ``ProjectSpec`` instance; plain strings keep working unchanged.

docs/api_reference.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ API Reference
88
.. autoclass:: pluggy.PluginManager
99
:members:
1010

11+
.. autoclass:: pluggy.ProjectSpec
12+
:members:
13+
1114
.. autoclass:: pluggy.PluginValidationError
1215
:show-inheritance:
1316
:members:

src/pluggy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"__version__",
33
"PluginManager",
44
"PluginValidationError",
5+
"ProjectSpec",
56
"HookCaller",
67
"NormalHookCaller",
78
"HistoricHookCaller",
@@ -35,6 +36,7 @@
3536
from ._hooks import WrapperImpl
3637
from ._manager import PluginManager
3738
from ._manager import PluginValidationError
39+
from ._project import ProjectSpec
3840
from ._pytest_compat import HookimplOpts
3941
from ._pytest_compat import HookspecOpts
4042
from ._result import HookCallError

src/pluggy/_decorators.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ._config import HookimplConfiguration
2121
from ._config import HookspecConfiguration
22+
from ._project import ProjectSpec
2223

2324

2425
_F = TypeVar("_F", bound=Callable[..., object])
@@ -30,15 +31,23 @@
3031
class HookspecMarker:
3132
"""Decorator for marking functions as hook specifications.
3233
33-
Instantiate it with a project_name to get a decorator.
34+
Instantiate it with a project name or :class:`ProjectSpec` to get a
35+
decorator.
3436
Calling :meth:`PluginManager.add_hookspecs` later will discover all marked
3537
functions if the :class:`PluginManager` uses the same project name.
3638
"""
3739

38-
__slots__ = ("project_name",)
40+
__slots__ = ("_project_spec",)
3941

40-
def __init__(self, project_name: str) -> None:
41-
self.project_name: Final = project_name
42+
def __init__(self, project_name: str | ProjectSpec) -> None:
43+
self._project_spec: Final = (
44+
ProjectSpec(project_name) if isinstance(project_name, str) else project_name
45+
)
46+
47+
@property
48+
def project_name(self) -> str:
49+
"""The project name from the associated :class:`ProjectSpec`."""
50+
return self._project_spec.project_name
4251

4352
@overload
4453
def __call__(
@@ -115,15 +124,23 @@ def setattr_hookspec_opts(func: _F) -> _F:
115124
class HookimplMarker:
116125
"""Decorator for marking functions as hook implementations.
117126
118-
Instantiate it with a ``project_name`` to get a decorator.
127+
Instantiate it with a project name or :class:`ProjectSpec` to get a
128+
decorator.
119129
Calling :meth:`PluginManager.register` later will discover all marked
120130
functions if the :class:`PluginManager` uses the same project name.
121131
"""
122132

123-
__slots__ = ("project_name",)
133+
__slots__ = ("_project_spec",)
124134

125-
def __init__(self, project_name: str) -> None:
126-
self.project_name: Final = project_name
135+
def __init__(self, project_name: str | ProjectSpec) -> None:
136+
self._project_spec: Final = (
137+
ProjectSpec(project_name) if isinstance(project_name, str) else project_name
138+
)
139+
140+
@property
141+
def project_name(self) -> str:
142+
"""The project name from the associated :class:`ProjectSpec`."""
143+
return self._project_spec.project_name
127144

128145
@overload
129146
def __call__(

src/pluggy/_manager.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ._hooks import NormalImpl
3333
from ._hooks import SubsetHookCaller
3434
from ._hooks import WrapperImpl
35+
from ._project import ProjectSpec
3536
from ._pytest_compat import HookimplOpts
3637
from ._pytest_compat import HookspecOpts
3738
from ._result import Result
@@ -86,12 +87,17 @@ class PluginManager:
8687
which will subsequently send debug information to the trace helper.
8788
8889
:param project_name:
89-
The short project name. Prefer snake case. Make sure it's unique!
90+
The short project name (prefer snake case, make sure it's unique!),
91+
or a :class:`ProjectSpec` instance.
92+
93+
.. versionchanged:: 1.7
94+
A :class:`ProjectSpec` may be passed instead of a plain name.
9095
"""
9196

92-
def __init__(self, project_name: str) -> None:
93-
#: The project name.
94-
self.project_name: Final = project_name
97+
def __init__(self, project_name: str | ProjectSpec) -> None:
98+
self._project_spec: Final = (
99+
ProjectSpec(project_name) if isinstance(project_name, str) else project_name
100+
)
95101
self._name2plugin: Final[dict[str, _Plugin]] = {}
96102
self._plugin_distinfo: Final[
97103
list[tuple[_Plugin, importlib.metadata.Distribution]]
@@ -105,6 +111,11 @@ def __init__(self, project_name: str) -> None:
105111
)
106112
self._inner_hookexec = _multicall
107113

114+
@property
115+
def project_name(self) -> str:
116+
"""The project name from the associated :class:`ProjectSpec`."""
117+
return self._project_spec.project_name
118+
108119
def _hookexec(
109120
self,
110121
hook_name: str,

src/pluggy/_project.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Project configuration hub for pluggy projects.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from collections.abc import Callable
8+
from typing import Final
9+
from typing import TYPE_CHECKING
10+
11+
12+
if TYPE_CHECKING:
13+
from ._config import HookimplConfiguration
14+
from ._config import HookspecConfiguration
15+
from ._manager import PluginManager
16+
17+
18+
class ProjectSpec:
19+
"""Manages hook markers and plugin manager creation for a pluggy project.
20+
21+
This class provides a unified interface for creating and managing the
22+
core components of a pluggy project: :class:`HookspecMarker`,
23+
:class:`HookimplMarker`, and :class:`PluginManager`.
24+
25+
All components share the same ``project_name``, ensuring consistent
26+
behavior across hook specifications, implementations, and plugin
27+
management.
28+
29+
:param project_name:
30+
The short project name. Prefer snake case. Make sure it's unique!
31+
:param plugin_manager_cls:
32+
Custom :class:`PluginManager` subclass to use (defaults to
33+
:class:`PluginManager`).
34+
35+
.. versionadded:: 1.7
36+
"""
37+
38+
def __init__(
39+
self,
40+
project_name: str,
41+
plugin_manager_cls: type[PluginManager] | None = None,
42+
) -> None:
43+
# Local imports to avoid circular imports with the marker and
44+
# manager modules.
45+
from ._decorators import HookimplMarker
46+
from ._decorators import HookspecMarker
47+
from ._manager import PluginManager as DefaultPluginManager
48+
49+
#: The project name used across all components.
50+
self.project_name: Final = project_name
51+
self._plugin_manager_cls: Final = plugin_manager_cls or DefaultPluginManager
52+
53+
# Marker instances are stateless decorators, safe to share.
54+
#: Hook specification marker for this project.
55+
self.hookspec: Final = HookspecMarker(self)
56+
#: Hook implementation marker for this project.
57+
self.hookimpl: Final = HookimplMarker(self)
58+
59+
def create_plugin_manager(self) -> PluginManager:
60+
"""Create a new :class:`PluginManager` instance for this project.
61+
62+
Each call returns a fresh, independent instance configured with this
63+
project's name.
64+
"""
65+
return self._plugin_manager_cls(self)
66+
67+
def get_hookspec_config(
68+
self, func: Callable[..., object]
69+
) -> HookspecConfiguration | None:
70+
"""Extract the hook specification configuration from a decorated
71+
function, or ``None`` if it is not decorated with this project's
72+
hookspec marker."""
73+
attr_name = self.project_name + "_spec"
74+
return getattr(func, attr_name, None)
75+
76+
def get_hookimpl_config(
77+
self, func: Callable[..., object]
78+
) -> HookimplConfiguration | None:
79+
"""Extract the hook implementation configuration from a decorated
80+
function, or ``None`` if it is not decorated with this project's
81+
hookimpl marker."""
82+
attr_name = self.project_name + "_impl"
83+
return getattr(func, attr_name, None)
84+
85+
def __repr__(self) -> str:
86+
return f"ProjectSpec(project_name={self.project_name!r})"

testing/test_project_spec.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""
2+
Tests for ProjectSpec functionality.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from pluggy import HookimplMarker
8+
from pluggy import HookspecMarker
9+
from pluggy import PluginManager
10+
from pluggy import ProjectSpec
11+
12+
13+
def test_project_spec_basic_creation() -> None:
14+
project = ProjectSpec("testproject")
15+
16+
assert project.project_name == "testproject"
17+
assert isinstance(project.hookspec, HookspecMarker)
18+
assert isinstance(project.hookimpl, HookimplMarker)
19+
assert project.hookspec.project_name == "testproject"
20+
assert project.hookimpl.project_name == "testproject"
21+
assert repr(project) == "ProjectSpec(project_name='testproject')"
22+
23+
24+
def test_project_spec_plugin_manager_creation() -> None:
25+
project = ProjectSpec("testproject")
26+
27+
pm1 = project.create_plugin_manager()
28+
pm2 = project.create_plugin_manager()
29+
30+
assert pm1 is not pm2
31+
assert pm1.project_name == "testproject"
32+
assert pm2.project_name == "testproject"
33+
assert isinstance(pm1, PluginManager)
34+
assert isinstance(pm2, PluginManager)
35+
36+
37+
def test_project_spec_custom_plugin_manager_class() -> None:
38+
class CustomPluginManager(PluginManager):
39+
def __init__(self, project_name: str | ProjectSpec) -> None:
40+
super().__init__(project_name)
41+
self.custom_attr = "custom_value"
42+
43+
project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
44+
pm = project.create_plugin_manager()
45+
46+
assert isinstance(pm, CustomPluginManager)
47+
assert pm.project_name == "testproject"
48+
assert pm.custom_attr == "custom_value"
49+
50+
51+
def test_project_spec_functional_integration() -> None:
52+
project = ProjectSpec("testproject")
53+
54+
hookspec = project.hookspec
55+
hookimpl = project.hookimpl
56+
57+
class HookSpecs:
58+
@hookspec
59+
def my_hook(self, arg: int) -> int: # type: ignore[empty-body]
60+
...
61+
62+
class Plugin:
63+
@hookimpl
64+
def my_hook(self, arg: int) -> int:
65+
return arg * 2
66+
67+
pm = project.create_plugin_manager()
68+
pm.add_hookspecs(HookSpecs)
69+
pm.register(Plugin())
70+
71+
result = pm.hook.my_hook(arg=5)
72+
assert result == [10]
73+
74+
75+
def test_project_spec_multiple_plugin_managers_independent() -> None:
76+
project = ProjectSpec("testproject")
77+
78+
pm1 = project.create_plugin_manager()
79+
pm2 = project.create_plugin_manager()
80+
81+
class Plugin1:
82+
pass
83+
84+
class Plugin2:
85+
pass
86+
87+
pm1.register(Plugin1(), name="plugin1")
88+
pm2.register(Plugin2(), name="plugin2")
89+
90+
assert pm1.has_plugin("plugin1")
91+
assert not pm1.has_plugin("plugin2")
92+
assert pm2.has_plugin("plugin2")
93+
assert not pm2.has_plugin("plugin1")
94+
95+
96+
def test_project_spec_hook_attribute_naming() -> None:
97+
project = ProjectSpec("myproject")
98+
99+
@project.hookspec
100+
def test_hook() -> None:
101+
pass
102+
103+
@project.hookimpl
104+
def test_hook_impl() -> None:
105+
pass
106+
107+
assert hasattr(test_hook, "myproject_spec")
108+
assert hasattr(test_hook_impl, "myproject_impl")
109+
110+
111+
def test_project_spec_get_hook_configs() -> None:
112+
project = ProjectSpec("testproject")
113+
114+
@project.hookspec(firstresult=True)
115+
def my_hook() -> None:
116+
pass
117+
118+
@project.hookimpl(tryfirst=True, optionalhook=True)
119+
def my_hook_impl() -> None:
120+
pass
121+
122+
spec_config = project.get_hookspec_config(my_hook)
123+
assert spec_config is not None
124+
assert spec_config.firstresult is True
125+
126+
impl_config = project.get_hookimpl_config(my_hook_impl)
127+
assert impl_config is not None
128+
assert impl_config.tryfirst is True
129+
assert impl_config.optionalhook is True
130+
assert impl_config.wrapper is False
131+
132+
def undecorated() -> None:
133+
pass
134+
135+
assert project.get_hookspec_config(undecorated) is None
136+
assert project.get_hookimpl_config(undecorated) is None
137+
138+
139+
def test_marker_classes_accept_project_spec() -> None:
140+
project = ProjectSpec("testproject")
141+
142+
hookspec_from_project = HookspecMarker(project)
143+
hookimpl_from_project = HookimplMarker(project)
144+
145+
assert hookspec_from_project.project_name == "testproject"
146+
assert hookimpl_from_project.project_name == "testproject"
147+
assert hookspec_from_project._project_spec is project
148+
assert hookimpl_from_project._project_spec is project
149+
150+
151+
def test_marker_classes_accept_string() -> None:
152+
hookspec_from_string = HookspecMarker("testproject")
153+
hookimpl_from_string = HookimplMarker("testproject")
154+
155+
assert hookspec_from_string.project_name == "testproject"
156+
assert hookimpl_from_string.project_name == "testproject"
157+
assert hookspec_from_string._project_spec.project_name == "testproject"
158+
assert hookimpl_from_string._project_spec.project_name == "testproject"
159+
160+
161+
def test_plugin_manager_accepts_project_spec() -> None:
162+
project = ProjectSpec("testproject")
163+
pm = PluginManager(project)
164+
165+
assert pm.project_name == "testproject"
166+
assert pm._project_spec is project
167+
168+
169+
def test_plugin_manager_accepts_string() -> None:
170+
pm = PluginManager("testproject")
171+
172+
assert pm.project_name == "testproject"
173+
assert pm._project_spec.project_name == "testproject"

0 commit comments

Comments
 (0)