pyplugy 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.
- pyplugy/__init__.py +56 -0
- pyplugy/_context.py +239 -0
- pyplugy/_depgraph.py +135 -0
- pyplugy/_discovery.py +178 -0
- pyplugy/_manager.py +506 -0
- pyplugy/_plugin.py +263 -0
- pyplugy/_tasky_protocol.py +100 -0
- pyplugy/exceptions.py +46 -0
- pyplugy-0.1.0.dist-info/METADATA +269 -0
- pyplugy-0.1.0.dist-info/RECORD +12 -0
- pyplugy-0.1.0.dist-info/WHEEL +4 -0
- pyplugy-0.1.0.dist-info/licenses/LICENSE +21 -0
pyplugy/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""pyPlugy — plugin framework with hooks (pyHooky) and tasks (pyWorkflowy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyplugy._context import PluginContext, current_context
|
|
6
|
+
from pyplugy._manager import (
|
|
7
|
+
HOOK_PLUGIN_DISABLE,
|
|
8
|
+
HOOK_PLUGIN_ENABLE,
|
|
9
|
+
HOOK_PLUGIN_ERROR,
|
|
10
|
+
HOOK_PLUGIN_LOAD,
|
|
11
|
+
HOOK_PLUGIN_UNLOAD,
|
|
12
|
+
PluginManager,
|
|
13
|
+
current_manager,
|
|
14
|
+
)
|
|
15
|
+
from pyplugy._plugin import (
|
|
16
|
+
Plugin,
|
|
17
|
+
PluginInfo,
|
|
18
|
+
PluginManifest,
|
|
19
|
+
PluginState,
|
|
20
|
+
plugin,
|
|
21
|
+
)
|
|
22
|
+
from pyplugy.exceptions import (
|
|
23
|
+
PluginAlreadyLoadedError,
|
|
24
|
+
PluginDependencyError,
|
|
25
|
+
PluginError,
|
|
26
|
+
PluginLoadError,
|
|
27
|
+
PluginManifestError,
|
|
28
|
+
PluginNotFoundError,
|
|
29
|
+
PluginUnloadError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"HOOK_PLUGIN_DISABLE",
|
|
34
|
+
"HOOK_PLUGIN_ENABLE",
|
|
35
|
+
"HOOK_PLUGIN_ERROR",
|
|
36
|
+
"HOOK_PLUGIN_LOAD",
|
|
37
|
+
"HOOK_PLUGIN_UNLOAD",
|
|
38
|
+
"Plugin",
|
|
39
|
+
"PluginAlreadyLoadedError",
|
|
40
|
+
"PluginContext",
|
|
41
|
+
"PluginDependencyError",
|
|
42
|
+
"PluginError",
|
|
43
|
+
"PluginInfo",
|
|
44
|
+
"PluginLoadError",
|
|
45
|
+
"PluginManager",
|
|
46
|
+
"PluginManifest",
|
|
47
|
+
"PluginManifestError",
|
|
48
|
+
"PluginNotFoundError",
|
|
49
|
+
"PluginState",
|
|
50
|
+
"PluginUnloadError",
|
|
51
|
+
"current_context",
|
|
52
|
+
"current_manager",
|
|
53
|
+
"plugin",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
pyplugy/_context.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""The :class:`PluginContext` handed to a plugin's setup / lifecycle methods.
|
|
2
|
+
|
|
3
|
+
A context bundles everything a plugin needs to register hooks, schedule tasks,
|
|
4
|
+
read config, and talk to its manager. The manager always runs ``on_load``
|
|
5
|
+
inside a ``pyhooky.tag_scope(plugin_name)`` block, so *every* hook registered
|
|
6
|
+
through the context (or directly via ``pyhooky``'s module helpers) is
|
|
7
|
+
auto-tagged with the plugin's name. That auto-tagging is the central
|
|
8
|
+
mechanism enabling clean unload via ``HookRegistry.clear_tag``.
|
|
9
|
+
|
|
10
|
+
The task helpers on the context use the :mod:`pyplugy._tasky_protocol` so
|
|
11
|
+
plugins don't need pyWorkflowy installed at import time. When the manager has no
|
|
12
|
+
``tasky`` configured, ``ctx.task`` raises a clear :class:`RuntimeError`
|
|
13
|
+
pointing at the ``pyplugy[tasks]`` extra.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
|
|
25
|
+
from pyhooky import HookRegistry
|
|
26
|
+
|
|
27
|
+
from pyplugy._plugin import PluginManifest
|
|
28
|
+
from pyplugy._tasky_protocol import SchedulerProtocol, TaskyProtocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["PluginContext", "current_context"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_current_context: ContextVar[PluginContext | None] = ContextVar(
|
|
35
|
+
"pyplugy_current_context", default=None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def current_context() -> PluginContext | None:
|
|
40
|
+
"""Return the :class:`PluginContext` for the plugin currently being loaded, if any.
|
|
41
|
+
|
|
42
|
+
Set by :class:`PluginManager` for the duration of an ``on_load`` /
|
|
43
|
+
``on_unload`` / ``on_enable`` / ``on_disable`` call. Contextvar-based, so
|
|
44
|
+
it propagates across asyncio tasks and threads spawned during setup.
|
|
45
|
+
"""
|
|
46
|
+
return _current_context.get()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PluginContext:
|
|
50
|
+
"""Per-plugin handle for hook registration, task wiring, config, and logging.
|
|
51
|
+
|
|
52
|
+
:class:`PluginContext` is constructed by :class:`PluginManager` and passed
|
|
53
|
+
to every lifecycle method. All hook-registration helpers (:meth:`before`,
|
|
54
|
+
:meth:`after`, :meth:`around`, :meth:`on`, :meth:`on_error`) are thin
|
|
55
|
+
passthroughs to the bound :class:`pyhooky.HookRegistry` — they target the
|
|
56
|
+
registry the manager owns by default, but a custom one may be passed at
|
|
57
|
+
construction.
|
|
58
|
+
|
|
59
|
+
Because the manager wraps every call to a plugin's lifecycle methods in
|
|
60
|
+
``pyhooky.tag_scope(plugin.name)``, the ``tag=`` argument can be omitted —
|
|
61
|
+
every registration in the block picks up the plugin name automatically.
|
|
62
|
+
Helpers exposed here merely make that intent explicit and route through
|
|
63
|
+
the manager-owned registry.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
__slots__ = (
|
|
67
|
+
"_config",
|
|
68
|
+
"_logger",
|
|
69
|
+
"_manifest",
|
|
70
|
+
"_registry",
|
|
71
|
+
"_scheduler",
|
|
72
|
+
"_tasks",
|
|
73
|
+
"_tasky",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
manifest: PluginManifest,
|
|
79
|
+
*,
|
|
80
|
+
registry: HookRegistry,
|
|
81
|
+
config: Mapping[str, Any] | None = None,
|
|
82
|
+
tasky: TaskyProtocol | None = None,
|
|
83
|
+
scheduler: SchedulerProtocol | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._manifest = manifest
|
|
86
|
+
self._registry = registry
|
|
87
|
+
self._config: dict[str, Any] = dict(config or {})
|
|
88
|
+
self._tasky = tasky
|
|
89
|
+
self._scheduler = scheduler
|
|
90
|
+
self._tasks: list[Any] = []
|
|
91
|
+
self._logger = logging.getLogger(f"pyplugy.{manifest.name}")
|
|
92
|
+
|
|
93
|
+
# ---------- exposed surface ----------
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def name(self) -> str:
|
|
97
|
+
return self._manifest.name
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def manifest(self) -> PluginManifest:
|
|
101
|
+
return self._manifest
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def hooks(self) -> HookRegistry:
|
|
105
|
+
"""The :class:`pyhooky.HookRegistry` hooks land in by default."""
|
|
106
|
+
return self._registry
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def config(self) -> dict[str, Any]:
|
|
110
|
+
"""Mutable per-plugin config dict — populated by the manager."""
|
|
111
|
+
return self._config
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def logger(self) -> logging.Logger:
|
|
115
|
+
"""A :class:`logging.Logger` named ``pyplugy.<plugin-name>``."""
|
|
116
|
+
return self._logger
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def scheduler(self) -> SchedulerProtocol | None:
|
|
120
|
+
"""The optional shared scheduler. ``None`` when none is configured."""
|
|
121
|
+
return self._scheduler
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def tasks(self) -> tuple[Any, ...]:
|
|
125
|
+
"""Tasks registered via :meth:`task`, in registration order."""
|
|
126
|
+
return tuple(self._tasks)
|
|
127
|
+
|
|
128
|
+
# ---------- hook passthroughs ----------
|
|
129
|
+
|
|
130
|
+
def before(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
|
|
131
|
+
from pyhooky import before as _before
|
|
132
|
+
|
|
133
|
+
return _before(target, fn, registry=self._registry, **kwargs)
|
|
134
|
+
|
|
135
|
+
def after(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
|
|
136
|
+
from pyhooky import after as _after
|
|
137
|
+
|
|
138
|
+
return _after(target, fn, registry=self._registry, **kwargs)
|
|
139
|
+
|
|
140
|
+
def around(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
|
|
141
|
+
from pyhooky import around as _around
|
|
142
|
+
|
|
143
|
+
return _around(target, fn, registry=self._registry, **kwargs)
|
|
144
|
+
|
|
145
|
+
def on(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
|
|
146
|
+
from pyhooky import on as _on
|
|
147
|
+
|
|
148
|
+
return _on(target, fn, registry=self._registry, **kwargs)
|
|
149
|
+
|
|
150
|
+
def on_error(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
|
|
151
|
+
from pyhooky import on_error as _on_error
|
|
152
|
+
|
|
153
|
+
return _on_error(target, fn, registry=self._registry, **kwargs)
|
|
154
|
+
|
|
155
|
+
def hook(self, target: Any = None, fn: Any = None, **kwargs: Any) -> Any:
|
|
156
|
+
"""Wrap a callable so plugin hooks fire around it — passthrough to :func:`pyhooky.hook`.
|
|
157
|
+
|
|
158
|
+
Supports the same call shapes as :func:`pyhooky.hook`: bare callable,
|
|
159
|
+
bare invocation, explicit string target, or direct ``(name, fn)`` pair.
|
|
160
|
+
"""
|
|
161
|
+
from pyhooky import hook as _hook
|
|
162
|
+
|
|
163
|
+
# Bare callable form — `@ctx.hook` directly on a function. The bare
|
|
164
|
+
# overload of `pyhooky.hook` doesn't accept registry=, so wrap it.
|
|
165
|
+
if callable(target) and not isinstance(target, str) and fn is None and not kwargs:
|
|
166
|
+
wrapped = self._registry.wrap(getattr(target, "__name__", None) or repr(target), target)
|
|
167
|
+
return wrapped
|
|
168
|
+
if target is None and fn is None:
|
|
169
|
+
return _hook(registry=self._registry, **kwargs)
|
|
170
|
+
if isinstance(target, str) and fn is None:
|
|
171
|
+
return _hook(target, registry=self._registry, **kwargs)
|
|
172
|
+
return _hook(target, fn, registry=self._registry, **kwargs)
|
|
173
|
+
|
|
174
|
+
# ---------- task passthroughs ----------
|
|
175
|
+
|
|
176
|
+
def task(self, fn: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
|
|
177
|
+
"""Register a task with the plugin's task scope.
|
|
178
|
+
|
|
179
|
+
Requires a :mod:`pyplugy._tasky_protocol`-compatible object on the
|
|
180
|
+
manager (real pyWorkflowy or a test stub). Without one, raises a
|
|
181
|
+
:class:`RuntimeError` pointing at the ``pyplugy[tasks]`` extra.
|
|
182
|
+
|
|
183
|
+
Both bare (``@ctx.task``) and parameterised (``@ctx.task(retries=3)``)
|
|
184
|
+
decorator forms work — same call shape as ``@pyworkflowy.task``.
|
|
185
|
+
"""
|
|
186
|
+
if self._tasky is None:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"plugin {self._manifest.name!r} called ctx.task but no task backend is "
|
|
189
|
+
"configured on the manager. Install pyWorkflowy (`pip install pyplugy[tasks]`) "
|
|
190
|
+
"or pass tasky= to PluginManager(...)."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
decorator = self._tasky.task
|
|
194
|
+
|
|
195
|
+
def _record(task_obj: Any) -> Any:
|
|
196
|
+
self._tasks.append(task_obj)
|
|
197
|
+
return task_obj
|
|
198
|
+
|
|
199
|
+
if fn is None:
|
|
200
|
+
# Parameterised — `@ctx.task(retries=3)`
|
|
201
|
+
inner = decorator(**kwargs)
|
|
202
|
+
|
|
203
|
+
def wrap(real_fn: Callable[..., Any]) -> Any:
|
|
204
|
+
return _record(inner(real_fn))
|
|
205
|
+
|
|
206
|
+
return wrap
|
|
207
|
+
|
|
208
|
+
if callable(fn) and not kwargs:
|
|
209
|
+
# Bare — `@ctx.task` directly on the function.
|
|
210
|
+
return _record(decorator(fn))
|
|
211
|
+
|
|
212
|
+
# Mixed form: `ctx.task(fn, retries=3)` — uncommon but legal.
|
|
213
|
+
return _record(decorator(fn, **kwargs))
|
|
214
|
+
|
|
215
|
+
# ---------- introspection ----------
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return (
|
|
219
|
+
f"PluginContext(name={self._manifest.name!r}, "
|
|
220
|
+
f"registry={self._registry.name!r}, tasks={len(self._tasks)})"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _activate(ctx: PluginContext) -> Any:
|
|
225
|
+
"""Internal helper used by :class:`PluginManager` to publish ``ctx`` via contextvar.
|
|
226
|
+
|
|
227
|
+
Returns a contextmanager that restores the previous context on exit.
|
|
228
|
+
"""
|
|
229
|
+
from contextlib import contextmanager
|
|
230
|
+
|
|
231
|
+
@contextmanager
|
|
232
|
+
def _scope() -> Any:
|
|
233
|
+
token = _current_context.set(ctx)
|
|
234
|
+
try:
|
|
235
|
+
yield ctx
|
|
236
|
+
finally:
|
|
237
|
+
_current_context.reset(token)
|
|
238
|
+
|
|
239
|
+
return _scope()
|
pyplugy/_depgraph.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Plugin dependency resolution.
|
|
2
|
+
|
|
3
|
+
Each plugin's :attr:`PluginManifest.requires` entry is a string like
|
|
4
|
+
``"other>=2.0"`` — a PEP 440 specifier prefixed with the dependency's name.
|
|
5
|
+
The leading identifier is the dependency name; anything that follows the name
|
|
6
|
+
is a :class:`packaging.specifiers.SpecifierSet` constraint. A bare name
|
|
7
|
+
(``"other"``) means "any version".
|
|
8
|
+
|
|
9
|
+
:func:`resolve_load_order` returns a list of plugins ordered so that every
|
|
10
|
+
plugin appears after the ones it depends on. Missing dependencies, version
|
|
11
|
+
mismatches, and cycles all raise :class:`PluginDependencyError`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
20
|
+
from packaging.version import InvalidVersion, Version
|
|
21
|
+
|
|
22
|
+
from pyplugy.exceptions import PluginDependencyError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
from pyplugy._plugin import Plugin
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["parse_requirement", "resolve_load_order"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Plugin names are loose — we just split on the first specifier operator.
|
|
34
|
+
_REQ_SPLIT_RE = re.compile(r"(===|==|!=|<=|>=|~=|<|>)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_requirement(req: str) -> tuple[str, SpecifierSet]:
|
|
38
|
+
"""Split ``"other>=2.0"`` into ``("other", SpecifierSet(">=2.0"))``.
|
|
39
|
+
|
|
40
|
+
A bare name returns an empty :class:`SpecifierSet` (matches any version).
|
|
41
|
+
Raises :class:`PluginDependencyError` if the specifier portion is malformed.
|
|
42
|
+
"""
|
|
43
|
+
if not req or not isinstance(req, str):
|
|
44
|
+
raise PluginDependencyError(f"requirement must be a non-empty string, got {req!r}")
|
|
45
|
+
match = _REQ_SPLIT_RE.search(req)
|
|
46
|
+
if match is None:
|
|
47
|
+
return req.strip(), SpecifierSet()
|
|
48
|
+
name = req[: match.start()].strip()
|
|
49
|
+
spec_str = req[match.start() :].strip()
|
|
50
|
+
if not name:
|
|
51
|
+
raise PluginDependencyError(f"requirement {req!r} has no plugin name before the specifier")
|
|
52
|
+
try:
|
|
53
|
+
spec = SpecifierSet(spec_str)
|
|
54
|
+
except InvalidSpecifier as exc:
|
|
55
|
+
raise PluginDependencyError(
|
|
56
|
+
f"requirement {req!r} has an invalid version specifier {spec_str!r}: {exc}"
|
|
57
|
+
) from exc
|
|
58
|
+
return name, spec
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_version(name: str, version_str: str) -> Version | None:
|
|
62
|
+
if not version_str:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return Version(version_str)
|
|
66
|
+
except InvalidVersion:
|
|
67
|
+
# Non-PEP440 plugin versions silently disable specifier checks for that
|
|
68
|
+
# plugin — better than refusing to load. We still warn-via-error if a
|
|
69
|
+
# *consumer* requires a specifier on a non-PEP440 dep.
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
|
|
74
|
+
"""Topologically sort ``plugins`` honoring ``manifest.requires``.
|
|
75
|
+
|
|
76
|
+
- Raises :class:`PluginDependencyError` when a required plugin is missing.
|
|
77
|
+
- Raises :class:`PluginDependencyError` when the available version doesn't
|
|
78
|
+
satisfy the specifier.
|
|
79
|
+
- Raises :class:`PluginDependencyError` on cycles, naming all plugins in
|
|
80
|
+
the cycle for easy debugging.
|
|
81
|
+
"""
|
|
82
|
+
plugins_list = list(plugins)
|
|
83
|
+
by_name: dict[str, Plugin] = {}
|
|
84
|
+
for p in plugins_list:
|
|
85
|
+
name = p.manifest.name
|
|
86
|
+
if name in by_name:
|
|
87
|
+
raise PluginDependencyError(f"duplicate plugin name {name!r} in resolve_load_order")
|
|
88
|
+
by_name[name] = p
|
|
89
|
+
|
|
90
|
+
# Build adjacency: dep -> dependents (so we can Kahn-sort).
|
|
91
|
+
incoming: dict[str, set[str]] = {n: set() for n in by_name}
|
|
92
|
+
outgoing: dict[str, set[str]] = {n: set() for n in by_name}
|
|
93
|
+
|
|
94
|
+
for plugin_obj in plugins_list:
|
|
95
|
+
consumer = plugin_obj.manifest.name
|
|
96
|
+
for req in plugin_obj.manifest.requires:
|
|
97
|
+
dep_name, spec = parse_requirement(req)
|
|
98
|
+
if dep_name not in by_name:
|
|
99
|
+
raise PluginDependencyError(
|
|
100
|
+
f"plugin {consumer!r} requires {dep_name!r} which is not loaded "
|
|
101
|
+
f"(requirement {req!r})"
|
|
102
|
+
)
|
|
103
|
+
dep_plugin = by_name[dep_name]
|
|
104
|
+
if spec:
|
|
105
|
+
parsed = _parse_version(dep_name, dep_plugin.manifest.version)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
raise PluginDependencyError(
|
|
108
|
+
f"plugin {consumer!r} requires {req!r}, but {dep_name!r} reports "
|
|
109
|
+
f"non-PEP440 version {dep_plugin.manifest.version!r}; cannot check"
|
|
110
|
+
)
|
|
111
|
+
if parsed not in spec:
|
|
112
|
+
raise PluginDependencyError(
|
|
113
|
+
f"plugin {consumer!r} requires {req!r}, but {dep_name!r} version "
|
|
114
|
+
f"{dep_plugin.manifest.version!r} does not satisfy the specifier"
|
|
115
|
+
)
|
|
116
|
+
incoming[consumer].add(dep_name)
|
|
117
|
+
outgoing[dep_name].add(consumer)
|
|
118
|
+
|
|
119
|
+
# Kahn's algorithm — process plugins whose deps are already emitted.
|
|
120
|
+
ready = sorted(n for n, deps in incoming.items() if not deps)
|
|
121
|
+
ordered: list[Plugin] = []
|
|
122
|
+
while ready:
|
|
123
|
+
name = ready.pop(0)
|
|
124
|
+
ordered.append(by_name[name])
|
|
125
|
+
for dependent in sorted(outgoing[name]):
|
|
126
|
+
incoming[dependent].discard(name)
|
|
127
|
+
if not incoming[dependent]:
|
|
128
|
+
ready.append(dependent)
|
|
129
|
+
# Keep `ready` sorted for deterministic output.
|
|
130
|
+
ready.sort()
|
|
131
|
+
|
|
132
|
+
if len(ordered) != len(plugins_list):
|
|
133
|
+
cycle = sorted(n for n, deps in incoming.items() if deps)
|
|
134
|
+
raise PluginDependencyError(f"cyclic plugin dependency involving: {', '.join(cycle)}")
|
|
135
|
+
return ordered
|
pyplugy/_discovery.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Plugin discovery — entry points, directory scan, JIT loading.
|
|
2
|
+
|
|
3
|
+
Three discovery modes feed plugins into the manager:
|
|
4
|
+
|
|
5
|
+
- **Entry points**: ``manager.load_entry_points(group="myapp.plugins")``
|
|
6
|
+
resolves each entry point and scans the loaded module for plugins.
|
|
7
|
+
- **Directory scanning**: ``manager.load_directory(path, pattern="*.py")``
|
|
8
|
+
imports every match through :func:`importlib.util.spec_from_file_location`
|
|
9
|
+
under a sandboxed module name (``pyplugy._loaded.<stem>``) so we don't
|
|
10
|
+
pollute the global :data:`sys.modules` namespace.
|
|
11
|
+
- **JIT (just-in-time)**: ``manager.load(target)`` accepts a class, instance,
|
|
12
|
+
module, dotted path, or filesystem path and figures out what to do.
|
|
13
|
+
|
|
14
|
+
All three modes converge on :func:`scan_module` — given a module object, it
|
|
15
|
+
returns every :class:`Plugin` instance or class found in it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.metadata
|
|
22
|
+
import importlib.util
|
|
23
|
+
import inspect
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from types import ModuleType
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from pyplugy._plugin import Plugin
|
|
30
|
+
from pyplugy.exceptions import PluginLoadError
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Iterable, Iterator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"import_file",
|
|
38
|
+
"iter_directory_modules",
|
|
39
|
+
"iter_entry_points",
|
|
40
|
+
"scan_module",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_SANDBOX_PREFIX = "pyplugy._loaded"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_module(module: ModuleType) -> list[Plugin]:
|
|
48
|
+
"""Return every plugin defined in ``module``.
|
|
49
|
+
|
|
50
|
+
Recognises three shapes:
|
|
51
|
+
|
|
52
|
+
- Module-level :class:`Plugin` instances (e.g. produced by the
|
|
53
|
+
:func:`plugin` decorator).
|
|
54
|
+
- :class:`Plugin` subclasses defined in the module (instantiated here with
|
|
55
|
+
no arguments).
|
|
56
|
+
- A ``setup`` callable wrapped by the :func:`plugin` decorator (already a
|
|
57
|
+
:class:`Plugin` instance — covered by the first case).
|
|
58
|
+
|
|
59
|
+
Items imported *from* other modules are skipped: only definitions whose
|
|
60
|
+
``__module__`` matches ``module.__name__`` are considered. That keeps
|
|
61
|
+
re-imports from accidentally double-registering a plugin discovered via
|
|
62
|
+
another path.
|
|
63
|
+
"""
|
|
64
|
+
found: list[Plugin] = []
|
|
65
|
+
seen_ids: set[int] = set()
|
|
66
|
+
seen_names: set[str] = set()
|
|
67
|
+
|
|
68
|
+
for attr_name in dir(module):
|
|
69
|
+
if attr_name.startswith("_"):
|
|
70
|
+
continue
|
|
71
|
+
obj = getattr(module, attr_name, None)
|
|
72
|
+
|
|
73
|
+
# 1) Plugin instances (decorator form)
|
|
74
|
+
if isinstance(obj, Plugin):
|
|
75
|
+
if id(obj) in seen_ids:
|
|
76
|
+
continue
|
|
77
|
+
seen_ids.add(id(obj))
|
|
78
|
+
name = obj.manifest.name or attr_name
|
|
79
|
+
if name in seen_names:
|
|
80
|
+
continue
|
|
81
|
+
seen_names.add(name)
|
|
82
|
+
found.append(obj)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# 2) Plugin subclasses (class form) — only those defined in this module
|
|
86
|
+
if (
|
|
87
|
+
inspect.isclass(obj)
|
|
88
|
+
and issubclass(obj, Plugin)
|
|
89
|
+
and obj is not Plugin
|
|
90
|
+
and getattr(obj, "__module__", None) == module.__name__
|
|
91
|
+
):
|
|
92
|
+
try:
|
|
93
|
+
instance = obj()
|
|
94
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
95
|
+
raise PluginLoadError(
|
|
96
|
+
f"failed to instantiate plugin class {obj.__name__!r} from "
|
|
97
|
+
f"module {module.__name__!r}: {exc}"
|
|
98
|
+
) from exc
|
|
99
|
+
if instance.manifest.name in seen_names:
|
|
100
|
+
continue
|
|
101
|
+
seen_names.add(instance.manifest.name)
|
|
102
|
+
seen_ids.add(id(instance))
|
|
103
|
+
found.append(instance)
|
|
104
|
+
|
|
105
|
+
return found
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def iter_entry_points(group: str) -> Iterator[tuple[str, ModuleType]]:
|
|
109
|
+
"""Yield ``(entry_point_name, module)`` for every entry point in ``group``.
|
|
110
|
+
|
|
111
|
+
Each entry point is resolved with :func:`importlib.metadata.entry_points`.
|
|
112
|
+
Targets that don't resolve to a module are imported via dotted-path
|
|
113
|
+
semantics so plugins published as ``"my.module:setup"`` still work
|
|
114
|
+
(the manager scans the parent module).
|
|
115
|
+
"""
|
|
116
|
+
eps = importlib.metadata.entry_points(group=group)
|
|
117
|
+
for ep in eps:
|
|
118
|
+
loaded = ep.load()
|
|
119
|
+
if isinstance(loaded, ModuleType):
|
|
120
|
+
yield ep.name, loaded
|
|
121
|
+
else:
|
|
122
|
+
# ``ep.load()`` may return a class/function — use its parent module.
|
|
123
|
+
module_name = getattr(loaded, "__module__", None) or ep.value.split(":")[0]
|
|
124
|
+
module = importlib.import_module(module_name)
|
|
125
|
+
yield ep.name, module
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sandbox_name(stem: str, *, counter: dict[str, int]) -> str:
|
|
129
|
+
"""Pick a unique sandboxed name like ``pyplugy._loaded.foo`` (or ``foo_1`` on collision)."""
|
|
130
|
+
base = f"{_SANDBOX_PREFIX}.{stem}"
|
|
131
|
+
n = counter.get(base, 0)
|
|
132
|
+
counter[base] = n + 1
|
|
133
|
+
return base if n == 0 else f"{base}_{n}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def import_file(path: Path, *, sandbox_counter: dict[str, int] | None = None) -> ModuleType:
|
|
137
|
+
"""Import a ``.py`` file under a sandboxed module name.
|
|
138
|
+
|
|
139
|
+
Avoids inserting the loaded module under its bare stem in
|
|
140
|
+
:data:`sys.modules` — that would shadow real top-level packages.
|
|
141
|
+
"""
|
|
142
|
+
if sandbox_counter is None:
|
|
143
|
+
sandbox_counter = {}
|
|
144
|
+
module_name = _sandbox_name(path.stem, counter=sandbox_counter)
|
|
145
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
146
|
+
if spec is None or spec.loader is None:
|
|
147
|
+
raise PluginLoadError(f"cannot build module spec for {path!r}")
|
|
148
|
+
module = importlib.util.module_from_spec(spec)
|
|
149
|
+
# Register before exec so intra-module relative imports resolve.
|
|
150
|
+
sys.modules[module_name] = module
|
|
151
|
+
try:
|
|
152
|
+
spec.loader.exec_module(module)
|
|
153
|
+
except Exception:
|
|
154
|
+
sys.modules.pop(module_name, None)
|
|
155
|
+
raise
|
|
156
|
+
return module
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_directory_modules(
|
|
160
|
+
path: Path,
|
|
161
|
+
*,
|
|
162
|
+
recursive: bool = False,
|
|
163
|
+
pattern: str = "*.py",
|
|
164
|
+
) -> Iterable[ModuleType]:
|
|
165
|
+
"""Yield modules imported from every file matching ``pattern`` under ``path``."""
|
|
166
|
+
if not path.exists():
|
|
167
|
+
raise PluginLoadError(f"plugin directory {path!r} does not exist")
|
|
168
|
+
if not path.is_dir():
|
|
169
|
+
raise PluginLoadError(f"plugin path {path!r} is not a directory")
|
|
170
|
+
|
|
171
|
+
sandbox_counter: dict[str, int] = {}
|
|
172
|
+
iterator = path.rglob(pattern) if recursive else path.glob(pattern)
|
|
173
|
+
for file in sorted(iterator):
|
|
174
|
+
if not file.is_file():
|
|
175
|
+
continue
|
|
176
|
+
if file.name == "__init__.py":
|
|
177
|
+
continue
|
|
178
|
+
yield import_file(file, sandbox_counter=sandbox_counter)
|