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 +607 -0
- pyplugy-0.4.0/README.md +558 -0
- {pyplugy-0.2.0 → pyplugy-0.4.0}/pyproject.toml +7 -2
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/__init__.py +10 -0
- pyplugy-0.4.0/src/pyplugy/_context.py +510 -0
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/_depgraph.py +106 -9
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/_manager.py +616 -41
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/_plugin.py +135 -17
- pyplugy-0.4.0/src/pyplugy/_task_info.py +36 -0
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/_tasky_protocol.py +11 -2
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/exceptions.py +35 -0
- pyplugy-0.2.0/PKG-INFO +0 -341
- pyplugy-0.2.0/README.md +0 -293
- pyplugy-0.2.0/src/pyplugy/_context.py +0 -271
- {pyplugy-0.2.0 → pyplugy-0.4.0}/.gitignore +0 -0
- {pyplugy-0.2.0 → pyplugy-0.4.0}/LICENSE +0 -0
- {pyplugy-0.2.0 → pyplugy-0.4.0}/src/pyplugy/_discovery.py +0 -0
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
|
+
```
|