pyplugy 0.2.0__tar.gz → 0.4.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.4.0/PKG-INFO ADDED
@@ -0,0 +1,607 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyplugy
3
+ Version: 0.4.0
4
+ Summary: Plugin framework with hooks (pyHooky) and tasks (pyWorkflowy).
5
+ Project-URL: Homepage, https://github.com/kiliansen/pyPlugy
6
+ Project-URL: Issues, https://github.com/kiliansen/pyPlugy/issues
7
+ Author: KilianSen
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Kilian Senger
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: extension,framework,hooks,plugins,tasks
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
40
+ Classifier: Typing :: Typed
41
+ Requires-Python: >=3.11
42
+ Requires-Dist: packaging>=24.0
43
+ Requires-Dist: pydantic>=2.0
44
+ Requires-Dist: pyhooky>=0.1.0
45
+ Requires-Dist: pyworkflowy>=0.2.0
46
+ Provides-Extra: tasks
47
+ Requires-Dist: pyworkflowy>=0.1.0; extra == 'tasks'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # pyPlugy
51
+
52
+ Plugin framework for Python that composes [pyHooky](https://github.com/kiliansen/pyHooky) for
53
+ hook-based extension points and [pyWorkflowy](https://github.com/kiliansen/pyWorkflowy)
54
+ for plugin-owned background tasks.
55
+
56
+ A plugin in pyPlugy is just a tagged collection of hook registrations (plus,
57
+ optionally, tasks and typed events). The manager drives lifecycle, isolates
58
+ each plugin's registrations via pyHooky's `tag_scope`, and tears them all
59
+ down with one `clear_tag` call on unload.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ uv add pyplugy
65
+ ```
66
+
67
+ Core dependencies: `pyhooky`, `packaging`, `pyworkflowy`, `pydantic>=2`. The
68
+ `[tasks]` extra exists for back-compat but is a no-op now — pyWorkflowy is
69
+ always installed. `ctx.task` still raises a clear error if you don't pass
70
+ `tasky=` to `PluginManager(...)` (see [Integration with pyWorkflowy](#integration-with-pyworkflowy)).
71
+
72
+ ## Defining a plugin
73
+
74
+ Two equivalent authoring forms:
75
+
76
+ ### Decorator form
77
+
78
+ ```python
79
+ from pyplugy import plugin, PluginContext
80
+
81
+ @plugin("audit", version="1.0.0", requires=["auth>=1.0"], description="Audit log")
82
+ def setup(ctx: PluginContext) -> None:
83
+ @ctx.before("checkout")
84
+ def log_start(cart): ...
85
+
86
+ @ctx.after("checkout")
87
+ def log_done(result, cart): ...
88
+
89
+ @ctx.on("checkout:step")
90
+ def log_step(step, **kw): ...
91
+ ```
92
+
93
+ ### Class form
94
+
95
+ ```python
96
+ from pyplugy import Plugin, PluginContext
97
+
98
+ class AuditPlugin(Plugin):
99
+ # name is auto-derived from the class name → "audit_plugin"
100
+ # (override by setting `name = "audit"` explicitly)
101
+ version = "1.0.0"
102
+ requires = ("auth>=1.0",)
103
+ description = "Audit log"
104
+
105
+ def on_load(self, ctx: PluginContext) -> None:
106
+ @ctx.before("checkout")
107
+ def log_start(cart): ...
108
+
109
+ def on_unload(self, ctx: PluginContext) -> None: ...
110
+ def on_enable(self, ctx: PluginContext) -> None: ...
111
+ def on_disable(self, ctx: PluginContext) -> None: ...
112
+ ```
113
+
114
+ Both produce a `Plugin` instance with the same `PluginManifest`.
115
+
116
+ Class-form plugins get their `name` auto-derived from the class name via
117
+ PascalCase → snake_case (`AuthPlugin` → `"auth_plugin"`, `HTTPAdapter` →
118
+ `"http_adapter"`, `Auth` → `"auth"`). Setting `name = "..."` explicitly
119
+ always wins.
120
+
121
+ ## PluginContext
122
+
123
+ The `PluginContext` handed to your setup / lifecycle methods exposes:
124
+
125
+ | Attribute / method | Use |
126
+ |---------------------------------------------|----------------------------------------------------------------------|
127
+ | `ctx.hooks` | The `pyhooky.HookRegistry` hooks land in |
128
+ | `ctx.before/after/around/on/on_error/hook` | Thin auto-tagged pyHooky passthroughs |
129
+ | `ctx.event(name, Schema)` | Build a pyHooky `HookPoint` typed with a pydantic model |
130
+ | `ctx.events` | `HookPoint`s built from the class-level `events = {...}` declaration |
131
+ | `ctx.task` | Register tasks (decorator, `TaskBase` subclass, or callable) |
132
+ | `ctx.scheduler` | Shared scheduler for periodic tasks (`Scheduler` protocol) |
133
+ | `ctx.config` | Per-plugin config dict (manager-injected) |
134
+ | `ctx.config_model` | Pydantic-validated config instance (when `config_model` declared) |
135
+ | `ctx.dep(name)` / `ctx.depends_on(name)` | The dep's `PluginContext` (must be declared in requires) |
136
+ | `ctx.api_of(name)` | Whatever the dep last `export`-ed |
137
+ | `ctx.export(obj)` | Publish this plugin's API for dependents (validated vs `api_model`) |
138
+ | `ctx.logger` | `logging.Logger` named `pyplugy.<plugin-name>` |
139
+
140
+ Every hook registered inside `on_load` is auto-tagged with the plugin's name
141
+ because the manager wraps the call in `pyhooky.tag_scope(plugin.name)`.
142
+
143
+ ## Discovery
144
+
145
+ ```python
146
+ from pathlib import Path
147
+ from pyplugy import PluginManager
148
+
149
+ manager = PluginManager()
150
+
151
+ # Entry points
152
+ manager.load_entry_points("myapp.plugins")
153
+
154
+ # Directory
155
+ manager.load_directory(Path("./plugins"), recursive=True)
156
+
157
+ # JIT — any of these:
158
+ manager.load(MyPlugin)
159
+ manager.load(MyPlugin())
160
+ manager.load(some_module)
161
+ manager.load("my_app.plugins.audit") # dotted path
162
+ manager.load(Path("./extra/audit.py")) # .py file
163
+ ```
164
+
165
+ ## Lifecycle
166
+
167
+ States: `UNLOADED → LOADED → ENABLED ↔ DISABLED → UNLOADED`.
168
+
169
+ ```python
170
+ manager.load(MyPlugin) # → LOADED, then ENABLED
171
+ manager.disable("audit") # ENABLED → DISABLED
172
+ manager.enable("audit") # DISABLED → ENABLED (re-runs on_load)
173
+ manager.unload("audit") # → UNLOADED
174
+ manager.reload("audit") # unload + load (cascades by default)
175
+ manager.swap("audit", AuditV2()) # replace the instance, cascade-reload deps
176
+ ```
177
+
178
+ **Cascade rules.** When a plugin has dependents (hard `requires` or
179
+ `peer_requires`), `disable` / `unload` refuse by default and raise
180
+ `PluginDependencyError`. Pass `cascade=True` to tear down dependents first
181
+ (reverse-topo order):
182
+
183
+ ```python
184
+ manager.unload("auth") # raises if dependents loaded
185
+ manager.unload("auth", cascade=True) # unload dependents first, then auth
186
+ ```
187
+
188
+ `reload` cascades by default (`cascade=True`), restoring each dependent's
189
+ prior enabled state after the reload.
190
+
191
+ **Disable strategy** — pyPlugy *tears down* a disabled plugin's hooks and
192
+ tasks (via `clear_tag`) and *re-runs* its setup on enable. This keeps the
193
+ hook registry as the single source of truth and avoids stale half-registered
194
+ state when a plugin mutates external resources. Plugin authors should treat
195
+ `on_load` / `setup` as idempotent (or at least re-runnable).
196
+
197
+ ### Async lifecycle
198
+
199
+ `on_load` / `on_enable` / `on_disable` / `on_unload` may be declared
200
+ `async def`. Drive an async plugin through the async variants of the
201
+ manager API — they accept the same arguments as their sync siblings:
202
+
203
+ ```python
204
+ async def startup():
205
+ await manager.aload(MyAsyncPlugin)
206
+ await manager.adisable("my-plugin")
207
+ await manager.aenable("my-plugin")
208
+ await manager.aunload("my-plugin")
209
+ await manager.aswap("my-plugin", MyAsyncPluginV2())
210
+ ```
211
+
212
+ Calling the sync `manager.load(...)` on a plugin whose `on_load` is
213
+ `async def` raises `PluginLoadError` rather than silently leaking the
214
+ coroutine.
215
+
216
+ ### Persistence
217
+
218
+ pyPlugy is in-memory only: it never writes plugin state to disk and never
219
+ remembers across process restarts which plugins were enabled. The host owns
220
+ that — persist `enabled` flags wherever you keep plugin settings, then on
221
+ startup replay them via `manager.load(...)` followed by `manager.enable(...)`
222
+ or `manager.disable(...)`.
223
+
224
+ ### Runtime config edits
225
+
226
+ The dict returned by `ctx.config` is the same object the manager holds in
227
+ its internal `_configs` table — edits the host makes propagate without a
228
+ reload. Use `manager.update_config(name, new_config)` to do that edit
229
+ cleanly:
230
+
231
+ ```python
232
+ manager.update_config("audit", {"verbosity": "debug"}) # merge
233
+ manager.update_config("audit", {"only": True}, replace=True) # replace
234
+ ```
235
+
236
+ `update_config` fires the `pyplugy:plugin:config-changed` listener hook
237
+ (constant `HOOK_PLUGIN_CONFIG_CHANGED`) so plugins can react. It also
238
+ works *before* a plugin is loaded — useful for pre-seeding config from
239
+ host storage. When the plugin declared a `config_model`, the new config is
240
+ re-validated and the dict is rolled back on failure (raises
241
+ `PluginConfigValidationError`).
242
+
243
+ ### Extending `PluginContext`
244
+
245
+ If a host needs to attach extras to the context plugins receive
246
+ (`ctx.app`, `ctx.event_bus`, `ctx.task_queue`, …), subclass
247
+ `PluginContext` and pass the subclass — typically bound via
248
+ `functools.partial` — as `context_class`:
249
+
250
+ ```python
251
+ import functools
252
+ from pyplugy import Plugin, PluginContext, PluginManager
253
+
254
+ class HostContext(PluginContext):
255
+ __slots__ = ("app", "event_bus")
256
+
257
+ def __init__(self, *args, app, event_bus, **kwargs):
258
+ super().__init__(*args, **kwargs)
259
+ self.app = app
260
+ self.event_bus = event_bus
261
+
262
+ manager = PluginManager(
263
+ context_class=functools.partial(HostContext, app=fastapi_app, event_bus=bus),
264
+ )
265
+ ```
266
+
267
+ The base `PluginContext` uses `__slots__`; subclasses must either declare
268
+ their own `__slots__` for the new fields or omit `__slots__` to get a
269
+ per-instance `__dict__`.
270
+
271
+ ## Dependencies between plugins
272
+
273
+ Three kinds of dependency declarations:
274
+
275
+ | Field | Semantics |
276
+ |---------------------|------------------------------------------------------------------------------------------|
277
+ | `requires` | Hard: dep must be loaded; orders the consumer after it. Resolvers can auto-fetch. |
278
+ | `optional_requires` | Soft: if present, orders the consumer after it; if missing, planning proceeds silently. |
279
+ | `peer_requires` | Hard, host-supplied: must be loaded, but resolvers deliberately skip these. |
280
+ | `conflicts` | Refuse to coexist with the named plugins (symmetric). |
281
+
282
+ All four accept `"<name><specifier>"` PEP 440 strings (bare names = any
283
+ version):
284
+
285
+ ```python
286
+ class Reporting(Plugin):
287
+ version = "0.2.0"
288
+ requires = ("audit>=1.0", "auth<3.0")
289
+ optional_requires = ("metrics",)
290
+ peer_requires = ("event_bus",)
291
+ conflicts = ("legacy_reporting",)
292
+ ```
293
+
294
+ On load, the manager performs a topological sort:
295
+
296
+ - Missing hard dep → `PluginMissingDependencyError` (carries `missing` + `consumer`)
297
+ - Missing peer dep → `PluginPeerDependencyError`
298
+ - Specifier mismatch → `PluginDependencyError`
299
+ - Cycle → `PluginDependencyError`
300
+ - Conflict → `PluginConflictError`
301
+
302
+ ### Auto-resolving missing deps
303
+
304
+ Register a resolver to fetch missing hard deps on demand (peers are
305
+ deliberately skipped):
306
+
307
+ ```python
308
+ from pathlib import Path
309
+
310
+ manager.use_entry_point_resolver("myapp.plugins")
311
+ manager.use_directory_resolver(Path("./plugins"), recursive=True)
312
+
313
+ # Or custom:
314
+ manager.add_resolver(lambda name: my_lookup(name)) # returns list[Plugin] | None
315
+ ```
316
+
317
+ Resolvers run in registration order. The first one returning a non-empty
318
+ list contributes its plugins to the batch and planning re-runs.
319
+ `resolver_max_rounds` (default 10) caps runaway resolvers.
320
+
321
+ ### Injecting deps into setup signatures
322
+
323
+ Parameters past `ctx` named after a declared dep are auto-filled with
324
+ `ctx.api_of(<name>)`:
325
+
326
+ ```python
327
+ class Users(Plugin):
328
+ requires = ("auth",)
329
+
330
+ def on_load(self, ctx, auth): # `auth` = ctx.api_of("auth")
331
+ ... # could be a pydantic model, a callable, anything
332
+ ```
333
+
334
+ Decorator form works the same way — pyPlugy introspects the wrapped setup:
335
+
336
+ ```python
337
+ @plugin("users", requires=["auth"])
338
+ def setup(ctx, auth):
339
+ ...
340
+ ```
341
+
342
+ `*args` / `**kwargs` are skipped. Parameter names that don't match a
343
+ declared dep are left unbound, surfacing as a clear `TypeError` from the
344
+ call — pyPlugy doesn't guess.
345
+
346
+ ### Introspection
347
+
348
+ ```python
349
+ manager.dependencies_of("reporting") # ["audit"]
350
+ manager.dependencies_of("reporting", transitive=True) # ["audit", "auth"]
351
+ manager.dependents_of("audit") # ["reporting"]
352
+ manager.dependency_graph() # {"audit": [], "reporting": ["audit"], ...}
353
+ manager.dump() # full snapshot incl. graph
354
+ ```
355
+
356
+ ## Plugin-exported APIs
357
+
358
+ A plugin can publish a value (callable, pydantic model, dict, …) that its
359
+ dependents reach via `ctx.api_of(name)`:
360
+
361
+ ```python
362
+ from pydantic import BaseModel
363
+
364
+ class AuthAPI(BaseModel):
365
+ sign: str
366
+
367
+ class Auth(Plugin):
368
+ version = "1.0.0"
369
+ api_model = AuthAPI # optional pydantic schema
370
+
371
+ def on_load(self, ctx):
372
+ ctx.export({"sign": "v1"}) # dict → AuthAPI (validated)
373
+
374
+ class Users(Plugin):
375
+ requires = ("auth",)
376
+
377
+ def on_load(self, ctx, auth): # auto-injected from declared dep
378
+ assert isinstance(auth, AuthAPI)
379
+ assert auth.sign == "v1"
380
+ ```
381
+
382
+ `ctx.api_of("auth")` enforces that `"auth"` is declared in
383
+ `requires` / `optional_requires` / `peer_requires`. Returns `None` for an
384
+ optional dep that isn't loaded. `ctx.export` validates the payload against
385
+ `api_model` when declared (dict → model via `model_validate`; existing
386
+ instance type-checked).
387
+
388
+ ## Typed events
389
+
390
+ `ctx.event(name, Schema)` builds a pyHooky `HookPoint` typed with a
391
+ pydantic model and bound to the manager's registry — listeners attached
392
+ through it are auto-tagged for cleanup:
393
+
394
+ ```python
395
+ from pydantic import BaseModel
396
+
397
+ class LoginEvent(BaseModel):
398
+ user: str
399
+
400
+ @plugin("auth", version="1.0.0")
401
+ def setup(ctx):
402
+ login = ctx.event("auth:login", LoginEvent)
403
+
404
+ @login.listen
405
+ def audit(evt: LoginEvent): ...
406
+
407
+ login.trigger(user="alice") # validated against LoginEvent
408
+ ```
409
+
410
+ Class-level declaration auto-builds the `HookPoint`s and stores them on
411
+ `ctx.events`, namespaced as `"<plugin>:<key>"` in the registry:
412
+
413
+ ```python
414
+ class Auth(Plugin):
415
+ events = {"login": LoginEvent, "logout": LogoutEvent}
416
+
417
+ def on_load(self, ctx):
418
+ @ctx.events["login"].listen
419
+ def audit(evt: LoginEvent): ...
420
+
421
+ ctx.events["login"].trigger(user="alice")
422
+ ```
423
+
424
+ ## Validated config
425
+
426
+ Declare `config_model` (a pydantic `BaseModel` subclass) and the manager
427
+ validates `ctx.config` on load — and on every `update_config` call,
428
+ rolling back the dict if validation fails:
429
+
430
+ ```python
431
+ class AuthConfig(BaseModel):
432
+ secret: str
433
+
434
+ class Auth(Plugin):
435
+ version = "1.0.0"
436
+ config_model = AuthConfig
437
+
438
+ def on_load(self, ctx):
439
+ cfg = ctx.config_model # AuthConfig instance, validated
440
+ assert cfg is not None
441
+ connect(cfg.secret)
442
+
443
+ manager = PluginManager(configs={"auth": {"secret": "abc"}})
444
+ manager.load(Auth()) # OK
445
+ manager.update_config("auth", {"secret": 123}) # raises PluginConfigValidationError
446
+ ```
447
+
448
+ `ctx.config` still returns the raw dict — `config_model` is the validated
449
+ snapshot, refreshed on each `update_config`.
450
+
451
+ ## Hot-swap
452
+
453
+ Replace a running plugin instance with a new one of the same name —
454
+ dependents are cascade-reloaded around the swap so they pick up the new
455
+ exported API:
456
+
457
+ ```python
458
+ class AuthV2(Plugin):
459
+ name = "auth"
460
+ version = "2.0.0"
461
+ api_model = AuthAPI
462
+
463
+ def on_load(self, ctx):
464
+ ctx.export({"sign": "v2"})
465
+
466
+ manager.swap("auth", AuthV2()) # users sees v2 after reload
467
+ ```
468
+
469
+ `swap` rejects a different-named replacement (it's not a rename) and works
470
+ fine with zero dependents (degenerates to unload + load). Async variant:
471
+ `manager.aswap`.
472
+
473
+ ## Tag-based isolation
474
+
475
+ Every plugin's hooks are tagged with its `name` via `pyhooky.tag_scope`.
476
+ Unload is a single call:
477
+
478
+ ```python
479
+ manager.unload("audit")
480
+ # under the hood:
481
+ # registry.clear_tag("audit")
482
+ ```
483
+
484
+ You can list a plugin's targets / tasks with:
485
+
486
+ ```python
487
+ manager.plugin_targets("audit") # ["checkout", "checkout:step", ...]
488
+ manager.plugin_tasks("audit") # (PluginTaskInfo(task=<Task name='periodic_flush'>, metadata={}), ...)
489
+ [i.task.name for i in manager.plugin_tasks("audit")] # ["periodic_flush", ...]
490
+ ```
491
+
492
+ ## Manager-level hooks
493
+
494
+ The manager fires pyHooky listener hooks for every lifecycle transition:
495
+
496
+ | Target | Fires |
497
+ |---------------------------------|----------------------------------------------------------------------|
498
+ | `pyplugy:plugin:load` | After a plugin reaches LOADED |
499
+ | `pyplugy:plugin:enable` | After enable |
500
+ | `pyplugy:plugin:disable` | After disable |
501
+ | `pyplugy:plugin:unload` | Before the plugin's hooks/tasks are torn down |
502
+ | `pyplugy:plugin:error` | When any lifecycle method raises `(plugin, exc)` |
503
+ | `pyplugy:plugin:config-changed` | After `manager.update_config` mutates a loaded plugin's config |
504
+
505
+ Listen with `pyhooky.on`:
506
+
507
+ ```python
508
+ from pyhooky import on
509
+ from pyplugy import HOOK_PLUGIN_LOAD
510
+
511
+ @on(HOOK_PLUGIN_LOAD)
512
+ def announce(plugin):
513
+ print("loaded:", plugin.manifest.name)
514
+ ```
515
+
516
+ ## Integration with pyWorkflowy
517
+
518
+ `ctx.task` and `ctx.scheduler` come from the pyWorkflowy surface, wired via
519
+ the manager:
520
+
521
+ ```python
522
+ import pyworkflowy
523
+ from pyworkflowy import TaskBase
524
+ from pyworkflowy.schedule import Scheduler
525
+ from pyplugy import PluginManager, plugin
526
+
527
+ manager = PluginManager(tasky=pyworkflowy, scheduler=Scheduler())
528
+
529
+ @plugin("cron-cleanup", version="0.1.0")
530
+ def setup(ctx):
531
+ @ctx.task
532
+ def cleanup(): ... # decorator form
533
+
534
+ class Vacuum(TaskBase): # pyWorkflowy class form
535
+ name = "vacuum"
536
+ def run(self): ...
537
+
538
+ ctx.task(Vacuum) # registers and returns the Task instance
539
+ ctx.scheduler.every("5m").do(cleanup)
540
+ ```
541
+
542
+ Without a `tasky` argument, `ctx.task(fn)` raises a clear `RuntimeError`.
543
+ The `TaskBase`-class form is self-contained (it bypasses the `tasky=`
544
+ requirement) and works on any manager. pyPlugy itself only depends on a
545
+ small `typing.Protocol` defined in `pyplugy._tasky_protocol`, so the
546
+ `tasky=` argument can be a stub for tests.
547
+
548
+ ### Triggers auto-wire on enable
549
+
550
+ A task declared with `triggers=` is bound to the scheduler automatically when
551
+ the plugin is enabled, and cancelled on disable — no host-side wiring needed:
552
+
553
+ ```python
554
+ @plugin("reactive", version="0.1.0")
555
+ def setup(ctx):
556
+ @ctx.task(triggers=("book.added",), payload_map={"book_id": "book_id"})
557
+ def on_book_added(book_id: str) -> None:
558
+ ...
559
+ ```
560
+
561
+ The manager calls `scheduler.bind_tasks(...)` for every task with a non-empty
562
+ `triggers` attribute, and `scheduler.cancel(job)` for each on disable.
563
+
564
+ ### Host metadata
565
+
566
+ `ctx.task(metadata={...})` attaches data the host reads back via
567
+ `PluginManager.plugin_tasks(name)`. pyPlugy never inspects it — useful when
568
+ the host wants to surface a task's category, priority, or input schema in
569
+ its admin UI without subclassing the context:
570
+
571
+ ```python
572
+ @ctx.task(metadata={"category": "reactive", "priority": 5})
573
+ def handler() -> None: ...
574
+
575
+ # Host code:
576
+ for info in manager.plugin_tasks("reactive"):
577
+ print(info.task.name, info.metadata)
578
+ ```
579
+
580
+ ## Errors
581
+
582
+ | Error | When |
583
+ |----------------------------------|-----------------------------------------------------------------------------|
584
+ | `PluginError` | Base |
585
+ | `PluginNotFoundError` | `get` / `enable` / `disable` / `unload` on unknown |
586
+ | `PluginAlreadyLoadedError` | Loading a plugin whose name is already loaded |
587
+ | `PluginDependencyError` | Specifier mismatch, cycle, blocked cascade, generic dep failure |
588
+ | `PluginMissingDependencyError` | Hard `requires` entry missing (carries `missing` + `consumer`) |
589
+ | `PluginPeerDependencyError` | `peer_requires` entry missing — host must supply, resolvers won't help |
590
+ | `PluginConflictError` | `conflicts` entry collides with another loaded or about-to-load plugin |
591
+ | `PluginConfigValidationError` | `ctx.config` fails its `config_model` (load or `update_config`) |
592
+ | `PluginLoadError` | `on_load` / `on_enable` / decorator setup raised |
593
+ | `PluginUnloadError` | `on_unload` raised |
594
+ | `PluginManifestError` | Invalid manifest (missing name, malformed requirement, …) |
595
+
596
+ All inherit from `PluginError`, so one `except PluginError` catches the
597
+ family; `isinstance` discriminates.
598
+
599
+ ## Development
600
+
601
+ ```bash
602
+ uv sync
603
+ uv run pytest
604
+ uv run ruff format --check .
605
+ uv run ruff check .
606
+ uv run pyrefly check
607
+ ```