pyplugy 0.2.0__tar.gz → 0.3.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.
- {pyplugy-0.2.0 → pyplugy-0.3.0}/PKG-INFO +3 -2
- {pyplugy-0.2.0 → pyplugy-0.3.0}/pyproject.toml +5 -2
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/__init__.py +8 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_context.py +225 -4
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_depgraph.py +106 -9
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_manager.py +535 -34
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_plugin.py +132 -15
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/exceptions.py +35 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/.gitignore +0 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/LICENSE +0 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/README.md +0 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_discovery.py +0 -0
- {pyplugy-0.2.0 → pyplugy-0.3.0}/src/pyplugy/_tasky_protocol.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyplugy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Plugin framework with hooks (pyHooky) and tasks (pyWorkflowy).
|
|
5
5
|
Project-URL: Homepage, https://github.com/kiliansen/pyPlugy
|
|
6
6
|
Project-URL: Issues, https://github.com/kiliansen/pyPlugy/issues
|
|
@@ -40,8 +40,9 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
40
40
|
Classifier: Typing :: Typed
|
|
41
41
|
Requires-Python: >=3.11
|
|
42
42
|
Requires-Dist: packaging>=24.0
|
|
43
|
+
Requires-Dist: pydantic>=2.0
|
|
43
44
|
Requires-Dist: pyhooky>=0.1.0
|
|
44
|
-
Requires-Dist: pyworkflowy>=0.
|
|
45
|
+
Requires-Dist: pyworkflowy>=0.2.0
|
|
45
46
|
Provides-Extra: tasks
|
|
46
47
|
Requires-Dist: pyworkflowy>=0.1.0; extra == 'tasks'
|
|
47
48
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyplugy"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Plugin framework with hooks (pyHooky) and tasks (pyWorkflowy)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -22,7 +22,8 @@ classifiers = [
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"pyhooky>=0.1.0",
|
|
24
24
|
"packaging>=24.0",
|
|
25
|
-
"pyworkflowy>=0.
|
|
25
|
+
"pyworkflowy>=0.2.0",
|
|
26
|
+
"pydantic>=2.0",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.optional-dependencies]
|
|
@@ -35,6 +36,7 @@ Issues = "https://github.com/kiliansen/pyPlugy/issues"
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
|
|
38
40
|
[build-system]
|
|
39
41
|
requires = ["hatchling"]
|
|
40
42
|
build-backend = "hatchling.build"
|
|
@@ -120,6 +122,7 @@ exclude_commit_patterns = ["^chore\\(release\\):"]
|
|
|
120
122
|
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
|
|
123
126
|
[dependency-groups]
|
|
124
127
|
dev = [
|
|
125
128
|
"pytest>=8.0",
|
|
@@ -22,11 +22,15 @@ from pyplugy._plugin import (
|
|
|
22
22
|
)
|
|
23
23
|
from pyplugy.exceptions import (
|
|
24
24
|
PluginAlreadyLoadedError,
|
|
25
|
+
PluginConfigValidationError,
|
|
26
|
+
PluginConflictError,
|
|
25
27
|
PluginDependencyError,
|
|
26
28
|
PluginError,
|
|
27
29
|
PluginLoadError,
|
|
28
30
|
PluginManifestError,
|
|
31
|
+
PluginMissingDependencyError,
|
|
29
32
|
PluginNotFoundError,
|
|
33
|
+
PluginPeerDependencyError,
|
|
30
34
|
PluginUnloadError,
|
|
31
35
|
)
|
|
32
36
|
|
|
@@ -39,6 +43,8 @@ __all__ = [
|
|
|
39
43
|
"HOOK_PLUGIN_UNLOAD",
|
|
40
44
|
"Plugin",
|
|
41
45
|
"PluginAlreadyLoadedError",
|
|
46
|
+
"PluginConfigValidationError",
|
|
47
|
+
"PluginConflictError",
|
|
42
48
|
"PluginContext",
|
|
43
49
|
"PluginDependencyError",
|
|
44
50
|
"PluginError",
|
|
@@ -47,7 +53,9 @@ __all__ = [
|
|
|
47
53
|
"PluginManager",
|
|
48
54
|
"PluginManifest",
|
|
49
55
|
"PluginManifestError",
|
|
56
|
+
"PluginMissingDependencyError",
|
|
50
57
|
"PluginNotFoundError",
|
|
58
|
+
"PluginPeerDependencyError",
|
|
51
59
|
"PluginState",
|
|
52
60
|
"PluginUnloadError",
|
|
53
61
|
"current_context",
|
|
@@ -90,6 +90,8 @@ class PluginContext:
|
|
|
90
90
|
|
|
91
91
|
__slots__ = (
|
|
92
92
|
"_config",
|
|
93
|
+
"_config_model",
|
|
94
|
+
"_events",
|
|
93
95
|
"_logger",
|
|
94
96
|
"_manifest",
|
|
95
97
|
"_registry",
|
|
@@ -121,6 +123,12 @@ class PluginContext:
|
|
|
121
123
|
self._scheduler = scheduler
|
|
122
124
|
self._tasks: list[Any] = []
|
|
123
125
|
self._logger = logging.getLogger(f"pyplugy.{manifest.name}")
|
|
126
|
+
# Populated by the manager once it has the Plugin instance and can
|
|
127
|
+
# consult ``plugin.config_model``. Stays None when no model is declared.
|
|
128
|
+
self._config_model: Any | None = None
|
|
129
|
+
# Populated by the manager from ``plugin.events`` — one HookPoint per
|
|
130
|
+
# declared key, namespaced as ``"<plugin_name>:<key>"``.
|
|
131
|
+
self._events: dict[str, Any] = {}
|
|
124
132
|
|
|
125
133
|
# ---------- exposed surface ----------
|
|
126
134
|
|
|
@@ -142,6 +150,30 @@ class PluginContext:
|
|
|
142
150
|
"""Mutable per-plugin config dict — populated by the manager."""
|
|
143
151
|
return self._config
|
|
144
152
|
|
|
153
|
+
@property
|
|
154
|
+
def events(self) -> dict[str, Any]:
|
|
155
|
+
"""Typed :class:`~pyhooky.HookPoint` instances keyed by ``Plugin.events``.
|
|
156
|
+
|
|
157
|
+
Empty when the plugin didn't declare any. Each ``HookPoint`` is
|
|
158
|
+
bound to the manager's registry; listeners attached to them are
|
|
159
|
+
auto-tagged for cleanup.
|
|
160
|
+
"""
|
|
161
|
+
return self._events
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def config_model(self) -> Any | None:
|
|
165
|
+
"""Validated config model instance, or ``None`` when the plugin declared no schema.
|
|
166
|
+
|
|
167
|
+
The manager populates this when the plugin declares
|
|
168
|
+
``config_model``. After :meth:`PluginManager.update_config` runs,
|
|
169
|
+
the model is re-validated from the (now-updated) ``config`` dict.
|
|
170
|
+
If a plugin mutates ``ctx.config`` directly, ``config_model``
|
|
171
|
+
won't pick up the change until the next ``update_config`` call —
|
|
172
|
+
explicit mutation is the loose path, validated updates are the
|
|
173
|
+
sharp one.
|
|
174
|
+
"""
|
|
175
|
+
return self._config_model
|
|
176
|
+
|
|
145
177
|
@property
|
|
146
178
|
def logger(self) -> logging.Logger:
|
|
147
179
|
"""A :class:`logging.Logger` named ``pyplugy.<plugin-name>``."""
|
|
@@ -203,6 +235,146 @@ class PluginContext:
|
|
|
203
235
|
return _hook(target, registry=self._registry, **kwargs)
|
|
204
236
|
return _hook(target, fn, registry=self._registry, **kwargs)
|
|
205
237
|
|
|
238
|
+
# ---------- typed events (pyHooky HookPoint factory) ----------
|
|
239
|
+
|
|
240
|
+
def event(self, name: str, schema: Any) -> Any:
|
|
241
|
+
"""Return a pyHooky :class:`~pyhooky.HookPoint` bound to this manager.
|
|
242
|
+
|
|
243
|
+
``schema`` is a pydantic :class:`~pydantic.BaseModel` subclass.
|
|
244
|
+
The returned ``HookPoint`` dispatches through the same registry
|
|
245
|
+
the manager owns, so listeners attached inside the plugin's
|
|
246
|
+
lifecycle are tagged with the plugin name automatically (via
|
|
247
|
+
the existing ``tag_scope`` block) and torn down with it.
|
|
248
|
+
|
|
249
|
+
Plugins typically bind the result locally::
|
|
250
|
+
|
|
251
|
+
login = ctx.event("auth:login", LoginEvent)
|
|
252
|
+
@login.listen
|
|
253
|
+
def audit(evt: LoginEvent): ...
|
|
254
|
+
login.trigger(user="alice")
|
|
255
|
+
"""
|
|
256
|
+
from pyhooky import HookPoint
|
|
257
|
+
|
|
258
|
+
return HookPoint(name, schema, registry=self._registry)
|
|
259
|
+
|
|
260
|
+
# ---------- dependency access ----------
|
|
261
|
+
|
|
262
|
+
def dep(self, name: str) -> PluginContext | None:
|
|
263
|
+
"""Return the :class:`PluginContext` of a declared dependency.
|
|
264
|
+
|
|
265
|
+
``name`` must appear in this plugin's ``requires`` or
|
|
266
|
+
``optional_requires``; otherwise a :class:`PluginDependencyError`
|
|
267
|
+
is raised so the manifest stays the single source of truth for the
|
|
268
|
+
plugin graph. For hard deps the manager guarantees the slot exists
|
|
269
|
+
(load order resolves dependencies first). For optional deps that
|
|
270
|
+
aren't currently loaded, returns ``None``.
|
|
271
|
+
|
|
272
|
+
Must be called inside a lifecycle hook (``on_load`` / ``on_enable``
|
|
273
|
+
/ etc.) so that :func:`~pyplugy._manager.current_manager` is set.
|
|
274
|
+
"""
|
|
275
|
+
from pyplugy._manager import current_manager
|
|
276
|
+
from pyplugy.exceptions import PluginDependencyError
|
|
277
|
+
|
|
278
|
+
declared = set(self._manifest.requires) | set(self._manifest.optional_requires)
|
|
279
|
+
# Strip version specifiers — match by name only.
|
|
280
|
+
from pyplugy._depgraph import parse_requirement
|
|
281
|
+
|
|
282
|
+
declared_names = {parse_requirement(r)[0] for r in declared}
|
|
283
|
+
if name not in declared_names:
|
|
284
|
+
raise PluginDependencyError(
|
|
285
|
+
f"plugin {self._manifest.name!r} did not declare dep {name!r}; "
|
|
286
|
+
f"add it to requires or optional_requires"
|
|
287
|
+
)
|
|
288
|
+
manager = current_manager()
|
|
289
|
+
if manager is None:
|
|
290
|
+
raise PluginDependencyError(
|
|
291
|
+
f"ctx.dep({name!r}) called outside an active lifecycle hook; no current manager"
|
|
292
|
+
)
|
|
293
|
+
slot = manager._plugins.get(name)
|
|
294
|
+
if slot is None:
|
|
295
|
+
# Optional dep that isn't loaded — silently absent.
|
|
296
|
+
if name in {parse_requirement(r)[0] for r in self._manifest.optional_requires}:
|
|
297
|
+
return None
|
|
298
|
+
# Hard dep should always be present at this point.
|
|
299
|
+
raise PluginDependencyError(
|
|
300
|
+
f"plugin {self._manifest.name!r} requires {name!r} but it is not loaded"
|
|
301
|
+
)
|
|
302
|
+
return slot.ctx
|
|
303
|
+
|
|
304
|
+
depends_on = dep
|
|
305
|
+
|
|
306
|
+
def export(self, obj: Any) -> Any:
|
|
307
|
+
"""Publish ``obj`` as this plugin's API for dependents.
|
|
308
|
+
|
|
309
|
+
``ctx.api_of(name)`` returns whatever was last passed here.
|
|
310
|
+
Must be called inside a lifecycle hook (``on_load`` / ``on_enable``).
|
|
311
|
+
Calling more than once is allowed — the latest call wins, which
|
|
312
|
+
makes it safe to call from both ``on_load`` and ``on_enable``.
|
|
313
|
+
|
|
314
|
+
When the plugin declares ``api_model`` (a pydantic
|
|
315
|
+
:class:`~pydantic.BaseModel` subclass), ``obj`` is validated
|
|
316
|
+
against it: a dict is parsed into the model, an existing model
|
|
317
|
+
instance is type-checked, and anything else raises
|
|
318
|
+
:class:`PluginManifestError`-equivalent error from pydantic.
|
|
319
|
+
|
|
320
|
+
Returns the stored value (the validated model, or ``obj``
|
|
321
|
+
verbatim when no ``api_model`` is declared).
|
|
322
|
+
"""
|
|
323
|
+
from pyplugy._manager import current_manager
|
|
324
|
+
from pyplugy.exceptions import PluginError
|
|
325
|
+
|
|
326
|
+
manager = current_manager()
|
|
327
|
+
if manager is None:
|
|
328
|
+
raise PluginError(
|
|
329
|
+
f"ctx.export(...) called outside an active lifecycle hook for "
|
|
330
|
+
f"plugin {self._manifest.name!r}"
|
|
331
|
+
)
|
|
332
|
+
slot = manager._plugins.get(self._manifest.name)
|
|
333
|
+
if slot is None:
|
|
334
|
+
raise PluginError(
|
|
335
|
+
f"ctx.export(...) called for {self._manifest.name!r} but the "
|
|
336
|
+
"manager has no slot for it — was on_load aborted?"
|
|
337
|
+
)
|
|
338
|
+
validated = _validate_against_model(obj, slot.plugin.api_model)
|
|
339
|
+
slot.api = validated
|
|
340
|
+
return validated
|
|
341
|
+
|
|
342
|
+
def api_of(self, name: str) -> Any:
|
|
343
|
+
"""Return the API another plugin published via :meth:`export`.
|
|
344
|
+
|
|
345
|
+
``name`` must appear in this plugin's
|
|
346
|
+
``requires`` / ``optional_requires`` / ``peer_requires`` — the
|
|
347
|
+
same declaration check that :meth:`dep` enforces. Returns ``None``
|
|
348
|
+
if the dep is loaded but never called ``export``, or if it's an
|
|
349
|
+
unsatisfied optional dep.
|
|
350
|
+
"""
|
|
351
|
+
from pyplugy._depgraph import parse_requirement
|
|
352
|
+
from pyplugy._manager import current_manager
|
|
353
|
+
from pyplugy.exceptions import PluginDependencyError
|
|
354
|
+
|
|
355
|
+
declared = {
|
|
356
|
+
parse_requirement(r)[0]
|
|
357
|
+
for r in (
|
|
358
|
+
*self._manifest.requires,
|
|
359
|
+
*self._manifest.optional_requires,
|
|
360
|
+
*self._manifest.peer_requires,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
if name not in declared:
|
|
364
|
+
raise PluginDependencyError(
|
|
365
|
+
f"plugin {self._manifest.name!r} did not declare dep {name!r}; "
|
|
366
|
+
f"add it to requires / optional_requires / peer_requires"
|
|
367
|
+
)
|
|
368
|
+
manager = current_manager()
|
|
369
|
+
if manager is None:
|
|
370
|
+
raise PluginDependencyError(
|
|
371
|
+
f"ctx.api_of({name!r}) called outside an active lifecycle hook"
|
|
372
|
+
)
|
|
373
|
+
slot = manager._plugins.get(name)
|
|
374
|
+
if slot is None:
|
|
375
|
+
return None
|
|
376
|
+
return slot.api
|
|
377
|
+
|
|
206
378
|
# ---------- task passthroughs ----------
|
|
207
379
|
|
|
208
380
|
def task(self, fn: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
|
|
@@ -215,6 +387,25 @@ class PluginContext:
|
|
|
215
387
|
Both bare (``@ctx.task``) and parameterised (``@ctx.task(retries=3)``)
|
|
216
388
|
decorator forms work — same call shape as ``@pyworkflowy.task``.
|
|
217
389
|
"""
|
|
390
|
+
|
|
391
|
+
def _record(task_obj: Any) -> Any:
|
|
392
|
+
self._tasks.append(task_obj)
|
|
393
|
+
return task_obj
|
|
394
|
+
|
|
395
|
+
# pyWorkflowy class form: ``ctx.task(MyTaskClass)`` — instantiating
|
|
396
|
+
# a TaskBase subclass returns a Task directly (via TaskBase.__new__),
|
|
397
|
+
# so we register that instance and return it. Lazy import keeps
|
|
398
|
+
# pyworkflowy optional for plugins that only use the decorator form.
|
|
399
|
+
# Handled before the tasky-required check so this form works even
|
|
400
|
+
# when the manager has no tasky configured.
|
|
401
|
+
if isinstance(fn, type):
|
|
402
|
+
try:
|
|
403
|
+
from pyworkflowy import TaskBase
|
|
404
|
+
except ImportError:
|
|
405
|
+
TaskBase = None # type: ignore[assignment]
|
|
406
|
+
if TaskBase is not None and issubclass(fn, TaskBase):
|
|
407
|
+
return _record(fn())
|
|
408
|
+
|
|
218
409
|
if self._tasky is None:
|
|
219
410
|
raise RuntimeError(
|
|
220
411
|
f"plugin {self._manifest.name!r} called ctx.task but no task backend is "
|
|
@@ -224,10 +415,6 @@ class PluginContext:
|
|
|
224
415
|
|
|
225
416
|
decorator = self._tasky.task
|
|
226
417
|
|
|
227
|
-
def _record(task_obj: Any) -> Any:
|
|
228
|
-
self._tasks.append(task_obj)
|
|
229
|
-
return task_obj
|
|
230
|
-
|
|
231
418
|
if fn is None:
|
|
232
419
|
# Parameterised — `@ctx.task(retries=3)`
|
|
233
420
|
inner = decorator(**kwargs)
|
|
@@ -253,6 +440,40 @@ class PluginContext:
|
|
|
253
440
|
)
|
|
254
441
|
|
|
255
442
|
|
|
443
|
+
def _validate_against_model(obj: Any, model: Any | None) -> Any:
|
|
444
|
+
"""Coerce/validate ``obj`` against ``model`` (a pydantic ``BaseModel`` subclass).
|
|
445
|
+
|
|
446
|
+
``model=None`` → return ``obj`` as-is. ``obj`` already an instance of
|
|
447
|
+
``model`` → returned unchanged. ``obj`` is a dict → parsed via
|
|
448
|
+
``model.model_validate``. Anything else with a non-None model raises
|
|
449
|
+
:class:`PluginManifestError` so misuses fail loudly at the boundary.
|
|
450
|
+
"""
|
|
451
|
+
from pydantic import BaseModel, ValidationError
|
|
452
|
+
|
|
453
|
+
from pyplugy.exceptions import PluginManifestError
|
|
454
|
+
|
|
455
|
+
if model is None:
|
|
456
|
+
return obj
|
|
457
|
+
if isinstance(obj, model):
|
|
458
|
+
return obj
|
|
459
|
+
if isinstance(obj, dict):
|
|
460
|
+
try:
|
|
461
|
+
return model.model_validate(obj)
|
|
462
|
+
except ValidationError as exc:
|
|
463
|
+
raise PluginManifestError(
|
|
464
|
+
f"export payload failed validation against {model.__name__}: {exc}"
|
|
465
|
+
) from exc
|
|
466
|
+
if isinstance(obj, BaseModel):
|
|
467
|
+
raise PluginManifestError(
|
|
468
|
+
f"export payload is a {type(obj).__name__} instance but {model.__name__} "
|
|
469
|
+
"was declared as api_model; types don't match"
|
|
470
|
+
)
|
|
471
|
+
raise PluginManifestError(
|
|
472
|
+
f"export payload of type {type(obj).__name__} cannot be coerced to {model.__name__}; "
|
|
473
|
+
"pass a dict or an instance of the declared model"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
256
477
|
def _activate(ctx: PluginContext) -> Any:
|
|
257
478
|
"""Internal helper used by :class:`PluginManager` to publish ``ctx`` via contextvar.
|
|
258
479
|
|
|
@@ -19,7 +19,12 @@ from typing import TYPE_CHECKING
|
|
|
19
19
|
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
20
20
|
from packaging.version import InvalidVersion, Version
|
|
21
21
|
|
|
22
|
-
from pyplugy.exceptions import
|
|
22
|
+
from pyplugy.exceptions import (
|
|
23
|
+
PluginConflictError,
|
|
24
|
+
PluginDependencyError,
|
|
25
|
+
PluginMissingDependencyError,
|
|
26
|
+
PluginPeerDependencyError,
|
|
27
|
+
)
|
|
23
28
|
|
|
24
29
|
if TYPE_CHECKING:
|
|
25
30
|
from collections.abc import Iterable
|
|
@@ -27,7 +32,7 @@ if TYPE_CHECKING:
|
|
|
27
32
|
from pyplugy._plugin import Plugin
|
|
28
33
|
|
|
29
34
|
|
|
30
|
-
__all__ = ["parse_requirement", "resolve_load_order"]
|
|
35
|
+
__all__ = ["compute_reverse_graph", "parse_requirement", "resolve_load_order"]
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
# Plugin names are loose — we just split on the first specifier operator.
|
|
@@ -73,11 +78,18 @@ def _parse_version(name: str, version_str: str) -> Version | None:
|
|
|
73
78
|
def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
|
|
74
79
|
"""Topologically sort ``plugins`` honoring ``manifest.requires``.
|
|
75
80
|
|
|
76
|
-
- Raises :class:`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- Raises :class:`PluginDependencyError`
|
|
80
|
-
|
|
81
|
+
- Raises :class:`PluginMissingDependencyError` when a hard-required
|
|
82
|
+
plugin is missing. The exception carries the missing dep name so
|
|
83
|
+
resolvers can fetch it and retry.
|
|
84
|
+
- Raises :class:`PluginDependencyError` when the available version
|
|
85
|
+
doesn't satisfy the specifier.
|
|
86
|
+
- Raises :class:`PluginConflictError` when two plugins declare each
|
|
87
|
+
other (or are declared by each other) in ``conflicts``.
|
|
88
|
+
- Raises :class:`PluginDependencyError` on cycles, naming all plugins
|
|
89
|
+
in the cycle for easy debugging.
|
|
90
|
+
|
|
91
|
+
``optional_requires`` contribute ordering edges only when the named
|
|
92
|
+
plugin is present; missing optional deps are silently ignored.
|
|
81
93
|
"""
|
|
82
94
|
plugins_list = list(plugins)
|
|
83
95
|
by_name: dict[str, Plugin] = {}
|
|
@@ -87,18 +99,30 @@ def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
|
|
|
87
99
|
raise PluginDependencyError(f"duplicate plugin name {name!r} in resolve_load_order")
|
|
88
100
|
by_name[name] = p
|
|
89
101
|
|
|
102
|
+
# Conflicts first — fail fast before building the graph.
|
|
103
|
+
for plugin_obj in plugins_list:
|
|
104
|
+
consumer = plugin_obj.manifest.name
|
|
105
|
+
for foe in plugin_obj.manifest.conflicts:
|
|
106
|
+
if foe in by_name:
|
|
107
|
+
raise PluginConflictError(
|
|
108
|
+
f"plugin {consumer!r} conflicts with {foe!r}; cannot load together"
|
|
109
|
+
)
|
|
110
|
+
|
|
90
111
|
# Build adjacency: dep -> dependents (so we can Kahn-sort).
|
|
91
112
|
incoming: dict[str, set[str]] = {n: set() for n in by_name}
|
|
92
113
|
outgoing: dict[str, set[str]] = {n: set() for n in by_name}
|
|
93
114
|
|
|
94
115
|
for plugin_obj in plugins_list:
|
|
95
116
|
consumer = plugin_obj.manifest.name
|
|
117
|
+
# Hard requires — missing is an error.
|
|
96
118
|
for req in plugin_obj.manifest.requires:
|
|
97
119
|
dep_name, spec = parse_requirement(req)
|
|
98
120
|
if dep_name not in by_name:
|
|
99
|
-
raise
|
|
121
|
+
raise PluginMissingDependencyError(
|
|
100
122
|
f"plugin {consumer!r} requires {dep_name!r} which is not loaded "
|
|
101
|
-
f"(requirement {req!r})"
|
|
123
|
+
f"(requirement {req!r})",
|
|
124
|
+
missing=dep_name,
|
|
125
|
+
consumer=consumer,
|
|
102
126
|
)
|
|
103
127
|
dep_plugin = by_name[dep_name]
|
|
104
128
|
if spec:
|
|
@@ -115,6 +139,55 @@ def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
|
|
|
115
139
|
)
|
|
116
140
|
incoming[consumer].add(dep_name)
|
|
117
141
|
outgoing[dep_name].add(consumer)
|
|
142
|
+
# Peer requires — must be present, but resolvers don't auto-fetch.
|
|
143
|
+
# Use a dedicated exception class so the manager's resolver retry
|
|
144
|
+
# loop (which catches PluginMissingDependencyError) skips peers.
|
|
145
|
+
for req in plugin_obj.manifest.peer_requires:
|
|
146
|
+
dep_name, spec = parse_requirement(req)
|
|
147
|
+
if dep_name not in by_name:
|
|
148
|
+
raise PluginPeerDependencyError(
|
|
149
|
+
f"plugin {consumer!r} requires peer {dep_name!r} which is not loaded "
|
|
150
|
+
f"(requirement {req!r}); the host must supply it"
|
|
151
|
+
)
|
|
152
|
+
dep_plugin = by_name[dep_name]
|
|
153
|
+
if spec:
|
|
154
|
+
parsed = _parse_version(dep_name, dep_plugin.manifest.version)
|
|
155
|
+
if parsed is None:
|
|
156
|
+
raise PluginDependencyError(
|
|
157
|
+
f"plugin {consumer!r} requires peer {req!r}, but {dep_name!r} "
|
|
158
|
+
f"reports non-PEP440 version {dep_plugin.manifest.version!r}; "
|
|
159
|
+
"cannot check"
|
|
160
|
+
)
|
|
161
|
+
if parsed not in spec:
|
|
162
|
+
raise PluginDependencyError(
|
|
163
|
+
f"plugin {consumer!r} requires peer {req!r}, but {dep_name!r} "
|
|
164
|
+
f"version {dep_plugin.manifest.version!r} does not satisfy "
|
|
165
|
+
"the specifier"
|
|
166
|
+
)
|
|
167
|
+
incoming[consumer].add(dep_name)
|
|
168
|
+
outgoing[dep_name].add(consumer)
|
|
169
|
+
# Optional requires — missing is silent, version mismatch still raises.
|
|
170
|
+
for req in plugin_obj.manifest.optional_requires:
|
|
171
|
+
dep_name, spec = parse_requirement(req)
|
|
172
|
+
if dep_name not in by_name:
|
|
173
|
+
continue
|
|
174
|
+
dep_plugin = by_name[dep_name]
|
|
175
|
+
if spec:
|
|
176
|
+
parsed = _parse_version(dep_name, dep_plugin.manifest.version)
|
|
177
|
+
if parsed is None:
|
|
178
|
+
raise PluginDependencyError(
|
|
179
|
+
f"plugin {consumer!r} optionally requires {req!r}, but "
|
|
180
|
+
f"{dep_name!r} reports non-PEP440 version "
|
|
181
|
+
f"{dep_plugin.manifest.version!r}; cannot check"
|
|
182
|
+
)
|
|
183
|
+
if parsed not in spec:
|
|
184
|
+
raise PluginDependencyError(
|
|
185
|
+
f"plugin {consumer!r} optionally requires {req!r}, but "
|
|
186
|
+
f"{dep_name!r} version {dep_plugin.manifest.version!r} does "
|
|
187
|
+
f"not satisfy the specifier"
|
|
188
|
+
)
|
|
189
|
+
incoming[consumer].add(dep_name)
|
|
190
|
+
outgoing[dep_name].add(consumer)
|
|
118
191
|
|
|
119
192
|
# Kahn's algorithm — process plugins whose deps are already emitted.
|
|
120
193
|
ready = sorted(n for n, deps in incoming.items() if not deps)
|
|
@@ -133,3 +206,27 @@ def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
|
|
|
133
206
|
cycle = sorted(n for n, deps in incoming.items() if deps)
|
|
134
207
|
raise PluginDependencyError(f"cyclic plugin dependency involving: {', '.join(cycle)}")
|
|
135
208
|
return ordered
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def compute_reverse_graph(plugins: Iterable[Plugin]) -> dict[str, set[str]]:
|
|
212
|
+
"""Return ``{dep_name: {consumer_name, ...}}`` over ``plugins``.
|
|
213
|
+
|
|
214
|
+
Hard ``requires`` and ``peer_requires`` edges are included — both
|
|
215
|
+
represent a "the consumer genuinely needs this loaded" relationship,
|
|
216
|
+
and both block teardown of the dependency. ``optional_requires``
|
|
217
|
+
are excluded: an optional dep can disappear without breaking the
|
|
218
|
+
consumer, so it does not pin the dependency.
|
|
219
|
+
"""
|
|
220
|
+
reverse: dict[str, set[str]] = {}
|
|
221
|
+
names = {p.manifest.name for p in plugins}
|
|
222
|
+
for p in plugins:
|
|
223
|
+
consumer = p.manifest.name
|
|
224
|
+
for req in p.manifest.requires:
|
|
225
|
+
dep_name, _ = parse_requirement(req)
|
|
226
|
+
if dep_name in names:
|
|
227
|
+
reverse.setdefault(dep_name, set()).add(consumer)
|
|
228
|
+
for req in p.manifest.peer_requires:
|
|
229
|
+
dep_name, _ = parse_requirement(req)
|
|
230
|
+
if dep_name in names:
|
|
231
|
+
reverse.setdefault(dep_name, set()).add(consumer)
|
|
232
|
+
return reverse
|