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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyplugy
3
- Version: 0.2.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.1.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.2.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.1.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 PluginDependencyError
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:`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
+ - 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 PluginDependencyError(
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