pluginkit 0.1.0__py3-none-any.whl
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.
- pluginkit/__init__.py +38 -0
- pluginkit/aio.py +122 -0
- pluginkit/exceptions.py +10 -0
- pluginkit/manager.py +474 -0
- pluginkit/markers.py +117 -0
- pluginkit/py.typed +0 -0
- pluginkit-0.1.0.dist-info/METADATA +130 -0
- pluginkit-0.1.0.dist-info/RECORD +9 -0
- pluginkit-0.1.0.dist-info/WHEEL +4 -0
pluginkit/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
]
|
pluginkit/aio.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Async dispatch: an AsyncPluginManager that awaits coroutine implementations.
|
|
2
|
+
|
|
3
|
+
The registration, validation and lifecycle machinery is reused unchanged from the
|
|
4
|
+
synchronous manager; only the *calling* path is asynchronous. Implementations may
|
|
5
|
+
be plain functions or coroutine functions - their results are awaited when
|
|
6
|
+
awaitable. Collecting, firstresult and pipeline dispatch are all supported.
|
|
7
|
+
|
|
8
|
+
Wrappers in the async manager are **async generators** and are observe-only: they
|
|
9
|
+
run setup before `yield` and teardown after it (including in a `finally`), and
|
|
10
|
+
they observe exceptions thrown back in, but - because async generators cannot
|
|
11
|
+
return a value - they do not replace the result. Use the synchronous manager when
|
|
12
|
+
a wrapper must transform the result.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from types import AsyncGeneratorType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pluginkit.manager import _UNSET, HookCaller, HookImpl, PluginManager
|
|
21
|
+
from pluginkit.markers import HookimplOpts, HookspecOpts
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncHookCaller(HookCaller):
|
|
25
|
+
"""A HookCaller whose calls are coroutines that await async implementations."""
|
|
26
|
+
|
|
27
|
+
async def __call__(self, **kwargs: Any) -> Any:
|
|
28
|
+
"""Await the hook: a list, a single value (firstresult), or the threaded value (pipeline)."""
|
|
29
|
+
if self.spec.historic:
|
|
30
|
+
raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
|
|
31
|
+
self.check_arguments(kwargs)
|
|
32
|
+
return await self._execute_async(kwargs, self._nonwrappers)
|
|
33
|
+
|
|
34
|
+
async def call_extra(self, functions: list[Callable[..., Any]], kwargs: dict[str, Any]) -> Any:
|
|
35
|
+
"""Await the hook with extra one-off implementations that are not registered."""
|
|
36
|
+
if self.spec.historic:
|
|
37
|
+
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)
|
|
41
|
+
return await self._execute_async(kwargs, combined)
|
|
42
|
+
|
|
43
|
+
def call_historic(self, kwargs: dict[str, Any], result_callback: Callable[[Any], None] | None = None) -> None:
|
|
44
|
+
"""Historic hooks are not supported by the async manager."""
|
|
45
|
+
raise NotImplementedError("historic hooks are not supported by AsyncPluginManager")
|
|
46
|
+
|
|
47
|
+
async def _execute_async(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
48
|
+
"""Start async wrappers, run the impls, then unwind wrappers exception-safely."""
|
|
49
|
+
started: list[AsyncGeneratorType[Any, Any]] = []
|
|
50
|
+
try:
|
|
51
|
+
for wrapper in self._wrappers:
|
|
52
|
+
generator = wrapper.call(kwargs)
|
|
53
|
+
if not isinstance(generator, AsyncGeneratorType):
|
|
54
|
+
raise TypeError(f"async wrapper {wrapper.plugin_name}.{self.name} must be an async generator")
|
|
55
|
+
await generator.__anext__() # advance to the yield
|
|
56
|
+
started.append(generator)
|
|
57
|
+
result = await self._core_async(kwargs, nonwrappers)
|
|
58
|
+
except BaseException as exc: # noqa: BLE001 - re-raised after wrappers observe it
|
|
59
|
+
return await self._teardown_async(started, exc=exc)
|
|
60
|
+
return await self._teardown_async(started, result=result)
|
|
61
|
+
|
|
62
|
+
async def _core_async(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
63
|
+
"""Apply the spec's dispatch strategy, awaiting any awaitable results."""
|
|
64
|
+
if self.spec.pipeline:
|
|
65
|
+
return await self._run_pipeline_async(kwargs, nonwrappers)
|
|
66
|
+
results: list[Any] = []
|
|
67
|
+
for impl in nonwrappers:
|
|
68
|
+
outcome = await _maybe_await(impl.call(kwargs))
|
|
69
|
+
if outcome is None:
|
|
70
|
+
continue
|
|
71
|
+
results.append(outcome)
|
|
72
|
+
if self.spec.firstresult:
|
|
73
|
+
break
|
|
74
|
+
return (results[0] if results else None) if self.spec.firstresult else results
|
|
75
|
+
|
|
76
|
+
async def _run_pipeline_async(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
77
|
+
"""Thread the first argument through each impl, awaiting awaitable results."""
|
|
78
|
+
param = self.params[0]
|
|
79
|
+
value = kwargs[param]
|
|
80
|
+
current = dict(kwargs)
|
|
81
|
+
for impl in nonwrappers:
|
|
82
|
+
current[param] = value
|
|
83
|
+
outcome = await _maybe_await(impl.call(current))
|
|
84
|
+
if outcome is not None:
|
|
85
|
+
value = outcome
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
async def _teardown_async(
|
|
89
|
+
self, started: list[AsyncGeneratorType[Any, Any]], *, result: Any = _UNSET, exc: BaseException | None = None
|
|
90
|
+
) -> Any:
|
|
91
|
+
"""Resume each async wrapper in reverse so its teardown runs and it observes errors."""
|
|
92
|
+
for generator in reversed(started):
|
|
93
|
+
try:
|
|
94
|
+
if exc is not None:
|
|
95
|
+
await generator.athrow(exc)
|
|
96
|
+
else:
|
|
97
|
+
await generator.asend(result)
|
|
98
|
+
except StopAsyncIteration:
|
|
99
|
+
pass # normal completion; async wrappers cannot replace the result
|
|
100
|
+
except BaseException as new_exc: # noqa: BLE001 - propagate the wrapper's error onward
|
|
101
|
+
exc = new_exc
|
|
102
|
+
else:
|
|
103
|
+
await generator.aclose()
|
|
104
|
+
raise RuntimeError(f"async wrapper for {self.name!r} must yield exactly once")
|
|
105
|
+
if exc is not None:
|
|
106
|
+
raise exc
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AsyncPluginManager(PluginManager):
|
|
111
|
+
"""A PluginManager whose hooks are awaited; impls may be coroutine functions."""
|
|
112
|
+
|
|
113
|
+
def _make_caller(self, name: str, spec: HookspecOpts, params: tuple[str, ...]) -> HookCaller:
|
|
114
|
+
"""Build an AsyncHookCaller instead of the synchronous one."""
|
|
115
|
+
return AsyncHookCaller(name=name, spec=spec, params=params)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _maybe_await(value: Any) -> Any:
|
|
119
|
+
"""Await a value if it is awaitable, otherwise return it unchanged."""
|
|
120
|
+
if inspect.isawaitable(value):
|
|
121
|
+
return await value
|
|
122
|
+
return value
|
pluginkit/exceptions.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Exceptions raised by the plugin framework."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PluginValidationError(Exception):
|
|
5
|
+
"""Raised when a plugin or one of its hook implementations is invalid."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, plugin_name: str, message: str) -> None:
|
|
8
|
+
"""Record the offending plugin name alongside the message."""
|
|
9
|
+
self.plugin_name = plugin_name
|
|
10
|
+
super().__init__(f"plugin {plugin_name!r}: {message}")
|
pluginkit/manager.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The plugin manager: registers plugins and dispatches calls to their hooks.
|
|
2
|
+
|
|
3
|
+
A compact but hardened reimplementation of the pluggy ideas worth understanding:
|
|
4
|
+
|
|
5
|
+
- introspection-based discovery of specs and impls via stamped attributes;
|
|
6
|
+
- registration-time validation that impl arguments exist in the spec;
|
|
7
|
+
- per-impl keyword-argument filtering so an impl declares only what it needs;
|
|
8
|
+
- call ordering with tryfirst / trylast;
|
|
9
|
+
- collecting vs firstresult dispatch;
|
|
10
|
+
- generator wrappers that decorate the result and observe exceptions safely;
|
|
11
|
+
- historic hooks replayed to plugins registered later;
|
|
12
|
+
- plugin lifecycle: unregister, blocking, and lookup;
|
|
13
|
+
- external plugin discovery via the stdlib importlib.metadata.
|
|
14
|
+
|
|
15
|
+
The manager is safe to mutate (register / unregister / block) from multiple
|
|
16
|
+
threads; hook *calls* are not internally locked and should be coordinated by the
|
|
17
|
+
caller if they can race with registration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import inspect
|
|
21
|
+
import threading
|
|
22
|
+
from collections.abc import Callable, Generator, Iterator
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from importlib.metadata import entry_points
|
|
25
|
+
from types import GeneratorType
|
|
26
|
+
from typing import Any, Self
|
|
27
|
+
|
|
28
|
+
from pluginkit.exceptions import PluginValidationError
|
|
29
|
+
from pluginkit.markers import HookimplOpts, HookspecOpts
|
|
30
|
+
|
|
31
|
+
# Sentinel distinguishing "no result yet" from a legitimate None result.
|
|
32
|
+
_UNSET: Any = object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class HookImpl:
|
|
37
|
+
"""One plugin's implementation of a hook, plus the kwargs it accepts."""
|
|
38
|
+
|
|
39
|
+
plugin_name: str
|
|
40
|
+
function: Callable[..., Any]
|
|
41
|
+
opts: HookimplOpts
|
|
42
|
+
accepts: frozenset[str]
|
|
43
|
+
params: tuple[str, ...]
|
|
44
|
+
# Set by the caller once it knows the hook's full argument set: True when this
|
|
45
|
+
# impl declares exactly those arguments, so kwargs can be forwarded directly.
|
|
46
|
+
passthrough: bool = False
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_function(cls, plugin_name: str, function: Callable[..., Any], opts: HookimplOpts) -> Self:
|
|
50
|
+
"""Build an impl, recording which keyword arguments the function declares."""
|
|
51
|
+
params = tuple(inspect.signature(function).parameters)
|
|
52
|
+
return cls(plugin_name=plugin_name, function=function, opts=opts, accepts=frozenset(params), params=params)
|
|
53
|
+
|
|
54
|
+
def call(self, kwargs: dict[str, Any]) -> Any:
|
|
55
|
+
"""Invoke the function, passing only the arguments it declares.
|
|
56
|
+
|
|
57
|
+
The caller guarantees every declared argument is present in kwargs, so the
|
|
58
|
+
common "takes all the spec's arguments" case forwards kwargs directly and a
|
|
59
|
+
subset impl indexes the few it wants - both avoiding a membership scan.
|
|
60
|
+
"""
|
|
61
|
+
if self.passthrough:
|
|
62
|
+
return self.function(**kwargs)
|
|
63
|
+
return self.function(**{name: kwargs[name] for name in self.params})
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def order_key(self) -> int:
|
|
67
|
+
"""Sort key: tryfirst impls run first (0), normal next (1), trylast last (2)."""
|
|
68
|
+
match self.opts:
|
|
69
|
+
case HookimplOpts(tryfirst=True):
|
|
70
|
+
return 0
|
|
71
|
+
case HookimplOpts(trylast=True):
|
|
72
|
+
return 2
|
|
73
|
+
case _:
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class HookCaller:
|
|
79
|
+
"""Holds every implementation of one hook and dispatches calls to them."""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
spec: HookspecOpts
|
|
83
|
+
params: tuple[str, ...] = ()
|
|
84
|
+
argnames: frozenset[str] = frozenset()
|
|
85
|
+
_impls: list[HookImpl] = field(default_factory=list)
|
|
86
|
+
_wrappers: list[HookImpl] = field(default_factory=list)
|
|
87
|
+
_nonwrappers: list[HookImpl] = field(default_factory=list)
|
|
88
|
+
_history: list[tuple[dict[str, Any], Callable[[Any], None] | None]] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
"""Derive the argument-name set from the ordered parameters when given."""
|
|
92
|
+
if self.params and not self.argnames:
|
|
93
|
+
self.argnames = frozenset(self.params)
|
|
94
|
+
|
|
95
|
+
def check_arguments(self, kwargs: dict[str, Any]) -> None:
|
|
96
|
+
"""Validate that a call supplies exactly the spec's arguments."""
|
|
97
|
+
# dict_keys compares as a set against the frozenset without allocating one.
|
|
98
|
+
if kwargs.keys() == self.argnames:
|
|
99
|
+
return
|
|
100
|
+
provided = frozenset(kwargs)
|
|
101
|
+
problems: list[str] = []
|
|
102
|
+
missing = self.argnames - provided
|
|
103
|
+
unknown = provided - self.argnames
|
|
104
|
+
if missing:
|
|
105
|
+
problems.append(f"missing {sorted(missing)}")
|
|
106
|
+
if unknown:
|
|
107
|
+
problems.append(f"unknown {sorted(unknown)}")
|
|
108
|
+
raise TypeError(f"hook {self.name!r} called with {'; '.join(problems)}; expects {sorted(self.argnames)}")
|
|
109
|
+
|
|
110
|
+
def add_impl(self, impl: HookImpl) -> None:
|
|
111
|
+
"""Add an impl in priority order and replay any historic calls to it."""
|
|
112
|
+
impl.passthrough = impl.accepts == self.argnames
|
|
113
|
+
self._impls.append(impl)
|
|
114
|
+
self._reindex()
|
|
115
|
+
for kwargs, callback in self._history:
|
|
116
|
+
outcome = impl.call(kwargs)
|
|
117
|
+
if outcome is not None and callback is not None:
|
|
118
|
+
callback(outcome)
|
|
119
|
+
|
|
120
|
+
def remove_plugin(self, plugin_name: str) -> bool:
|
|
121
|
+
"""Drop every impl contributed by a plugin; return True if any were removed."""
|
|
122
|
+
before = len(self._impls)
|
|
123
|
+
self._impls = [impl for impl in self._impls if impl.plugin_name != plugin_name]
|
|
124
|
+
removed = len(self._impls) != before
|
|
125
|
+
if removed:
|
|
126
|
+
self._reindex()
|
|
127
|
+
return removed
|
|
128
|
+
|
|
129
|
+
def has_plugin(self, plugin_name: str) -> bool:
|
|
130
|
+
"""Return whether the named plugin contributes any impl to this hook."""
|
|
131
|
+
return any(impl.plugin_name == plugin_name for impl in self._impls)
|
|
132
|
+
|
|
133
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
134
|
+
"""Call the hook: a list, a single value (firstresult), or the threaded value (pipeline)."""
|
|
135
|
+
if self.spec.historic:
|
|
136
|
+
raise TypeError(f"historic hook {self.name!r} must be called via call_historic()")
|
|
137
|
+
self.check_arguments(kwargs)
|
|
138
|
+
return self._execute(kwargs, self._nonwrappers)
|
|
139
|
+
|
|
140
|
+
def call_extra(self, functions: list[Callable[..., Any]], kwargs: dict[str, Any]) -> Any:
|
|
141
|
+
"""Call the hook with extra one-off implementations that are not registered.
|
|
142
|
+
|
|
143
|
+
The extra functions run as normal-priority implementations for this call
|
|
144
|
+
only, ordered after the already-registered ones. Useful for tests and for
|
|
145
|
+
injecting a temporary implementation without mutating the manager.
|
|
146
|
+
"""
|
|
147
|
+
if self.spec.historic:
|
|
148
|
+
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)
|
|
159
|
+
return self._execute(kwargs, combined)
|
|
160
|
+
|
|
161
|
+
def call_historic(self, kwargs: dict[str, Any], result_callback: Callable[[Any], None] | None = None) -> None:
|
|
162
|
+
"""Call a historic hook now and remember it for plugins registered later."""
|
|
163
|
+
if not self.spec.historic:
|
|
164
|
+
raise TypeError(f"hook {self.name!r} is not historic")
|
|
165
|
+
self.check_arguments(kwargs)
|
|
166
|
+
self._history.append((kwargs, result_callback))
|
|
167
|
+
for outcome in self._collect(kwargs):
|
|
168
|
+
if result_callback is not None:
|
|
169
|
+
result_callback(outcome)
|
|
170
|
+
|
|
171
|
+
def _reindex(self) -> None:
|
|
172
|
+
"""Re-sort impls by priority and refresh the wrapper / non-wrapper split."""
|
|
173
|
+
# Stable sort keeps registration order within each priority bucket.
|
|
174
|
+
self._impls.sort(key=lambda candidate: candidate.order_key)
|
|
175
|
+
self._wrappers = [impl for impl in self._impls if impl.opts.wrapper]
|
|
176
|
+
self._nonwrappers = [impl for impl in self._impls if not impl.opts.wrapper]
|
|
177
|
+
|
|
178
|
+
def _execute(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
179
|
+
"""Run the inner impls, wrapped by any wrappers, unwinding exception-safely."""
|
|
180
|
+
# Fast path: with no wrappers there is nothing to unwind, so skip the
|
|
181
|
+
# try/except and generator bookkeeping entirely and let errors propagate.
|
|
182
|
+
if not self._wrappers:
|
|
183
|
+
return self._core(kwargs, nonwrappers)
|
|
184
|
+
started: list[Generator[Any, Any, Any]] = []
|
|
185
|
+
try:
|
|
186
|
+
for wrapper in self._wrappers:
|
|
187
|
+
generator = wrapper.call(kwargs)
|
|
188
|
+
if not isinstance(generator, GeneratorType):
|
|
189
|
+
raise TypeError(f"wrapper {wrapper.plugin_name}.{self.name} must be a generator function")
|
|
190
|
+
next(generator) # advance to the yield
|
|
191
|
+
started.append(generator)
|
|
192
|
+
result = self._core(kwargs, nonwrappers)
|
|
193
|
+
except BaseException as exc: # noqa: BLE001 - re-raised after wrappers observe it
|
|
194
|
+
return self._teardown(started, exc=exc)
|
|
195
|
+
return self._teardown(started, result=result)
|
|
196
|
+
|
|
197
|
+
def _core(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
198
|
+
"""Apply the spec's dispatch strategy to the non-wrapper impls."""
|
|
199
|
+
if self.spec.pipeline:
|
|
200
|
+
return self._run_pipeline(kwargs, nonwrappers)
|
|
201
|
+
if self.spec.firstresult:
|
|
202
|
+
for impl in nonwrappers:
|
|
203
|
+
outcome = impl.call(kwargs)
|
|
204
|
+
if outcome is not None:
|
|
205
|
+
return outcome
|
|
206
|
+
return None
|
|
207
|
+
results: list[Any] = []
|
|
208
|
+
for impl in nonwrappers:
|
|
209
|
+
outcome = impl.call(kwargs)
|
|
210
|
+
if outcome is not None:
|
|
211
|
+
results.append(outcome)
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
def _run_pipeline(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Any:
|
|
215
|
+
"""Thread the first argument through each impl, feeding its result to the next."""
|
|
216
|
+
param = self.params[0]
|
|
217
|
+
value = kwargs[param]
|
|
218
|
+
current = dict(kwargs)
|
|
219
|
+
for impl in nonwrappers:
|
|
220
|
+
current[param] = value
|
|
221
|
+
outcome = impl.call(current)
|
|
222
|
+
if outcome is not None: # None means "pass the value through unchanged"
|
|
223
|
+
value = outcome
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
def _collect(self, kwargs: dict[str, Any]) -> list[Any]:
|
|
227
|
+
"""Return the non-None results of the non-wrapper impls as a list."""
|
|
228
|
+
return list(self._collect_iter(kwargs, self._nonwrappers))
|
|
229
|
+
|
|
230
|
+
def _collect_iter(self, kwargs: dict[str, Any], nonwrappers: list[HookImpl]) -> Iterator[Any]:
|
|
231
|
+
"""Yield non-None results from the given non-wrapper impls, honouring firstresult."""
|
|
232
|
+
for impl in nonwrappers:
|
|
233
|
+
outcome = impl.call(kwargs)
|
|
234
|
+
if outcome is None:
|
|
235
|
+
continue
|
|
236
|
+
yield outcome
|
|
237
|
+
if self.spec.firstresult:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
def _teardown(
|
|
241
|
+
self, started: list[Generator[Any, Any, Any]], *, result: Any = _UNSET, exc: BaseException | None = None
|
|
242
|
+
) -> Any:
|
|
243
|
+
"""Resume each wrapper in reverse, letting it replace the result or handle the error."""
|
|
244
|
+
for generator in reversed(started):
|
|
245
|
+
try:
|
|
246
|
+
if exc is not None:
|
|
247
|
+
generator.throw(exc)
|
|
248
|
+
else:
|
|
249
|
+
generator.send(result)
|
|
250
|
+
except StopIteration as stop:
|
|
251
|
+
# A wrapper that returns after the yield ends here.
|
|
252
|
+
if exc is not None:
|
|
253
|
+
# The wrapper swallowed the exception and supplied a result.
|
|
254
|
+
exc = None
|
|
255
|
+
result = stop.value
|
|
256
|
+
elif stop.value is not None:
|
|
257
|
+
result = stop.value
|
|
258
|
+
except BaseException as new_exc: # noqa: BLE001 - propagate the wrapper's error onward
|
|
259
|
+
exc = new_exc
|
|
260
|
+
else:
|
|
261
|
+
# The generator yielded a second time, violating the one-yield contract.
|
|
262
|
+
generator.close()
|
|
263
|
+
raise RuntimeError(f"wrapper for {self.name!r} must yield exactly once")
|
|
264
|
+
if exc is not None:
|
|
265
|
+
raise exc
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
def implementations(self) -> list[HookImpl]:
|
|
269
|
+
"""Return this hook's implementations in call order (wrappers excluded)."""
|
|
270
|
+
return list(self._nonwrappers)
|
|
271
|
+
|
|
272
|
+
def __repr__(self) -> str:
|
|
273
|
+
"""Show the hook name and how many implementations it has."""
|
|
274
|
+
return f"<HookCaller {self.name!r} impls={len(self._impls)}>"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class HookRelay:
|
|
278
|
+
"""Attribute-style access to hook callers, e.g. pm.hook.add_ingredients(...)."""
|
|
279
|
+
|
|
280
|
+
def __init__(self) -> None:
|
|
281
|
+
"""Start with no registered callers."""
|
|
282
|
+
self._callers: dict[str, HookCaller] = {}
|
|
283
|
+
|
|
284
|
+
def _add_caller(self, caller: HookCaller) -> None:
|
|
285
|
+
"""Register a caller under its hook name."""
|
|
286
|
+
self._callers[caller.name] = caller
|
|
287
|
+
|
|
288
|
+
def _get_caller(self, name: str) -> HookCaller | None:
|
|
289
|
+
"""Return the caller for a hook name, or None if undefined."""
|
|
290
|
+
return self._callers.get(name)
|
|
291
|
+
|
|
292
|
+
def _all_callers(self) -> list[HookCaller]:
|
|
293
|
+
"""Return every registered caller."""
|
|
294
|
+
return list(self._callers.values())
|
|
295
|
+
|
|
296
|
+
def __getattr__(self, name: str) -> HookCaller:
|
|
297
|
+
"""Resolve pm.hook.<name> to its HookCaller, or raise AttributeError."""
|
|
298
|
+
try:
|
|
299
|
+
return self._callers[name]
|
|
300
|
+
except KeyError:
|
|
301
|
+
raise AttributeError(f"no hook named {name!r}") from None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class PluginManager:
|
|
305
|
+
"""Registers plugins and exposes their hooks via a HookRelay."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, project_name: str) -> None:
|
|
308
|
+
"""Bind the manager to a project name shared with the markers."""
|
|
309
|
+
self.project_name = project_name
|
|
310
|
+
self.hook = HookRelay()
|
|
311
|
+
self._spec_attribute = f"{project_name}_spec"
|
|
312
|
+
self._impl_attribute = f"{project_name}_impl"
|
|
313
|
+
self._name2plugin: dict[str, object] = {}
|
|
314
|
+
self._blocked: set[str] = set()
|
|
315
|
+
self._lock = threading.RLock()
|
|
316
|
+
|
|
317
|
+
def add_hookspecs(self, namespace: object) -> None:
|
|
318
|
+
"""Scan a module (or object) for hook specifications and create callers."""
|
|
319
|
+
with self._lock:
|
|
320
|
+
for member_name in dir(namespace):
|
|
321
|
+
member = getattr(namespace, member_name)
|
|
322
|
+
spec = getattr(member, self._spec_attribute, None)
|
|
323
|
+
if not isinstance(spec, HookspecOpts):
|
|
324
|
+
continue
|
|
325
|
+
params = tuple(inspect.signature(member).parameters)
|
|
326
|
+
self._validate_spec(member_name, spec, params)
|
|
327
|
+
self.hook._add_caller(self._make_caller(member_name, spec, params))
|
|
328
|
+
|
|
329
|
+
def _make_caller(self, name: str, spec: HookspecOpts, params: tuple[str, ...]) -> HookCaller:
|
|
330
|
+
"""Build the caller for a spec; overridden by AsyncPluginManager."""
|
|
331
|
+
return HookCaller(name=name, spec=spec, params=params)
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def _validate_spec(name: str, spec: HookspecOpts, params: tuple[str, ...]) -> None:
|
|
335
|
+
"""Reject contradictory or impossible spec option combinations."""
|
|
336
|
+
modes = [
|
|
337
|
+
mode
|
|
338
|
+
for mode, on in (
|
|
339
|
+
("firstresult", spec.firstresult),
|
|
340
|
+
("historic", spec.historic),
|
|
341
|
+
("pipeline", spec.pipeline),
|
|
342
|
+
)
|
|
343
|
+
if on
|
|
344
|
+
]
|
|
345
|
+
if len(modes) > 1:
|
|
346
|
+
raise ValueError(f"hook {name!r} cannot combine {' and '.join(modes)}")
|
|
347
|
+
if spec.pipeline and not params:
|
|
348
|
+
raise ValueError(f"pipeline hook {name!r} must declare at least one argument to thread through")
|
|
349
|
+
|
|
350
|
+
def register(self, plugin: object, name: str | None = None) -> str:
|
|
351
|
+
"""Register a plugin object, wiring up every hook implementation it carries."""
|
|
352
|
+
with self._lock:
|
|
353
|
+
plugin_name = name or self.get_canonical_name(plugin)
|
|
354
|
+
if plugin_name in self._blocked:
|
|
355
|
+
raise ValueError(f"plugin {plugin_name!r} is blocked")
|
|
356
|
+
if plugin_name in self._name2plugin:
|
|
357
|
+
raise ValueError(f"plugin name {plugin_name!r} is already registered")
|
|
358
|
+
if any(existing is plugin for existing in self._name2plugin.values()):
|
|
359
|
+
raise ValueError(f"plugin object {plugin!r} is already registered")
|
|
360
|
+
|
|
361
|
+
impls = self._collect_impls(plugin_name, plugin)
|
|
362
|
+
self._name2plugin[plugin_name] = plugin
|
|
363
|
+
for caller, impl in impls:
|
|
364
|
+
caller.add_impl(impl)
|
|
365
|
+
return plugin_name
|
|
366
|
+
|
|
367
|
+
def unregister(self, name_or_plugin: str | object) -> object | None:
|
|
368
|
+
"""Remove a plugin by name or by object; return the removed plugin or None."""
|
|
369
|
+
with self._lock:
|
|
370
|
+
name = name_or_plugin if isinstance(name_or_plugin, str) else self.get_name(name_or_plugin)
|
|
371
|
+
if name is None:
|
|
372
|
+
return None
|
|
373
|
+
plugin = self._name2plugin.pop(name, None)
|
|
374
|
+
if plugin is None:
|
|
375
|
+
return None
|
|
376
|
+
for caller in self.hook._all_callers():
|
|
377
|
+
caller.remove_plugin(name)
|
|
378
|
+
return plugin
|
|
379
|
+
|
|
380
|
+
def set_blocked(self, name: str) -> None:
|
|
381
|
+
"""Block a plugin name: unregister it if present and refuse future registration."""
|
|
382
|
+
with self._lock:
|
|
383
|
+
self._blocked.add(name)
|
|
384
|
+
self.unregister(name)
|
|
385
|
+
|
|
386
|
+
def is_blocked(self, name: str) -> bool:
|
|
387
|
+
"""Return whether a plugin name is blocked."""
|
|
388
|
+
return name in self._blocked
|
|
389
|
+
|
|
390
|
+
def is_registered(self, plugin: object) -> bool:
|
|
391
|
+
"""Return whether a plugin object is currently registered."""
|
|
392
|
+
return any(existing is plugin for existing in self._name2plugin.values())
|
|
393
|
+
|
|
394
|
+
def get_plugin(self, name: str) -> object | None:
|
|
395
|
+
"""Return the plugin registered under a name, or None."""
|
|
396
|
+
return self._name2plugin.get(name)
|
|
397
|
+
|
|
398
|
+
def get_name(self, plugin: object) -> str | None:
|
|
399
|
+
"""Return the registered name of a plugin object, or None."""
|
|
400
|
+
for registered_name, registered_plugin in self._name2plugin.items():
|
|
401
|
+
if registered_plugin is plugin:
|
|
402
|
+
return registered_name
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
def get_canonical_name(self, plugin: object) -> str:
|
|
406
|
+
"""Derive a default name for a plugin from its __name__ or type."""
|
|
407
|
+
return getattr(plugin, "__name__", None) or type(plugin).__name__
|
|
408
|
+
|
|
409
|
+
def plugin_names(self) -> list[str]:
|
|
410
|
+
"""Return the names of all registered plugins, in registration order."""
|
|
411
|
+
return list(self._name2plugin)
|
|
412
|
+
|
|
413
|
+
def get_hookcallers(self, plugin: object) -> list[HookCaller] | None:
|
|
414
|
+
"""Return the hooks a registered plugin contributes to, or None if unknown."""
|
|
415
|
+
name = self.get_name(plugin)
|
|
416
|
+
if name is None:
|
|
417
|
+
return None
|
|
418
|
+
return [caller for caller in self.hook._all_callers() if caller.has_plugin(name)]
|
|
419
|
+
|
|
420
|
+
def __repr__(self) -> str:
|
|
421
|
+
"""Show the project name and number of registered plugins."""
|
|
422
|
+
return f"<PluginManager {self.project_name!r} plugins={len(self._name2plugin)}>"
|
|
423
|
+
|
|
424
|
+
def load_entrypoints(self, group: str, *, ignore_errors: bool = False) -> int:
|
|
425
|
+
"""Discover and register external plugins advertised under an entry-point group.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
group: The entry-point group name to scan.
|
|
429
|
+
ignore_errors: When True, skip plugins that fail to load or register
|
|
430
|
+
instead of raising, so one broken plugin cannot block discovery.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
The number of plugins successfully registered.
|
|
434
|
+
"""
|
|
435
|
+
count = 0
|
|
436
|
+
for entry_point in entry_points(group=group):
|
|
437
|
+
if entry_point.name in self._name2plugin or entry_point.name in self._blocked:
|
|
438
|
+
continue
|
|
439
|
+
try:
|
|
440
|
+
plugin = entry_point.load()
|
|
441
|
+
self.register(plugin, name=entry_point.name)
|
|
442
|
+
except Exception as error:
|
|
443
|
+
if ignore_errors:
|
|
444
|
+
continue
|
|
445
|
+
raise PluginValidationError(entry_point.name, f"failed to load entry point: {error}") from error
|
|
446
|
+
count += 1
|
|
447
|
+
return count
|
|
448
|
+
|
|
449
|
+
def _collect_impls(self, plugin_name: str, plugin: object) -> list[tuple[HookCaller, HookImpl]]:
|
|
450
|
+
"""Find and validate every hook implementation a plugin carries."""
|
|
451
|
+
collected: list[tuple[HookCaller, HookImpl]] = []
|
|
452
|
+
for member_name in dir(plugin):
|
|
453
|
+
member = getattr(plugin, member_name)
|
|
454
|
+
opts = getattr(member, self._impl_attribute, None)
|
|
455
|
+
if not isinstance(opts, HookimplOpts):
|
|
456
|
+
continue
|
|
457
|
+
hook_name = opts.specname or member_name
|
|
458
|
+
caller = self.hook._get_caller(hook_name)
|
|
459
|
+
if caller is None:
|
|
460
|
+
if opts.optionalhook:
|
|
461
|
+
continue
|
|
462
|
+
raise PluginValidationError(plugin_name, f"implements unknown hook {hook_name!r}")
|
|
463
|
+
if opts.wrapper and caller.spec.historic:
|
|
464
|
+
raise PluginValidationError(plugin_name, f"historic hook {hook_name!r} cannot have a wrapper")
|
|
465
|
+
impl = HookImpl.from_function(plugin_name, member, opts)
|
|
466
|
+
unknown = impl.accepts - caller.argnames
|
|
467
|
+
if unknown:
|
|
468
|
+
raise PluginValidationError(
|
|
469
|
+
plugin_name,
|
|
470
|
+
f"hook {hook_name!r} impl declares unknown argument(s) {sorted(unknown)}; "
|
|
471
|
+
f"spec accepts {sorted(caller.argnames)}",
|
|
472
|
+
)
|
|
473
|
+
collected.append((caller, impl))
|
|
474
|
+
return collected
|
pluginkit/markers.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
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
|
pluginkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pluginkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, dependency-free plugin framework: hook specs, entry-point discovery, and sync/async dispatch
|
|
5
|
+
Keywords: plugins,hooks,protocol,entry-points
|
|
6
|
+
Author: Morten Hansen
|
|
7
|
+
Author-email: Morten Hansen <morten@winterop.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Project-URL: Homepage, https://github.com/winterop-com/pluginkit
|
|
17
|
+
Project-URL: Repository, https://github.com/winterop-com/pluginkit
|
|
18
|
+
Project-URL: Issues, https://github.com/winterop-com/pluginkit/issues
|
|
19
|
+
Project-URL: Documentation, https://winterop-com.github.io/pluginkit
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# pluginkit
|
|
23
|
+
|
|
24
|
+
A small, **dependency-free** plugin framework for Python: declare hook
|
|
25
|
+
specifications, let plugins implement them, and discover plugins via entry points.
|
|
26
|
+
Supports sync and async dispatch, hook ordering, wrappers, pipeline (fold)
|
|
27
|
+
dispatch, and historic hooks - in a few readable files.
|
|
28
|
+
|
|
29
|
+
The library is three files under `src/pluginkit/` (`markers.py`, `manager.py`,
|
|
30
|
+
`exceptions.py`), has **zero runtime dependencies** (standard library only), and
|
|
31
|
+
ships a `py.typed` marker.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pluginkit import HookspecMarker, HookimplMarker, PluginManager
|
|
35
|
+
|
|
36
|
+
hookspec = HookspecMarker("greeter")
|
|
37
|
+
hookimpl = HookimplMarker("greeter")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Specs:
|
|
41
|
+
@staticmethod
|
|
42
|
+
@hookspec
|
|
43
|
+
def greeting(name: str) -> str:
|
|
44
|
+
"""Return a greeting for the given name."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Casual:
|
|
48
|
+
@hookimpl
|
|
49
|
+
def greeting(self, name: str) -> str:
|
|
50
|
+
return f"hey {name}!"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
pm = PluginManager("greeter")
|
|
54
|
+
pm.add_hookspecs(Specs)
|
|
55
|
+
pm.register(Casual(), name="casual")
|
|
56
|
+
print(pm.hook.greeting(name="Ada")) # ['hey Ada!']
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What it supports
|
|
60
|
+
|
|
61
|
+
- collecting, `firstresult`, and **pipeline** (fold/middleware) hooks;
|
|
62
|
+
- call ordering with `tryfirst` / `trylast`, plus `optionalhook` and `specname`;
|
|
63
|
+
- generator **wrappers** that decorate results and observe exceptions safely;
|
|
64
|
+
- **historic** hooks replayed to plugins registered later;
|
|
65
|
+
- **async** dispatch via `AsyncPluginManager` (awaits coroutine impls);
|
|
66
|
+
- plugin lifecycle: `register`, `unregister` (by name or object), `set_blocked`,
|
|
67
|
+
lookup, `call_extra`;
|
|
68
|
+
- registration-time validation and call-time argument checking (failures are loud);
|
|
69
|
+
- external plugin discovery via the stdlib `importlib.metadata` (no setuptools);
|
|
70
|
+
- thread-safe registry mutation.
|
|
71
|
+
|
|
72
|
+
## Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
src/pluginkit/ the library (pure - no demo code)
|
|
76
|
+
tour/ pluginkit-tour: a guided CLI walkthrough, one step per mechanism
|
|
77
|
+
examples/ standalone single-file recipes, run directly
|
|
78
|
+
plugins/smoothie-extra/ an external plugin distribution (its own uv project)
|
|
79
|
+
docs/ mkdocs + Material documentation
|
|
80
|
+
tests/ library, tour, and example tests
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The **tour** and **examples** are two complementary ways to learn it: the tour is
|
|
84
|
+
a guided walkthrough on one host (`pluginkit-tour run all`), while the examples
|
|
85
|
+
are independent, real-world recipes you run on their own.
|
|
86
|
+
|
|
87
|
+
## Use it
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
make install # uv sync (library + tour + external plugin)
|
|
91
|
+
make test # pytest (framework, tour, examples)
|
|
92
|
+
make lint # ruff + mypy + pyright
|
|
93
|
+
make docs-serve # serve the docs at http://127.0.0.1:8000
|
|
94
|
+
make docs-build # build the docs (strict)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Two ways to learn it
|
|
98
|
+
|
|
99
|
+
**The tour** (`tour/`) walks through one mechanism at a time on a single host:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
make run # run every step
|
|
103
|
+
make run DEMO=wrapper # run one
|
|
104
|
+
uv run pluginkit-tour list
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**The examples** apply the library to different realistic domains - see
|
|
108
|
+
[`examples/`](examples/README.md):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv run python examples/report_builder.py
|
|
112
|
+
uv run python examples/notification_router.py
|
|
113
|
+
uv run python examples/validation_rules.py
|
|
114
|
+
uv run python examples/app_lifecycle.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
Full docs (concepts, one page per mechanism, production/hardening notes, and a
|
|
120
|
+
generated API reference) live under [`docs/`](docs/index.md). Serve them with
|
|
121
|
+
`make docs-serve`.
|
|
122
|
+
|
|
123
|
+
## Is it production ready?
|
|
124
|
+
|
|
125
|
+
It is solid - exception-safe wrappers, fail-fast validation, lifecycle management,
|
|
126
|
+
resilient discovery, thread-safe mutation, strict typing, and a test suite. But
|
|
127
|
+
for anything you ship, prefer **pluggy** itself: it is maintained and battle
|
|
128
|
+
tested by pytest, tox, and datasette. See
|
|
129
|
+
[docs/production/vs-pluggy.md](docs/production/vs-pluggy.md) for the honest
|
|
130
|
+
inventory of what differs.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pluginkit/__init__.py,sha256=JMt_aTN4o1MLHzHklZxd-Nb0DCBWvZ32fe8oNIrZO-s,1123
|
|
2
|
+
pluginkit/aio.py,sha256=LPyTqo4j8LBqWboceAHasKIwqYhwil_VmIjA0jE6IJI,5996
|
|
3
|
+
pluginkit/exceptions.py,sha256=3HrP-dm59wpBAZXkfmBt_L7N-0-F9ZeQk5fQ9mTAfA0,406
|
|
4
|
+
pluginkit/manager.py,sha256=bjPJlP64IPnWGpuEG1ZqNhJzXesNtLqk8Glhx89s6WI,21502
|
|
5
|
+
pluginkit/markers.py,sha256=kHSTT6MEfOpLSG1hpKMmKGi056dEXN1RYlBR9tuh0PU,3476
|
|
6
|
+
pluginkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pluginkit-0.1.0.dist-info/WHEEL,sha256=s49dN1sxqzkgPplo4QuUaKomil-_cbDzeLK4-pZKD-A,81
|
|
8
|
+
pluginkit-0.1.0.dist-info/METADATA,sha256=lNVSI7bv8SCyX03PDGZk31-uLeLGdbJE6ouxubPmTWg,4734
|
|
9
|
+
pluginkit-0.1.0.dist-info/RECORD,,
|