pluginkit 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pluginkit
3
- Version: 0.2.0
4
- Summary: A small, dependency-free plugin framework: hook specs, entry-point discovery, and sync/async dispatch
3
+ Version: 0.3.1
4
+ Summary: A strictly-typed, generics-first plugin framework for Python 3.13: hooks with derived return types
5
5
  Keywords: plugins,hooks,protocol,entry-points
6
6
  Author: Morten Hansen
7
7
  Author-email: Morten Hansen <morten@winterop.com>
@@ -9,12 +9,10 @@ License: MIT
9
9
  Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
12
  Classifier: Programming Language :: Python :: 3.13
15
13
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
14
  Classifier: Typing :: Typed
17
- Requires-Python: >=3.11
15
+ Requires-Python: >=3.13
18
16
  Project-URL: Homepage, https://github.com/winterop-com/pluginkit
19
17
  Project-URL: Repository, https://github.com/winterop-com/pluginkit
20
18
  Project-URL: Issues, https://github.com/winterop-com/pluginkit/issues
@@ -29,21 +27,22 @@ Description-Content-Type: text/markdown
29
27
  [![Docs](https://img.shields.io/badge/docs-pages-blue.svg)](https://winterop-com.github.io/pluginkit/)
30
28
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
31
29
 
32
- A small, **dependency-free** plugin framework for Python: declare hook
33
- specifications, let plugins implement them, and discover plugins via entry points.
34
- Supports sync and async dispatch, hook ordering, wrappers, pipeline (fold)
35
- dispatch, and historic hooks - in a few readable files.
30
+ A small, **strictly-typed**, generics-first plugin framework for **Python 3.13+**.
31
+ Declare hook specifications, let plugins implement them, discover plugins via entry
32
+ points - and, unlike untyped hook systems, get the **right return type for every
33
+ call**, derived from the spec and checked by your type checker.
36
34
 
37
- The library is three files under `src/pluginkit/` (`markers.py`, `manager.py`,
38
- `exceptions.py`), has **zero runtime dependencies** (standard library only), runs on
39
- **Python 3.11+**, and ships a `py.typed` marker.
35
+ `pm.caller(spec)` hands back a caller whose result type matches the dispatch mode -
36
+ `list[R]` for collecting, `R | None` for firstresult, `R` for pipeline - with no
37
+ hand-annotations and no drift. Zero runtime dependencies, a `py.typed` marker, and a
38
+ few readable files.
40
39
 
41
40
  ```bash
42
41
  pip install pluginkit # or: uv add pluginkit
43
42
  ```
44
43
 
45
44
  ```python
46
- from pluginkit import HookspecMarker, HookimplMarker, PluginManager
45
+ from pluginkit import HookimplMarker, HookspecMarker, PluginManager
47
46
 
48
47
  hookspec = HookspecMarker("greeter")
49
48
  hookimpl = HookimplMarker("greeter")
@@ -65,7 +64,9 @@ class Casual:
65
64
  pm = PluginManager("greeter")
66
65
  pm.add_hookspecs(Specs)
67
66
  pm.register(Casual(), name="casual")
68
- print(pm.hook.greeting(name="Ada")) # ['hey Ada!']
67
+
68
+ greetings = pm.caller(Specs.greeting)(name="Ada") # typed list[str] - derived, not asserted
69
+ print(greetings) # ['hey Ada!']
69
70
  ```
70
71
 
71
72
  ## What it supports
@@ -6,21 +6,22 @@
6
6
  [![Docs](https://img.shields.io/badge/docs-pages-blue.svg)](https://winterop-com.github.io/pluginkit/)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
8
8
 
9
- A small, **dependency-free** plugin framework for Python: declare hook
10
- specifications, let plugins implement them, and discover plugins via entry points.
11
- Supports sync and async dispatch, hook ordering, wrappers, pipeline (fold)
12
- dispatch, and historic hooks - in a few readable files.
9
+ A small, **strictly-typed**, generics-first plugin framework for **Python 3.13+**.
10
+ Declare hook specifications, let plugins implement them, discover plugins via entry
11
+ points - and, unlike untyped hook systems, get the **right return type for every
12
+ call**, derived from the spec and checked by your type checker.
13
13
 
14
- The library is three files under `src/pluginkit/` (`markers.py`, `manager.py`,
15
- `exceptions.py`), has **zero runtime dependencies** (standard library only), runs on
16
- **Python 3.11+**, and ships a `py.typed` marker.
14
+ `pm.caller(spec)` hands back a caller whose result type matches the dispatch mode -
15
+ `list[R]` for collecting, `R | None` for firstresult, `R` for pipeline - with no
16
+ hand-annotations and no drift. Zero runtime dependencies, a `py.typed` marker, and a
17
+ few readable files.
17
18
 
18
19
  ```bash
19
20
  pip install pluginkit # or: uv add pluginkit
20
21
  ```
21
22
 
22
23
  ```python
23
- from pluginkit import HookspecMarker, HookimplMarker, PluginManager
24
+ from pluginkit import HookimplMarker, HookspecMarker, PluginManager
24
25
 
25
26
  hookspec = HookspecMarker("greeter")
26
27
  hookimpl = HookimplMarker("greeter")
@@ -42,7 +43,9 @@ class Casual:
42
43
  pm = PluginManager("greeter")
43
44
  pm.add_hookspecs(Specs)
44
45
  pm.register(Casual(), name="casual")
45
- print(pm.hook.greeting(name="Ada")) # ['hey Ada!']
46
+
47
+ greetings = pm.caller(Specs.greeting)(name="Ada") # typed list[str] - derived, not asserted
48
+ print(greetings) # ['hey Ada!']
46
49
  ```
47
50
 
48
51
  ## What it supports
@@ -1,18 +1,16 @@
1
1
  [project]
2
2
  name = "pluginkit"
3
- version = "0.2.0"
4
- description = "A small, dependency-free plugin framework: hook specs, entry-point discovery, and sync/async dispatch"
3
+ version = "0.3.1"
4
+ description = "A strictly-typed, generics-first plugin framework for Python 3.13: hooks with derived return types"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
7
7
  license = { text = "MIT" }
8
- requires-python = ">=3.11"
8
+ requires-python = ">=3.13"
9
9
  keywords = ["plugins", "hooks", "protocol", "entry-points"]
10
10
  classifiers = [
11
11
  "Development Status :: 4 - Beta",
12
12
  "Intended Audience :: Developers",
13
13
  "License :: OSI Approved :: MIT License",
14
- "Programming Language :: Python :: 3.11",
15
- "Programming Language :: Python :: 3.12",
16
14
  "Programming Language :: Python :: 3.13",
17
15
  "Topic :: Software Development :: Libraries :: Python Modules",
18
16
  "Typing :: Typed",
@@ -56,7 +54,7 @@ pluginkit-tour = { workspace = true }
56
54
  smoothie-extra = { workspace = true }
57
55
 
58
56
  [tool.ruff]
59
- target-version = "py311"
57
+ target-version = "py313"
60
58
  line-length = 120
61
59
 
62
60
  [tool.ruff.lint]
@@ -85,7 +83,7 @@ pythonpath = ["examples/recipes", "examples/integrations"]
85
83
  norecursedirs = [".git", ".venv", "__pycache__"]
86
84
 
87
85
  [tool.mypy]
88
- python_version = "3.11"
86
+ python_version = "3.13"
89
87
  warn_return_any = true
90
88
  warn_unused_configs = true
91
89
  disallow_untyped_defs = true
@@ -110,7 +108,7 @@ disable_error_code = ["empty-body"]
110
108
  include = ["src", "tests", "examples/recipes", "examples/integrations", "examples/tour/src"]
111
109
  extraPaths = ["examples/recipes", "examples/integrations", "examples/tour/src"]
112
110
  exclude = ["**/.venv"]
113
- pythonVersion = "3.11"
111
+ pythonVersion = "3.13"
114
112
  typeCheckingMode = "strict"
115
113
  useLibraryCodeForTypes = true
116
114
  reportPrivateUsage = false
@@ -0,0 +1,81 @@
1
+ """pluginkit: a small, strictly-typed, generics-first plugin framework for Python 3.13+.
2
+
3
+ Unlike untyped hook systems, pluginkit derives a hook call's return type from its
4
+ spec: ``pm.caller(spec)`` hands back a caller whose result is ``list[R]``
5
+ (collecting), ``R | None`` (firstresult), or ``R`` (pipeline) - checked, not asserted.
6
+
7
+ Public API:
8
+
9
+ - :class:`HookspecMarker` / :class:`HookimplMarker` - decorators that declare hook
10
+ specifications and implementations. ``@hookspec`` brands the spec by dispatch mode.
11
+ - :class:`HookspecOpts` / :class:`HookimplOpts` - the option records the markers stamp.
12
+ - :class:`PluginManager` - registers plugins and dispatches hook calls; ``caller(spec)``
13
+ returns a typed caller.
14
+ - :class:`CollectingSpec` / :class:`FirstResultSpec` / :class:`PipelineSpec` - branded
15
+ spec types, and :class:`CollectingCaller` / :class:`FirstResultCaller` /
16
+ :class:`PipelineCaller` (and the ``Async*`` variants) - the typed callers.
17
+ - :class:`HookRelay` / :class:`HookCaller` / :class:`HookImpl` - the dispatch internals.
18
+ - :class:`PluginValidationError` - raised when a plugin is invalid.
19
+ """
20
+
21
+ from importlib.metadata import PackageNotFoundError, version
22
+
23
+ from pluginkit.aio import (
24
+ AsyncCollectingCaller,
25
+ AsyncFirstResultCaller,
26
+ AsyncHookCaller,
27
+ AsyncPipelineCaller,
28
+ AsyncPluginManager,
29
+ )
30
+ from pluginkit.exceptions import PluginValidationError
31
+ from pluginkit.manager import (
32
+ CollectingCaller,
33
+ FirstResultCaller,
34
+ HistoricCaller,
35
+ HookCaller,
36
+ HookImpl,
37
+ HookRelay,
38
+ PipelineCaller,
39
+ PluginManager,
40
+ )
41
+ from pluginkit.markers import (
42
+ CollectingSpec,
43
+ FirstResultSpec,
44
+ HistoricSpec,
45
+ HookimplMarker,
46
+ HookimplOpts,
47
+ HookspecMarker,
48
+ HookspecOpts,
49
+ PipelineSpec,
50
+ )
51
+
52
+ try:
53
+ __version__ = version("pluginkit")
54
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without an install
55
+ __version__ = "0.0.0+unknown"
56
+
57
+ __all__ = [
58
+ "AsyncCollectingCaller",
59
+ "AsyncFirstResultCaller",
60
+ "AsyncHookCaller",
61
+ "AsyncPipelineCaller",
62
+ "AsyncPluginManager",
63
+ "CollectingCaller",
64
+ "CollectingSpec",
65
+ "FirstResultCaller",
66
+ "FirstResultSpec",
67
+ "HistoricCaller",
68
+ "HistoricSpec",
69
+ "HookCaller",
70
+ "HookImpl",
71
+ "HookRelay",
72
+ "HookimplMarker",
73
+ "HookimplOpts",
74
+ "HookspecMarker",
75
+ "HookspecOpts",
76
+ "PipelineCaller",
77
+ "PipelineSpec",
78
+ "PluginManager",
79
+ "PluginValidationError",
80
+ "__version__",
81
+ ]
@@ -15,10 +15,15 @@ a wrapper must transform the result.
15
15
  import inspect
16
16
  from collections.abc import Callable
17
17
  from types import AsyncGeneratorType
18
- from typing import Any
18
+ from typing import Any, overload
19
19
 
20
20
  from pluginkit.manager import _UNSET, HookCaller, HookImpl, PluginManager
21
- from pluginkit.markers import HookimplOpts, HookspecOpts
21
+ from pluginkit.markers import (
22
+ CollectingSpec,
23
+ FirstResultSpec,
24
+ HookspecOpts,
25
+ PipelineSpec,
26
+ )
22
27
 
23
28
 
24
29
  class AsyncHookCaller(HookCaller):
@@ -28,16 +33,17 @@ class AsyncHookCaller(HookCaller):
28
33
  """Await the hook: a list, a single value (firstresult), or the threaded value (pipeline)."""
29
34
  if self.spec.historic:
30
35
  raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
31
- self.check_arguments(kwargs)
36
+ kwargs = self.check_arguments(kwargs)
32
37
  return await self._execute_async(kwargs, self._nonwrappers)
33
38
 
34
39
  async def call_extra(self, functions: list[Callable[..., Any]], kwargs: dict[str, Any]) -> Any:
35
40
  """Await the hook with extra one-off implementations that are not registered."""
36
41
  if self.spec.historic:
37
42
  raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
38
- self.check_arguments(kwargs)
39
- extra = [HookImpl.from_function("<call_extra>", function, HookimplOpts()) for function in functions]
40
- combined = sorted([*self._nonwrappers, *extra], key=lambda candidate: candidate.order_key)
43
+ kwargs = self.check_arguments(kwargs)
44
+ combined = sorted(
45
+ [*self._nonwrappers, *self._prepare_extra(functions)], key=lambda candidate: candidate.order_key
46
+ )
41
47
  return await self._execute_async(kwargs, combined)
42
48
 
43
49
  def call_historic(self, kwargs: dict[str, Any], result_callback: Callable[[Any], None] | None = None) -> None:
@@ -100,19 +106,61 @@ class AsyncHookCaller(HookCaller):
100
106
  except BaseException as new_exc: # noqa: BLE001 - propagate the wrapper's error onward
101
107
  exc = new_exc
102
108
  else:
109
+ # Double yield: capture the error but keep unwinding the remaining
110
+ # wrappers so their teardown still runs; raised after the loop.
103
111
  await generator.aclose()
104
- raise RuntimeError(f"async wrapper for {self.name!r} must yield exactly once")
112
+ exc = RuntimeError(f"async wrapper for {self.name!r} must yield exactly once")
105
113
  if exc is not None:
106
114
  raise exc
107
115
  return result
108
116
 
109
117
 
118
+ # Async typed views returned by AsyncPluginManager.caller(); never instantiated
119
+ # (the runtime object is an AsyncHookCaller). Awaiting a call yields the mode type.
120
+ class AsyncCollectingCaller[**P, R](AsyncHookCaller):
121
+ """A collecting async hook's typed caller: `await` a call to get `list[R]`."""
122
+
123
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> list[R]:
124
+ """Await the collecting hook, returning each impl's result as `list[R]`."""
125
+ raise NotImplementedError # pragma: no cover - the runtime object is an AsyncHookCaller
126
+
127
+
128
+ class AsyncFirstResultCaller[**P, R](AsyncHookCaller):
129
+ """A firstresult async hook's typed caller: `await` a call to get `R | None`."""
130
+
131
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R | None:
132
+ """Await the firstresult hook, returning the first non-None `R` or `None`."""
133
+ raise NotImplementedError # pragma: no cover - the runtime object is an AsyncHookCaller
134
+
135
+
136
+ class AsyncPipelineCaller[**P, R](AsyncHookCaller):
137
+ """A pipeline async hook's typed caller: `await` a call to get `R`."""
138
+
139
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
140
+ """Await the pipeline hook, returning the threaded value `R`."""
141
+ raise NotImplementedError # pragma: no cover - the runtime object is an AsyncHookCaller
142
+
143
+
110
144
  class AsyncPluginManager(PluginManager):
111
145
  """A PluginManager whose hooks are awaited; impls may be coroutine functions."""
112
146
 
113
- def _make_caller(self, name: str, spec: HookspecOpts, params: tuple[str, ...]) -> HookCaller:
147
+ def _make_caller(
148
+ self, name: str, spec: HookspecOpts, params: tuple[str, ...], defaults: dict[str, Any]
149
+ ) -> HookCaller:
114
150
  """Build an AsyncHookCaller instead of the synchronous one."""
115
- return AsyncHookCaller(name=name, spec=spec, params=params)
151
+ return AsyncHookCaller(name=name, spec=spec, params=params, defaults=defaults)
152
+
153
+ @overload # type: ignore[override] # async manager returns awaitable callers
154
+ def caller[**P, R](self, spec: FirstResultSpec[P, R]) -> AsyncFirstResultCaller[P, R]: ...
155
+ @overload
156
+ def caller[**P, R](self, spec: PipelineSpec[P, R]) -> AsyncPipelineCaller[P, R]: ...
157
+ @overload
158
+ def caller[**P, R](self, spec: CollectingSpec[P, R]) -> AsyncCollectingCaller[P, R]: ...
159
+ def caller( # pyright: ignore[reportIncompatibleMethodOverride] # async returns awaitable callers
160
+ self, spec: object
161
+ ) -> HookCaller:
162
+ """Return the typed async caller for a `@hookspec`-decorated spec function."""
163
+ return self._caller(spec)
116
164
 
117
165
 
118
166
  async def _maybe_await(value: Any) -> Any:
@@ -23,10 +23,17 @@ from collections.abc import Callable, Generator, Iterator
23
23
  from dataclasses import dataclass, field
24
24
  from importlib.metadata import entry_points
25
25
  from types import GeneratorType
26
- from typing import Any, Self
26
+ from typing import Any, NoReturn, Self, overload
27
27
 
28
28
  from pluginkit.exceptions import PluginValidationError
29
- from pluginkit.markers import HookimplOpts, HookspecOpts
29
+ from pluginkit.markers import (
30
+ CollectingSpec,
31
+ FirstResultSpec,
32
+ HistoricSpec,
33
+ HookimplOpts,
34
+ HookspecOpts,
35
+ PipelineSpec,
36
+ )
30
37
 
31
38
  # Sentinel distinguishing "no result yet" from a legitimate None result.
32
39
  _UNSET: Any = object()
@@ -82,6 +89,10 @@ class HookCaller:
82
89
  spec: HookspecOpts
83
90
  params: tuple[str, ...] = ()
84
91
  argnames: frozenset[str] = frozenset()
92
+ # Default values for spec params that declare one. A call may omit these (the
93
+ # branded caller's ParamSpec makes them optional); they are filled in at call
94
+ # time so the type checker and the runtime agree.
95
+ defaults: dict[str, Any] = field(default_factory=dict)
85
96
  _impls: list[HookImpl] = field(default_factory=list)
86
97
  _wrappers: list[HookImpl] = field(default_factory=list)
87
98
  _nonwrappers: list[HookImpl] = field(default_factory=list)
@@ -92,11 +103,18 @@ class HookCaller:
92
103
  if self.params and not self.argnames:
93
104
  self.argnames = frozenset(self.params)
94
105
 
95
- def check_arguments(self, kwargs: dict[str, Any]) -> None:
96
- """Validate that a call supplies exactly the spec's arguments."""
106
+ def check_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
107
+ """Fill any omitted defaulted args, then validate the call against the spec.
108
+
109
+ Returns the completed kwargs (defaults filled). Spec params with a default
110
+ are optional at the call site; required params and unknown args are still
111
+ rejected.
112
+ """
113
+ if self.defaults:
114
+ kwargs = {**self.defaults, **kwargs}
97
115
  # dict_keys compares as a set against the frozenset without allocating one.
98
116
  if kwargs.keys() == self.argnames:
99
- return
117
+ return kwargs
100
118
  provided = frozenset(kwargs)
101
119
  problems: list[str] = []
102
120
  missing = self.argnames - provided
@@ -130,11 +148,23 @@ class HookCaller:
130
148
  """Return whether the named plugin contributes any impl to this hook."""
131
149
  return any(impl.plugin_name == plugin_name for impl in self._impls)
132
150
 
151
+ def _prepare_extra(self, functions: list[Callable[..., Any]]) -> list[HookImpl]:
152
+ """Build one-off impls for call_extra, validating their args against the spec."""
153
+ extra: list[HookImpl] = []
154
+ for function in functions:
155
+ impl = HookImpl.from_function("<call_extra>", function, HookimplOpts())
156
+ unknown = impl.accepts - self.argnames
157
+ if unknown:
158
+ raise TypeError(f"call_extra impl for {self.name!r} declares unknown argument(s) {sorted(unknown)}")
159
+ impl.passthrough = impl.accepts == self.argnames
160
+ extra.append(impl)
161
+ return extra
162
+
133
163
  def __call__(self, **kwargs: Any) -> Any:
134
164
  """Call the hook: a list, a single value (firstresult), or the threaded value (pipeline)."""
135
165
  if self.spec.historic:
136
166
  raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
137
- self.check_arguments(kwargs)
167
+ kwargs = self.check_arguments(kwargs)
138
168
  return self._execute(kwargs, self._nonwrappers)
139
169
 
140
170
  def call_extra(self, functions: list[Callable[..., Any]], kwargs: dict[str, Any]) -> Any:
@@ -146,23 +176,17 @@ class HookCaller:
146
176
  """
147
177
  if self.spec.historic:
148
178
  raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
149
- self.check_arguments(kwargs)
150
- extra: list[HookImpl] = []
151
- for function in functions:
152
- impl = HookImpl.from_function("<call_extra>", function, HookimplOpts())
153
- unknown = impl.accepts - self.argnames
154
- if unknown:
155
- raise TypeError(f"call_extra impl for {self.name!r} declares unknown argument(s) {sorted(unknown)}")
156
- impl.passthrough = impl.accepts == self.argnames
157
- extra.append(impl)
158
- combined = sorted([*self._nonwrappers, *extra], key=lambda candidate: candidate.order_key)
179
+ kwargs = self.check_arguments(kwargs)
180
+ combined = sorted(
181
+ [*self._nonwrappers, *self._prepare_extra(functions)], key=lambda candidate: candidate.order_key
182
+ )
159
183
  return self._execute(kwargs, combined)
160
184
 
161
185
  def call_historic(self, kwargs: dict[str, Any], result_callback: Callable[[Any], None] | None = None) -> None:
162
186
  """Call a historic hook now and remember it for plugins registered later."""
163
187
  if not self.spec.historic:
164
188
  raise TypeError(f"hook {self.name!r} is not historic")
165
- self.check_arguments(kwargs)
189
+ kwargs = self.check_arguments(kwargs)
166
190
  self._history.append((kwargs, result_callback))
167
191
  for outcome in self._collect(kwargs):
168
192
  if result_callback is not None:
@@ -259,8 +283,10 @@ class HookCaller:
259
283
  exc = new_exc
260
284
  else:
261
285
  # The generator yielded a second time, violating the one-yield contract.
286
+ # Capture the error but keep unwinding so the remaining wrappers still
287
+ # tear down; the error propagates through them and is raised at the end.
262
288
  generator.close()
263
- raise RuntimeError(f"wrapper for {self.name!r} must yield exactly once")
289
+ exc = RuntimeError(f"wrapper for {self.name!r} must yield exactly once")
264
290
  if exc is not None:
265
291
  raise exc
266
292
  return result
@@ -274,6 +300,46 @@ class HookCaller:
274
300
  return f"<HookCaller {self.name!r} impls={len(self._impls)}>"
275
301
 
276
302
 
303
+ # Typed views returned by PluginManager.caller(). The runtime object is always a
304
+ # plain HookCaller; these subclasses are never instantiated - they exist only to
305
+ # refine the static return type of a call per dispatch mode, deriving the impl
306
+ # ParamSpec P and return R from the branded spec.
307
+ class CollectingCaller[**P, R](HookCaller):
308
+ """A collecting hook's typed caller: a call returns `list[R]`."""
309
+
310
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> list[R]:
311
+ """Call the collecting hook, returning each impl's result as `list[R]`."""
312
+ raise NotImplementedError # pragma: no cover - the runtime object is a HookCaller
313
+
314
+
315
+ class FirstResultCaller[**P, R](HookCaller):
316
+ """A firstresult hook's typed caller: a call returns `R | None`."""
317
+
318
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R | None:
319
+ """Call the firstresult hook, returning the first non-None `R` or `None`."""
320
+ raise NotImplementedError # pragma: no cover - the runtime object is a HookCaller
321
+
322
+
323
+ class PipelineCaller[**P, R](HookCaller):
324
+ """A pipeline hook's typed caller: a call returns `R`."""
325
+
326
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
327
+ """Call the pipeline hook, returning the threaded value `R`."""
328
+ raise NotImplementedError # pragma: no cover - the runtime object is a HookCaller
329
+
330
+
331
+ class HistoricCaller[**P, R](HookCaller):
332
+ """A historic hook's typed caller. Replay it with `call_historic({...})`.
333
+
334
+ Calling it directly raises - historic hooks have no plain call form - so the
335
+ typed `__call__` is `NoReturn` rather than a value it never produces.
336
+ """
337
+
338
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> NoReturn:
339
+ """Historic hooks cannot be called directly; use `call_historic`."""
340
+ raise NotImplementedError # pragma: no cover - the runtime object is a HookCaller
341
+
342
+
277
343
  class HookRelay:
278
344
  """Attribute-style access to hook callers, e.g. pm.hook.add_ingredients(...)."""
279
345
 
@@ -322,13 +388,48 @@ class PluginManager:
322
388
  spec = getattr(member, self._spec_attribute, None)
323
389
  if not isinstance(spec, HookspecOpts):
324
390
  continue
325
- params = tuple(inspect.signature(member).parameters)
391
+ signature = inspect.signature(member)
392
+ params = tuple(signature.parameters)
393
+ defaults = {
394
+ name: parameter.default
395
+ for name, parameter in signature.parameters.items()
396
+ if parameter.default is not inspect.Parameter.empty
397
+ }
326
398
  self._validate_spec(member_name, spec, params)
327
- self.hook._add_caller(self._make_caller(member_name, spec, params))
399
+ self.hook._add_caller(self._make_caller(member_name, spec, params, defaults))
328
400
 
329
- def _make_caller(self, name: str, spec: HookspecOpts, params: tuple[str, ...]) -> HookCaller:
401
+ def _make_caller(
402
+ self, name: str, spec: HookspecOpts, params: tuple[str, ...], defaults: dict[str, Any]
403
+ ) -> HookCaller:
330
404
  """Build the caller for a spec; overridden by AsyncPluginManager."""
331
- return HookCaller(name=name, spec=spec, params=params)
405
+ return HookCaller(name=name, spec=spec, params=params, defaults=defaults)
406
+
407
+ @overload
408
+ def caller[**P, R](self, spec: FirstResultSpec[P, R]) -> FirstResultCaller[P, R]: ...
409
+ @overload
410
+ def caller[**P, R](self, spec: PipelineSpec[P, R]) -> PipelineCaller[P, R]: ...
411
+ @overload
412
+ def caller[**P, R](self, spec: HistoricSpec[P, R]) -> HistoricCaller[P, R]: ...
413
+ @overload
414
+ def caller[**P, R](self, spec: CollectingSpec[P, R]) -> CollectingCaller[P, R]: ...
415
+ def caller(self, spec: object) -> HookCaller:
416
+ """Return the typed caller for a `@hookspec`-decorated spec function.
417
+
418
+ The result is a plain `HookCaller`, but its static type carries the spec's
419
+ dispatch mode, so a call returns `list[R]` (collecting), `R | None`
420
+ (firstresult), or `R` (pipeline) - derived from the spec, not asserted.
421
+ """
422
+ return self._caller(spec)
423
+
424
+ def _caller(self, spec: object) -> HookCaller:
425
+ """Resolve a spec function to its registered caller (shared by subclasses)."""
426
+ name = getattr(spec, "__name__", None)
427
+ if not isinstance(name, str):
428
+ raise TypeError("caller() expects a @hookspec-decorated function")
429
+ found = self.hook._get_caller(name)
430
+ if found is None:
431
+ raise PluginValidationError(self.project_name, f"unknown hook spec {name!r}; call add_hookspecs() first")
432
+ return found
332
433
 
333
434
  @staticmethod
334
435
  def _validate_spec(name: str, spec: HookspecOpts, params: tuple[str, ...]) -> None:
@@ -360,8 +461,16 @@ class PluginManager:
360
461
 
361
462
  impls = self._collect_impls(plugin_name, plugin)
362
463
  self._name2plugin[plugin_name] = plugin
363
- for caller, impl in impls:
364
- caller.add_impl(impl)
464
+ try:
465
+ for caller, impl in impls:
466
+ caller.add_impl(impl)
467
+ except BaseException:
468
+ # add_impl can fail mid-loop (e.g. a historic replay raising). Roll the
469
+ # partial wiring back so registration is all-or-nothing.
470
+ self._name2plugin.pop(plugin_name, None)
471
+ for caller in self.hook._all_callers():
472
+ caller.remove_plugin(plugin_name)
473
+ raise
365
474
  return plugin_name
366
475
 
367
476
  def unregister(self, name_or_plugin: str | object) -> object | None:
@@ -431,6 +540,11 @@ class PluginManager:
431
540
 
432
541
  Returns:
433
542
  The number of plugins successfully registered.
543
+
544
+ Note:
545
+ With ``ignore_errors=False``, a failure part-way through leaves the
546
+ plugins registered before it registered; this method does not roll back
547
+ across plugins.
434
548
  """
435
549
  count = 0
436
550
  for entry_point in entry_points(group=group):
@@ -0,0 +1,162 @@
1
+ """Decorator markers that tag functions as hook specs or hook implementations.
2
+
3
+ A marker stamps a small frozen dataclass of options onto the decorated function
4
+ under a project-namespaced attribute, so the manager can later recognise specs and
5
+ impls by introspection.
6
+
7
+ The `@hookspec` decorator is **typed by dispatch mode**: it returns a branded spec
8
+ type (`CollectingSpec` / `FirstResultSpec` / `PipelineSpec`) that carries the impl
9
+ signature (`P`) and per-impl return type (`R`). `PluginManager.caller(spec)` reads
10
+ that brand to hand back a caller whose result type is exactly right for the mode -
11
+ `list[R]`, `R | None`, or `R`. The brand classes are type-level only; they are never
12
+ instantiated (a spec is a declaration, not a callable you invoke directly).
13
+ """
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal, overload
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class HookspecOpts:
22
+ """Options attached to a hook specification."""
23
+
24
+ firstresult: bool = False
25
+ historic: bool = False
26
+ pipeline: bool = False
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class HookimplOpts:
31
+ """Options attached to a hook implementation."""
32
+
33
+ tryfirst: bool = False
34
+ trylast: bool = False
35
+ wrapper: bool = False
36
+ optionalhook: bool = False
37
+ specname: str | None = None
38
+
39
+
40
+ class CollectingSpec[**P, R]:
41
+ """A collecting hook spec: a call collects each impl's `R` into `list[R]`."""
42
+
43
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
44
+ """Specs are declarations; obtain a callable via PluginManager.caller(spec)."""
45
+ raise NotImplementedError("a spec is a declaration; call it via PluginManager.caller(spec)")
46
+
47
+
48
+ class FirstResultSpec[**P, R]:
49
+ """A firstresult hook spec: a call returns the first non-None `R`, or `None`."""
50
+
51
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
52
+ """Specs are declarations; obtain a callable via PluginManager.caller(spec)."""
53
+ raise NotImplementedError("a spec is a declaration; call it via PluginManager.caller(spec)")
54
+
55
+
56
+ class PipelineSpec[**P, R]:
57
+ """A pipeline hook spec: a call threads `R` through the impls and returns it."""
58
+
59
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
60
+ """Specs are declarations; obtain a callable via PluginManager.caller(spec)."""
61
+ raise NotImplementedError("a spec is a declaration; call it via PluginManager.caller(spec)")
62
+
63
+
64
+ class HistoricSpec[**P, R]:
65
+ """A historic hook spec: replayed to late plugins, driven via `call_historic`."""
66
+
67
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
68
+ """Specs are declarations; obtain a callable via PluginManager.caller(spec)."""
69
+ raise NotImplementedError("a spec is a declaration; call it via PluginManager.caller(spec)")
70
+
71
+
72
+ class HookspecMarker:
73
+ """Creates the @hookspec decorator bound to a project name."""
74
+
75
+ def __init__(self, project_name: str) -> None:
76
+ """Bind the marker to a project name used for the stamped attribute."""
77
+ self.project_name = project_name
78
+ self.attribute = f"{project_name}_spec"
79
+
80
+ @overload
81
+ def __call__[**P, R](self, function: Callable[P, R]) -> CollectingSpec[P, R]: ...
82
+ @overload
83
+ def __call__[**P, R](
84
+ self, function: None = ..., *, firstresult: Literal[True], historic: bool = ...
85
+ ) -> Callable[[Callable[P, R]], FirstResultSpec[P, R]]: ...
86
+ @overload
87
+ def __call__[**P, R](
88
+ self, function: None = ..., *, pipeline: Literal[True], historic: bool = ...
89
+ ) -> Callable[[Callable[P, R]], PipelineSpec[P, R]]: ...
90
+ @overload
91
+ def __call__[**P, R](
92
+ self, function: None = ..., *, historic: Literal[True]
93
+ ) -> Callable[[Callable[P, R]], HistoricSpec[P, R]]: ...
94
+ @overload
95
+ def __call__[**P, R](
96
+ self, function: None = ..., *, historic: Literal[False] = ...
97
+ ) -> Callable[[Callable[P, R]], CollectingSpec[P, R]]: ...
98
+ def __call__(
99
+ self,
100
+ function: Callable[..., Any] | None = None,
101
+ *,
102
+ firstresult: bool = False,
103
+ historic: bool = False,
104
+ pipeline: bool = False,
105
+ ) -> Any:
106
+ """Stamp HookspecOpts onto the function; supports bare and called forms."""
107
+
108
+ def mark(func: Callable[..., Any]) -> Callable[..., Any]:
109
+ setattr(func, self.attribute, HookspecOpts(firstresult=firstresult, historic=historic, pipeline=pipeline))
110
+ return func
111
+
112
+ return mark(function) if function is not None else mark
113
+
114
+
115
+ class HookimplMarker:
116
+ """Creates the @hookimpl decorator bound to a project name."""
117
+
118
+ def __init__(self, project_name: str) -> None:
119
+ """Bind the marker to a project name used for the stamped attribute."""
120
+ self.project_name = project_name
121
+ self.attribute = f"{project_name}_impl"
122
+
123
+ @overload
124
+ def __call__[F: Callable[..., Any]](self, function: F) -> F: ...
125
+ @overload
126
+ def __call__[F: Callable[..., Any]](
127
+ self,
128
+ function: None = ...,
129
+ *,
130
+ tryfirst: bool = ...,
131
+ trylast: bool = ...,
132
+ wrapper: bool = ...,
133
+ optionalhook: bool = ...,
134
+ specname: str | None = ...,
135
+ ) -> Callable[[F], F]: ...
136
+ def __call__[F: Callable[..., Any]](
137
+ self,
138
+ function: F | None = None,
139
+ *,
140
+ tryfirst: bool = False,
141
+ trylast: bool = False,
142
+ wrapper: bool = False,
143
+ optionalhook: bool = False,
144
+ specname: str | None = None,
145
+ ) -> F | Callable[[F], F]:
146
+ """Stamp HookimplOpts onto the function; supports bare and called forms."""
147
+
148
+ def mark(func: F) -> F:
149
+ setattr(
150
+ func,
151
+ self.attribute,
152
+ HookimplOpts(
153
+ tryfirst=tryfirst,
154
+ trylast=trylast,
155
+ wrapper=wrapper,
156
+ optionalhook=optionalhook,
157
+ specname=specname,
158
+ ),
159
+ )
160
+ return func
161
+
162
+ return mark(function) if function is not None else mark
@@ -1,38 +0,0 @@
1
- """pluginkit: a small, dependency-free, pluggy-style plugin framework.
2
-
3
- Public API:
4
-
5
- - :class:`HookspecMarker` / :class:`HookimplMarker` - decorators that declare hook
6
- specifications and implementations.
7
- - :class:`HookspecOpts` / :class:`HookimplOpts` - the option records the markers stamp.
8
- - :class:`PluginManager` - registers plugins and dispatches hook calls.
9
- - :class:`HookRelay` / :class:`HookCaller` / :class:`HookImpl` - the dispatch internals.
10
- - :class:`PluginValidationError` - raised when a plugin is invalid.
11
- """
12
-
13
- from pluginkit.aio import AsyncHookCaller, AsyncPluginManager
14
- from pluginkit.exceptions import PluginValidationError
15
- from pluginkit.manager import HookCaller, HookImpl, HookRelay, PluginManager
16
- from pluginkit.markers import (
17
- HookimplMarker,
18
- HookimplOpts,
19
- HookspecMarker,
20
- HookspecOpts,
21
- )
22
-
23
- __version__ = "0.1.0"
24
-
25
- __all__ = [
26
- "AsyncHookCaller",
27
- "AsyncPluginManager",
28
- "HookCaller",
29
- "HookImpl",
30
- "HookRelay",
31
- "HookimplMarker",
32
- "HookimplOpts",
33
- "HookspecMarker",
34
- "HookspecOpts",
35
- "PluginManager",
36
- "PluginValidationError",
37
- "__version__",
38
- ]
@@ -1,117 +0,0 @@
1
- """Decorator markers that tag functions as hook specs or hook implementations.
2
-
3
- Mirrors pluggy's HookspecMarker / HookimplMarker. A marker stamps a small frozen
4
- dataclass of options onto the decorated function under a project-namespaced
5
- attribute, so the manager can later recognise specs and impls by introspection.
6
- """
7
-
8
- from collections.abc import Callable
9
- from dataclasses import dataclass
10
- from typing import Any, TypeVar, overload
11
-
12
- F = TypeVar("F", bound=Callable[..., Any])
13
-
14
-
15
- @dataclass(frozen=True, slots=True)
16
- class HookspecOpts:
17
- """Options attached to a hook specification."""
18
-
19
- firstresult: bool = False
20
- historic: bool = False
21
- pipeline: bool = False
22
-
23
-
24
- @dataclass(frozen=True, slots=True)
25
- class HookimplOpts:
26
- """Options attached to a hook implementation."""
27
-
28
- tryfirst: bool = False
29
- trylast: bool = False
30
- wrapper: bool = False
31
- optionalhook: bool = False
32
- specname: str | None = None
33
-
34
-
35
- class HookspecMarker:
36
- """Creates the @hookspec decorator bound to a project name."""
37
-
38
- def __init__(self, project_name: str) -> None:
39
- """Bind the marker to a project name used for the stamped attribute."""
40
- self.project_name = project_name
41
- self.attribute = f"{project_name}_spec"
42
-
43
- @overload
44
- def __call__(self, function: F) -> F: ...
45
-
46
- @overload
47
- def __call__(
48
- self, function: None = ..., *, firstresult: bool = ..., historic: bool = ..., pipeline: bool = ...
49
- ) -> Callable[[F], F]: ...
50
-
51
- def __call__(
52
- self,
53
- function: F | None = None,
54
- *,
55
- firstresult: bool = False,
56
- historic: bool = False,
57
- pipeline: bool = False,
58
- ) -> F | Callable[[F], F]:
59
- """Stamp HookspecOpts onto the function; supports bare and called forms."""
60
-
61
- def mark(func: F) -> F:
62
- setattr(func, self.attribute, HookspecOpts(firstresult=firstresult, historic=historic, pipeline=pipeline))
63
- return func
64
-
65
- return mark(function) if function is not None else mark
66
-
67
-
68
- class HookimplMarker:
69
- """Creates the @hookimpl decorator bound to a project name."""
70
-
71
- def __init__(self, project_name: str) -> None:
72
- """Bind the marker to a project name used for the stamped attribute."""
73
- self.project_name = project_name
74
- self.attribute = f"{project_name}_impl"
75
-
76
- @overload
77
- def __call__(self, function: F) -> F: ...
78
-
79
- @overload
80
- def __call__(
81
- self,
82
- function: None = ...,
83
- *,
84
- tryfirst: bool = ...,
85
- trylast: bool = ...,
86
- wrapper: bool = ...,
87
- optionalhook: bool = ...,
88
- specname: str | None = ...,
89
- ) -> Callable[[F], F]: ...
90
-
91
- def __call__(
92
- self,
93
- function: F | None = None,
94
- *,
95
- tryfirst: bool = False,
96
- trylast: bool = False,
97
- wrapper: bool = False,
98
- optionalhook: bool = False,
99
- specname: str | None = None,
100
- ) -> F | Callable[[F], F]:
101
- """Stamp HookimplOpts onto the function; supports bare and called forms."""
102
-
103
- def mark(func: F) -> F:
104
- setattr(
105
- func,
106
- self.attribute,
107
- HookimplOpts(
108
- tryfirst=tryfirst,
109
- trylast=trylast,
110
- wrapper=wrapper,
111
- optionalhook=optionalhook,
112
- specname=specname,
113
- ),
114
- )
115
- return func
116
-
117
- return mark(function) if function is not None else mark