pluginkit 0.1.0__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.
@@ -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,109 @@
1
+ # pluginkit
2
+
3
+ A small, **dependency-free** plugin framework for Python: declare hook
4
+ specifications, let plugins implement them, and discover plugins via entry points.
5
+ Supports sync and async dispatch, hook ordering, wrappers, pipeline (fold)
6
+ dispatch, and historic hooks - in a few readable files.
7
+
8
+ The library is three files under `src/pluginkit/` (`markers.py`, `manager.py`,
9
+ `exceptions.py`), has **zero runtime dependencies** (standard library only), and
10
+ ships a `py.typed` marker.
11
+
12
+ ```python
13
+ from pluginkit import HookspecMarker, HookimplMarker, PluginManager
14
+
15
+ hookspec = HookspecMarker("greeter")
16
+ hookimpl = HookimplMarker("greeter")
17
+
18
+
19
+ class Specs:
20
+ @staticmethod
21
+ @hookspec
22
+ def greeting(name: str) -> str:
23
+ """Return a greeting for the given name."""
24
+
25
+
26
+ class Casual:
27
+ @hookimpl
28
+ def greeting(self, name: str) -> str:
29
+ return f"hey {name}!"
30
+
31
+
32
+ pm = PluginManager("greeter")
33
+ pm.add_hookspecs(Specs)
34
+ pm.register(Casual(), name="casual")
35
+ print(pm.hook.greeting(name="Ada")) # ['hey Ada!']
36
+ ```
37
+
38
+ ## What it supports
39
+
40
+ - collecting, `firstresult`, and **pipeline** (fold/middleware) hooks;
41
+ - call ordering with `tryfirst` / `trylast`, plus `optionalhook` and `specname`;
42
+ - generator **wrappers** that decorate results and observe exceptions safely;
43
+ - **historic** hooks replayed to plugins registered later;
44
+ - **async** dispatch via `AsyncPluginManager` (awaits coroutine impls);
45
+ - plugin lifecycle: `register`, `unregister` (by name or object), `set_blocked`,
46
+ lookup, `call_extra`;
47
+ - registration-time validation and call-time argument checking (failures are loud);
48
+ - external plugin discovery via the stdlib `importlib.metadata` (no setuptools);
49
+ - thread-safe registry mutation.
50
+
51
+ ## Layout
52
+
53
+ ```
54
+ src/pluginkit/ the library (pure - no demo code)
55
+ tour/ pluginkit-tour: a guided CLI walkthrough, one step per mechanism
56
+ examples/ standalone single-file recipes, run directly
57
+ plugins/smoothie-extra/ an external plugin distribution (its own uv project)
58
+ docs/ mkdocs + Material documentation
59
+ tests/ library, tour, and example tests
60
+ ```
61
+
62
+ The **tour** and **examples** are two complementary ways to learn it: the tour is
63
+ a guided walkthrough on one host (`pluginkit-tour run all`), while the examples
64
+ are independent, real-world recipes you run on their own.
65
+
66
+ ## Use it
67
+
68
+ ```bash
69
+ make install # uv sync (library + tour + external plugin)
70
+ make test # pytest (framework, tour, examples)
71
+ make lint # ruff + mypy + pyright
72
+ make docs-serve # serve the docs at http://127.0.0.1:8000
73
+ make docs-build # build the docs (strict)
74
+ ```
75
+
76
+ ## Two ways to learn it
77
+
78
+ **The tour** (`tour/`) walks through one mechanism at a time on a single host:
79
+
80
+ ```bash
81
+ make run # run every step
82
+ make run DEMO=wrapper # run one
83
+ uv run pluginkit-tour list
84
+ ```
85
+
86
+ **The examples** apply the library to different realistic domains - see
87
+ [`examples/`](examples/README.md):
88
+
89
+ ```bash
90
+ uv run python examples/report_builder.py
91
+ uv run python examples/notification_router.py
92
+ uv run python examples/validation_rules.py
93
+ uv run python examples/app_lifecycle.py
94
+ ```
95
+
96
+ ## Documentation
97
+
98
+ Full docs (concepts, one page per mechanism, production/hardening notes, and a
99
+ generated API reference) live under [`docs/`](docs/index.md). Serve them with
100
+ `make docs-serve`.
101
+
102
+ ## Is it production ready?
103
+
104
+ It is solid - exception-safe wrappers, fail-fast validation, lifecycle management,
105
+ resilient discovery, thread-safe mutation, strict typing, and a test suite. But
106
+ for anything you ship, prefer **pluggy** itself: it is maintained and battle
107
+ tested by pytest, tox, and datasette. See
108
+ [docs/production/vs-pluggy.md](docs/production/vs-pluggy.md) for the honest
109
+ inventory of what differs.
@@ -0,0 +1,118 @@
1
+ [project]
2
+ name = "pluginkit"
3
+ version = "0.1.0"
4
+ description = "A small, dependency-free plugin framework: hook specs, entry-point discovery, and sync/async dispatch"
5
+ readme = "README.md"
6
+ authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
7
+ license = { text = "MIT" }
8
+ requires-python = ">=3.13"
9
+ keywords = ["plugins", "hooks", "protocol", "entry-points"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Software Development :: Libraries :: Python Modules",
16
+ "Typing :: Typed",
17
+ ]
18
+ # The library itself has ZERO runtime dependencies (standard library only).
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/winterop-com/pluginkit"
23
+ Repository = "https://github.com/winterop-com/pluginkit"
24
+ Issues = "https://github.com/winterop-com/pluginkit/issues"
25
+ Documentation = "https://winterop-com.github.io/pluginkit"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "mypy>=1.20.2",
30
+ "pyright>=1.1.409",
31
+ "ruff>=0.15.12",
32
+ "pytest>=9.0.3",
33
+ "pytest-cov>=7.1.0",
34
+ "mkdocs>=1.6.1",
35
+ "mkdocs-material>=9.7.6",
36
+ "mkdocstrings[python]>=1.0.4",
37
+ "pluginkit-tour", # the walkthrough host, for tour tests and the docs API page
38
+ "smoothie-extra",
39
+ ]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.9.0,<0.12.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.uv.workspace]
46
+ members = ["tour", "plugins/*"]
47
+
48
+ [tool.uv.sources]
49
+ pluginkit-tour = { workspace = true }
50
+ smoothie-extra = { workspace = true }
51
+
52
+ [tool.ruff]
53
+ target-version = "py313"
54
+ line-length = 120
55
+
56
+ [tool.ruff.lint]
57
+ fixable = ["ALL"]
58
+ select = ["E", "W", "F", "I", "D"]
59
+ ignore = ["D203", "D213"]
60
+
61
+ [tool.ruff.lint.pydocstyle]
62
+ convention = "google"
63
+
64
+ [tool.ruff.lint.per-file-ignores]
65
+ "tests/**/*.py" = ["D"]
66
+ "**/__init__.py" = ["D104"]
67
+ "src/**/*.py" = ["D105", "D107"]
68
+
69
+ [tool.ruff.format]
70
+ quote-style = "double"
71
+ indent-style = "space"
72
+ skip-magic-trailing-comma = false
73
+ docstring-code-format = true
74
+ docstring-code-line-length = "dynamic"
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+ pythonpath = ["examples"]
79
+ norecursedirs = [".git", ".venv", "__pycache__"]
80
+
81
+ [tool.mypy]
82
+ python_version = "3.13"
83
+ warn_return_any = true
84
+ warn_unused_configs = true
85
+ disallow_untyped_defs = true
86
+ check_untyped_defs = true
87
+ no_implicit_optional = true
88
+ warn_unused_ignores = true
89
+ strict_equality = true
90
+ mypy_path = ["src", "tour/src", "examples"]
91
+
92
+ [[tool.mypy.overrides]]
93
+ module = "tests.*"
94
+ disallow_untyped_defs = false
95
+ # Tests define inline hook specs whose bodies are intentionally empty.
96
+ disable_error_code = ["empty-body"]
97
+
98
+ # Hook specs are intentionally empty (signature + docstring only).
99
+ [[tool.mypy.overrides]]
100
+ module = "pluginkit_tour.hookspecs"
101
+ disable_error_code = ["empty-body"]
102
+
103
+ [tool.pyright]
104
+ include = ["src", "tests", "examples", "tour/src"]
105
+ extraPaths = ["examples", "tour/src"]
106
+ exclude = ["**/.venv"]
107
+ pythonVersion = "3.13"
108
+ typeCheckingMode = "strict"
109
+ useLibraryCodeForTypes = true
110
+ reportPrivateUsage = false
111
+ reportUnusedFunction = false
112
+ reportMissingModuleSource = false
113
+ reportUnknownMemberType = false
114
+ reportUnknownArgumentType = false
115
+ reportUnknownParameterType = false
116
+ reportUnknownVariableType = false
117
+ reportUnknownLambdaType = false
118
+ reportMissingTypeArgument = false
@@ -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
+ ]
@@ -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
@@ -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}")
@@ -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
@@ -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
File without changes