pyplugy 0.1.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.1.0/.gitignore +31 -0
- pyplugy-0.1.0/LICENSE +21 -0
- pyplugy-0.1.0/PKG-INFO +269 -0
- pyplugy-0.1.0/README.md +221 -0
- pyplugy-0.1.0/pyproject.toml +128 -0
- pyplugy-0.1.0/src/pyplugy/__init__.py +56 -0
- pyplugy-0.1.0/src/pyplugy/_context.py +239 -0
- pyplugy-0.1.0/src/pyplugy/_depgraph.py +135 -0
- pyplugy-0.1.0/src/pyplugy/_discovery.py +178 -0
- pyplugy-0.1.0/src/pyplugy/_manager.py +506 -0
- pyplugy-0.1.0/src/pyplugy/_plugin.py +263 -0
- pyplugy-0.1.0/src/pyplugy/_tasky_protocol.py +100 -0
- pyplugy-0.1.0/src/pyplugy/exceptions.py +46 -0
pyplugy-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.mypy_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.coverage
|
|
9
|
+
.coverage.*
|
|
10
|
+
htmlcov/
|
|
11
|
+
coverage.xml
|
|
12
|
+
|
|
13
|
+
# Build artifacts
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
*.egg
|
|
18
|
+
|
|
19
|
+
# Virtual envs
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
env/
|
|
23
|
+
|
|
24
|
+
# IDE
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
27
|
+
*.swp
|
|
28
|
+
|
|
29
|
+
# OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
pyplugy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kilian Senger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyplugy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyplugy
|
|
3
|
+
Version: 0.1.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: pyhooky>=0.1.0
|
|
44
|
+
Requires-Dist: pyworkflowy>=0.1.0
|
|
45
|
+
Provides-Extra: tasks
|
|
46
|
+
Requires-Dist: pyworkflowy>=0.1.0; extra == 'tasks'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# pyPlugy
|
|
50
|
+
|
|
51
|
+
Plugin framework for Python that composes [pyHooky](https://github.com/kiliansen/pyHooky) for
|
|
52
|
+
hook-based extension points and (optionally) [pyWorkflowy](https://github.com/kiliansen/pyWorkflowy)
|
|
53
|
+
for plugin-owned background tasks.
|
|
54
|
+
|
|
55
|
+
A plugin in pyPlugy is just a tagged collection of hook registrations (plus,
|
|
56
|
+
optionally, tasks). The manager drives lifecycle, isolates each plugin's
|
|
57
|
+
registrations via pyHooky's `tag_scope`, and tears them all down with one
|
|
58
|
+
`clear_tag` call on unload.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv add pyplugy
|
|
64
|
+
uv add 'pyplugy[tasks]' # add pyWorkflowy integration
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`pyplugy` depends on `pyhooky` and `packaging`. The `[tasks]` extra pulls in
|
|
68
|
+
`pyworkflowy` — without it, `ctx.task` raises a clear error pointing at the
|
|
69
|
+
extra.
|
|
70
|
+
|
|
71
|
+
## Defining a plugin
|
|
72
|
+
|
|
73
|
+
Two equivalent authoring forms:
|
|
74
|
+
|
|
75
|
+
### Decorator form
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from pyplugy import plugin, PluginContext
|
|
79
|
+
|
|
80
|
+
@plugin("audit", version="1.0.0", requires=["auth>=1.0"], description="Audit log")
|
|
81
|
+
def setup(ctx: PluginContext) -> None:
|
|
82
|
+
@ctx.before("checkout")
|
|
83
|
+
def log_start(cart): ...
|
|
84
|
+
|
|
85
|
+
@ctx.after("checkout")
|
|
86
|
+
def log_done(result, cart): ...
|
|
87
|
+
|
|
88
|
+
@ctx.on("checkout:step")
|
|
89
|
+
def log_step(step, **kw): ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Class form
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pyplugy import Plugin, PluginContext
|
|
96
|
+
|
|
97
|
+
class AuditPlugin(Plugin):
|
|
98
|
+
name = "audit"
|
|
99
|
+
version = "1.0.0"
|
|
100
|
+
requires = ("auth>=1.0",)
|
|
101
|
+
description = "Audit log"
|
|
102
|
+
|
|
103
|
+
def on_load(self, ctx: PluginContext) -> None:
|
|
104
|
+
@ctx.before("checkout")
|
|
105
|
+
def log_start(cart): ...
|
|
106
|
+
|
|
107
|
+
def on_unload(self, ctx: PluginContext) -> None: ...
|
|
108
|
+
def on_enable(self, ctx: PluginContext) -> None: ...
|
|
109
|
+
def on_disable(self, ctx: PluginContext) -> None: ...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Both produce a `Plugin` instance with the same `PluginManifest`.
|
|
113
|
+
|
|
114
|
+
## PluginContext
|
|
115
|
+
|
|
116
|
+
The `PluginContext` handed to your setup / lifecycle methods exposes:
|
|
117
|
+
|
|
118
|
+
| Attribute | Use |
|
|
119
|
+
|----------------------|-----------------------------------------------------------|
|
|
120
|
+
| `ctx.hooks` | The `pyhooky.HookRegistry` hooks land in |
|
|
121
|
+
| `ctx.before/after/around/on/on_error/hook` | Thin auto-tagged pyHooky passthroughs |
|
|
122
|
+
| `ctx.task` | Register tasks via the manager's pyWorkflowy backend |
|
|
123
|
+
| `ctx.scheduler` | Shared scheduler for periodic tasks (`Scheduler` protocol) |
|
|
124
|
+
| `ctx.config` | Per-plugin config dict (manager-injected) |
|
|
125
|
+
| `ctx.logger` | `logging.Logger` named `pyplugy.<plugin-name>` |
|
|
126
|
+
|
|
127
|
+
Every hook registered inside `on_load` is auto-tagged with the plugin's name
|
|
128
|
+
because the manager wraps the call in `pyhooky.tag_scope(plugin.name)`.
|
|
129
|
+
|
|
130
|
+
## Discovery
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pathlib import Path
|
|
134
|
+
from pyplugy import PluginManager
|
|
135
|
+
|
|
136
|
+
manager = PluginManager()
|
|
137
|
+
|
|
138
|
+
# Entry points
|
|
139
|
+
manager.load_entry_points("myapp.plugins")
|
|
140
|
+
|
|
141
|
+
# Directory
|
|
142
|
+
manager.load_directory(Path("./plugins"), recursive=True)
|
|
143
|
+
|
|
144
|
+
# JIT — any of these:
|
|
145
|
+
manager.load(MyPlugin)
|
|
146
|
+
manager.load(MyPlugin())
|
|
147
|
+
manager.load(some_module)
|
|
148
|
+
manager.load("my_app.plugins.audit") # dotted path
|
|
149
|
+
manager.load(Path("./extra/audit.py")) # .py file
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Lifecycle
|
|
153
|
+
|
|
154
|
+
States: `UNLOADED → LOADED → ENABLED ↔ DISABLED → UNLOADED`.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
manager.load(MyPlugin) # → LOADED, then ENABLED
|
|
158
|
+
manager.disable("my-plugin") # ENABLED → DISABLED (tears down all hooks/tasks)
|
|
159
|
+
manager.enable("my-plugin") # DISABLED → ENABLED (re-runs on_load setup)
|
|
160
|
+
manager.unload("my-plugin") # → UNLOADED (clear_tag, on_unload)
|
|
161
|
+
manager.reload("my-plugin") # unload + load
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Disable strategy** — pyPlugy *tears down* a disabled plugin's hooks and
|
|
165
|
+
tasks (via `clear_tag`) and *re-runs* its setup on enable. This keeps the
|
|
166
|
+
hook registry as the single source of truth and avoids stale half-registered
|
|
167
|
+
state when a plugin mutates external resources. Plugin authors should treat
|
|
168
|
+
`on_load` / `setup` as idempotent (or at least re-runnable).
|
|
169
|
+
|
|
170
|
+
## Dependencies between plugins
|
|
171
|
+
|
|
172
|
+
`requires` accepts a list of `"<name><specifier>"` strings (PEP 440):
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
class Reporting(Plugin):
|
|
176
|
+
name = "reporting"
|
|
177
|
+
version = "0.2.0"
|
|
178
|
+
requires = ("audit>=1.0", "auth<3.0")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
On load, the manager performs a topological sort:
|
|
182
|
+
|
|
183
|
+
- Missing dependency → `PluginDependencyError`
|
|
184
|
+
- Specifier mismatch → `PluginDependencyError`
|
|
185
|
+
- Cycle → `PluginDependencyError`
|
|
186
|
+
|
|
187
|
+
## Tag-based isolation
|
|
188
|
+
|
|
189
|
+
Every plugin's hooks are tagged with its `name` via `pyhooky.tag_scope`.
|
|
190
|
+
Unload is a single call:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
manager.unload("audit")
|
|
194
|
+
# under the hood:
|
|
195
|
+
# registry.clear_tag("audit")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
You can list a plugin's targets / tasks with:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
manager.plugin_targets("audit") # ["checkout", "checkout:step", ...]
|
|
202
|
+
manager.plugin_tasks("audit") # ["periodic_flush", ...]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Manager-level hooks
|
|
206
|
+
|
|
207
|
+
The manager fires pyHooky listener hooks for every lifecycle transition:
|
|
208
|
+
|
|
209
|
+
| Target | Fires |
|
|
210
|
+
|--------------------------|------------------------------------------------------|
|
|
211
|
+
| `pyplugy:plugin:load` | After a plugin reaches LOADED |
|
|
212
|
+
| `pyplugy:plugin:enable` | After enable |
|
|
213
|
+
| `pyplugy:plugin:disable` | After disable |
|
|
214
|
+
| `pyplugy:plugin:unload` | Before the plugin's hooks/tasks are torn down |
|
|
215
|
+
| `pyplugy:plugin:error` | When any lifecycle method raises (plugin, exc) |
|
|
216
|
+
|
|
217
|
+
Listen with `pyhooky.on`:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pyhooky import on
|
|
221
|
+
from pyplugy import HOOK_PLUGIN_LOAD
|
|
222
|
+
|
|
223
|
+
@on(HOOK_PLUGIN_LOAD)
|
|
224
|
+
def announce(plugin):
|
|
225
|
+
print("loaded:", plugin.manifest.name)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Integration with pyWorkflowy (optional)
|
|
229
|
+
|
|
230
|
+
`ctx.task` and `ctx.scheduler` come from the manager-injected pyWorkflowy surface:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import pyworkflowy
|
|
234
|
+
from pyworkflowy.schedule import Scheduler
|
|
235
|
+
from pyplugy import PluginManager
|
|
236
|
+
|
|
237
|
+
manager = PluginManager(tasky=pyworkflowy, scheduler=Scheduler())
|
|
238
|
+
|
|
239
|
+
@plugin("cron-cleanup", version="0.1.0")
|
|
240
|
+
def setup(ctx):
|
|
241
|
+
@ctx.task
|
|
242
|
+
def cleanup(): ...
|
|
243
|
+
ctx.scheduler.every("5m").do(cleanup)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Without a `tasky` argument, `ctx.task` raises a clear `RuntimeError`. pyPlugy
|
|
247
|
+
itself only depends on a small `typing.Protocol` defined in
|
|
248
|
+
`pyplugy._tasky_protocol` — so tests and apps don't need pyWorkflowy installed.
|
|
249
|
+
|
|
250
|
+
## Errors
|
|
251
|
+
|
|
252
|
+
| Error | When |
|
|
253
|
+
|-----------------------------|-----------------------------------------------------|
|
|
254
|
+
| `PluginError` | Base |
|
|
255
|
+
| `PluginNotFoundError` | `get` / `enable` / `disable` / `unload` on unknown |
|
|
256
|
+
| `PluginAlreadyLoadedError` | Loading a plugin whose name is already loaded |
|
|
257
|
+
| `PluginDependencyError` | Missing dep, version mismatch, cycle |
|
|
258
|
+
| `PluginLoadError` | `on_load` / `on_enable` / decorator setup raised |
|
|
259
|
+
| `PluginUnloadError` | `on_unload` raised |
|
|
260
|
+
| `PluginManifestError` | Invalid manifest (missing name, ...) |
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
uv sync
|
|
266
|
+
uv run pytest
|
|
267
|
+
uv run ruff check .
|
|
268
|
+
uv run pyrefly check
|
|
269
|
+
```
|
pyplugy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# pyPlugy
|
|
2
|
+
|
|
3
|
+
Plugin framework for Python that composes [pyHooky](https://github.com/kiliansen/pyHooky) for
|
|
4
|
+
hook-based extension points and (optionally) [pyWorkflowy](https://github.com/kiliansen/pyWorkflowy)
|
|
5
|
+
for plugin-owned background tasks.
|
|
6
|
+
|
|
7
|
+
A plugin in pyPlugy is just a tagged collection of hook registrations (plus,
|
|
8
|
+
optionally, tasks). The manager drives lifecycle, isolates each plugin's
|
|
9
|
+
registrations via pyHooky's `tag_scope`, and tears them all down with one
|
|
10
|
+
`clear_tag` call on unload.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv add pyplugy
|
|
16
|
+
uv add 'pyplugy[tasks]' # add pyWorkflowy integration
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`pyplugy` depends on `pyhooky` and `packaging`. The `[tasks]` extra pulls in
|
|
20
|
+
`pyworkflowy` — without it, `ctx.task` raises a clear error pointing at the
|
|
21
|
+
extra.
|
|
22
|
+
|
|
23
|
+
## Defining a plugin
|
|
24
|
+
|
|
25
|
+
Two equivalent authoring forms:
|
|
26
|
+
|
|
27
|
+
### Decorator form
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pyplugy import plugin, PluginContext
|
|
31
|
+
|
|
32
|
+
@plugin("audit", version="1.0.0", requires=["auth>=1.0"], description="Audit log")
|
|
33
|
+
def setup(ctx: PluginContext) -> None:
|
|
34
|
+
@ctx.before("checkout")
|
|
35
|
+
def log_start(cart): ...
|
|
36
|
+
|
|
37
|
+
@ctx.after("checkout")
|
|
38
|
+
def log_done(result, cart): ...
|
|
39
|
+
|
|
40
|
+
@ctx.on("checkout:step")
|
|
41
|
+
def log_step(step, **kw): ...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Class form
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from pyplugy import Plugin, PluginContext
|
|
48
|
+
|
|
49
|
+
class AuditPlugin(Plugin):
|
|
50
|
+
name = "audit"
|
|
51
|
+
version = "1.0.0"
|
|
52
|
+
requires = ("auth>=1.0",)
|
|
53
|
+
description = "Audit log"
|
|
54
|
+
|
|
55
|
+
def on_load(self, ctx: PluginContext) -> None:
|
|
56
|
+
@ctx.before("checkout")
|
|
57
|
+
def log_start(cart): ...
|
|
58
|
+
|
|
59
|
+
def on_unload(self, ctx: PluginContext) -> None: ...
|
|
60
|
+
def on_enable(self, ctx: PluginContext) -> None: ...
|
|
61
|
+
def on_disable(self, ctx: PluginContext) -> None: ...
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Both produce a `Plugin` instance with the same `PluginManifest`.
|
|
65
|
+
|
|
66
|
+
## PluginContext
|
|
67
|
+
|
|
68
|
+
The `PluginContext` handed to your setup / lifecycle methods exposes:
|
|
69
|
+
|
|
70
|
+
| Attribute | Use |
|
|
71
|
+
|----------------------|-----------------------------------------------------------|
|
|
72
|
+
| `ctx.hooks` | The `pyhooky.HookRegistry` hooks land in |
|
|
73
|
+
| `ctx.before/after/around/on/on_error/hook` | Thin auto-tagged pyHooky passthroughs |
|
|
74
|
+
| `ctx.task` | Register tasks via the manager's pyWorkflowy backend |
|
|
75
|
+
| `ctx.scheduler` | Shared scheduler for periodic tasks (`Scheduler` protocol) |
|
|
76
|
+
| `ctx.config` | Per-plugin config dict (manager-injected) |
|
|
77
|
+
| `ctx.logger` | `logging.Logger` named `pyplugy.<plugin-name>` |
|
|
78
|
+
|
|
79
|
+
Every hook registered inside `on_load` is auto-tagged with the plugin's name
|
|
80
|
+
because the manager wraps the call in `pyhooky.tag_scope(plugin.name)`.
|
|
81
|
+
|
|
82
|
+
## Discovery
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
from pyplugy import PluginManager
|
|
87
|
+
|
|
88
|
+
manager = PluginManager()
|
|
89
|
+
|
|
90
|
+
# Entry points
|
|
91
|
+
manager.load_entry_points("myapp.plugins")
|
|
92
|
+
|
|
93
|
+
# Directory
|
|
94
|
+
manager.load_directory(Path("./plugins"), recursive=True)
|
|
95
|
+
|
|
96
|
+
# JIT — any of these:
|
|
97
|
+
manager.load(MyPlugin)
|
|
98
|
+
manager.load(MyPlugin())
|
|
99
|
+
manager.load(some_module)
|
|
100
|
+
manager.load("my_app.plugins.audit") # dotted path
|
|
101
|
+
manager.load(Path("./extra/audit.py")) # .py file
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Lifecycle
|
|
105
|
+
|
|
106
|
+
States: `UNLOADED → LOADED → ENABLED ↔ DISABLED → UNLOADED`.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
manager.load(MyPlugin) # → LOADED, then ENABLED
|
|
110
|
+
manager.disable("my-plugin") # ENABLED → DISABLED (tears down all hooks/tasks)
|
|
111
|
+
manager.enable("my-plugin") # DISABLED → ENABLED (re-runs on_load setup)
|
|
112
|
+
manager.unload("my-plugin") # → UNLOADED (clear_tag, on_unload)
|
|
113
|
+
manager.reload("my-plugin") # unload + load
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Disable strategy** — pyPlugy *tears down* a disabled plugin's hooks and
|
|
117
|
+
tasks (via `clear_tag`) and *re-runs* its setup on enable. This keeps the
|
|
118
|
+
hook registry as the single source of truth and avoids stale half-registered
|
|
119
|
+
state when a plugin mutates external resources. Plugin authors should treat
|
|
120
|
+
`on_load` / `setup` as idempotent (or at least re-runnable).
|
|
121
|
+
|
|
122
|
+
## Dependencies between plugins
|
|
123
|
+
|
|
124
|
+
`requires` accepts a list of `"<name><specifier>"` strings (PEP 440):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
class Reporting(Plugin):
|
|
128
|
+
name = "reporting"
|
|
129
|
+
version = "0.2.0"
|
|
130
|
+
requires = ("audit>=1.0", "auth<3.0")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
On load, the manager performs a topological sort:
|
|
134
|
+
|
|
135
|
+
- Missing dependency → `PluginDependencyError`
|
|
136
|
+
- Specifier mismatch → `PluginDependencyError`
|
|
137
|
+
- Cycle → `PluginDependencyError`
|
|
138
|
+
|
|
139
|
+
## Tag-based isolation
|
|
140
|
+
|
|
141
|
+
Every plugin's hooks are tagged with its `name` via `pyhooky.tag_scope`.
|
|
142
|
+
Unload is a single call:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
manager.unload("audit")
|
|
146
|
+
# under the hood:
|
|
147
|
+
# registry.clear_tag("audit")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
You can list a plugin's targets / tasks with:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
manager.plugin_targets("audit") # ["checkout", "checkout:step", ...]
|
|
154
|
+
manager.plugin_tasks("audit") # ["periodic_flush", ...]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Manager-level hooks
|
|
158
|
+
|
|
159
|
+
The manager fires pyHooky listener hooks for every lifecycle transition:
|
|
160
|
+
|
|
161
|
+
| Target | Fires |
|
|
162
|
+
|--------------------------|------------------------------------------------------|
|
|
163
|
+
| `pyplugy:plugin:load` | After a plugin reaches LOADED |
|
|
164
|
+
| `pyplugy:plugin:enable` | After enable |
|
|
165
|
+
| `pyplugy:plugin:disable` | After disable |
|
|
166
|
+
| `pyplugy:plugin:unload` | Before the plugin's hooks/tasks are torn down |
|
|
167
|
+
| `pyplugy:plugin:error` | When any lifecycle method raises (plugin, exc) |
|
|
168
|
+
|
|
169
|
+
Listen with `pyhooky.on`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from pyhooky import on
|
|
173
|
+
from pyplugy import HOOK_PLUGIN_LOAD
|
|
174
|
+
|
|
175
|
+
@on(HOOK_PLUGIN_LOAD)
|
|
176
|
+
def announce(plugin):
|
|
177
|
+
print("loaded:", plugin.manifest.name)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Integration with pyWorkflowy (optional)
|
|
181
|
+
|
|
182
|
+
`ctx.task` and `ctx.scheduler` come from the manager-injected pyWorkflowy surface:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
import pyworkflowy
|
|
186
|
+
from pyworkflowy.schedule import Scheduler
|
|
187
|
+
from pyplugy import PluginManager
|
|
188
|
+
|
|
189
|
+
manager = PluginManager(tasky=pyworkflowy, scheduler=Scheduler())
|
|
190
|
+
|
|
191
|
+
@plugin("cron-cleanup", version="0.1.0")
|
|
192
|
+
def setup(ctx):
|
|
193
|
+
@ctx.task
|
|
194
|
+
def cleanup(): ...
|
|
195
|
+
ctx.scheduler.every("5m").do(cleanup)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Without a `tasky` argument, `ctx.task` raises a clear `RuntimeError`. pyPlugy
|
|
199
|
+
itself only depends on a small `typing.Protocol` defined in
|
|
200
|
+
`pyplugy._tasky_protocol` — so tests and apps don't need pyWorkflowy installed.
|
|
201
|
+
|
|
202
|
+
## Errors
|
|
203
|
+
|
|
204
|
+
| Error | When |
|
|
205
|
+
|-----------------------------|-----------------------------------------------------|
|
|
206
|
+
| `PluginError` | Base |
|
|
207
|
+
| `PluginNotFoundError` | `get` / `enable` / `disable` / `unload` on unknown |
|
|
208
|
+
| `PluginAlreadyLoadedError` | Loading a plugin whose name is already loaded |
|
|
209
|
+
| `PluginDependencyError` | Missing dep, version mismatch, cycle |
|
|
210
|
+
| `PluginLoadError` | `on_load` / `on_enable` / decorator setup raised |
|
|
211
|
+
| `PluginUnloadError` | `on_unload` raised |
|
|
212
|
+
| `PluginManifestError` | Invalid manifest (missing name, ...) |
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
uv sync
|
|
218
|
+
uv run pytest
|
|
219
|
+
uv run ruff check .
|
|
220
|
+
uv run pyrefly check
|
|
221
|
+
```
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyplugy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Plugin framework with hooks (pyHooky) and tasks (pyWorkflowy)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "KilianSen" }]
|
|
9
|
+
keywords = ["plugins", "framework", "hooks", "tasks", "extension"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"pyhooky>=0.1.0",
|
|
24
|
+
"packaging>=24.0",
|
|
25
|
+
"pyworkflowy>=0.1.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
tasks = ["pyworkflowy>=0.1.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/kiliansen/pyPlugy"
|
|
33
|
+
Issues = "https://github.com/kiliansen/pyPlugy/issues"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/pyplugy"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/pyplugy", "README.md", "LICENSE", "pyproject.toml"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
minversion = "8.0"
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
addopts = ["-ra", "--strict-markers", "--strict-config"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
[tool.coverage.run]
|
|
57
|
+
source = ["src/pyplugy"]
|
|
58
|
+
branch = true
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
exclude_lines = [
|
|
62
|
+
"pragma: no cover",
|
|
63
|
+
"raise NotImplementedError",
|
|
64
|
+
"if TYPE_CHECKING:",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
target-version = "py311"
|
|
71
|
+
src = ["src", "tests"]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = [
|
|
75
|
+
"E", "F", "W", # pycodestyle / pyflakes
|
|
76
|
+
"I", # isort
|
|
77
|
+
"B", # bugbear
|
|
78
|
+
"UP", # pyupgrade
|
|
79
|
+
"SIM", # simplify
|
|
80
|
+
"RUF", # ruff-specific
|
|
81
|
+
]
|
|
82
|
+
ignore = [
|
|
83
|
+
# PEP 695 generic syntax doesn't play well with @overload chains
|
|
84
|
+
"UP046", # generic class via Generic[T]
|
|
85
|
+
"UP047", # generic function via TypeVar
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.per-file-ignores]
|
|
89
|
+
"tests/**" = ["B011"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
[tool.pyrefly]
|
|
93
|
+
project-includes = ["src", "tests"]
|
|
94
|
+
python-version = "3.11"
|
|
95
|
+
|
|
96
|
+
[tool.semantic_release]
|
|
97
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
98
|
+
tag_format = "v{version}"
|
|
99
|
+
commit_message = "chore(release): {version} [skip ci]"
|
|
100
|
+
build_command = "pip install uv && uv build"
|
|
101
|
+
major_on_zero = false
|
|
102
|
+
allow_zero_version = true
|
|
103
|
+
|
|
104
|
+
[tool.semantic_release.branches.master]
|
|
105
|
+
match = "master"
|
|
106
|
+
prerelease = false
|
|
107
|
+
|
|
108
|
+
[tool.semantic_release.commit_parser_options]
|
|
109
|
+
allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore"]
|
|
110
|
+
minor_tags = ["feat"]
|
|
111
|
+
patch_tags = ["fix", "perf"]
|
|
112
|
+
default_bump_level = 0
|
|
113
|
+
|
|
114
|
+
[tool.semantic_release.publish]
|
|
115
|
+
upload_to_vcs_release = true
|
|
116
|
+
|
|
117
|
+
[tool.semantic_release.changelog]
|
|
118
|
+
exclude_commit_patterns = ["^chore\\(release\\):"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
[dependency-groups]
|
|
122
|
+
dev = [
|
|
123
|
+
"pytest>=8.0",
|
|
124
|
+
"pytest-asyncio>=0.23",
|
|
125
|
+
"pytest-cov>=5.0",
|
|
126
|
+
"ruff>=0.6",
|
|
127
|
+
"pyrefly>=0.16",
|
|
128
|
+
]
|