@biffo/cli 0.198.9 → 0.199.1
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.
|
@@ -55,9 +55,36 @@ engine** (`services/_plugins/orchestrator/` in the biffo-template monorepo) —
|
|
|
55
55
|
class, same layout, and it ships a real `terraform/` too. It has considerably
|
|
56
56
|
more moving parts (workflow definitions, actions, SigV4 calls into the Core
|
|
57
57
|
API); this template's `example_plugin` is deliberately the smallest version of
|
|
58
|
-
the same shape: one table (`example_widgets`), four routes, one
|
|
58
|
+
the same shape: one table (`example_widgets`), four routes, one idempotent
|
|
59
59
|
seed, one `@subscribe` handler.
|
|
60
60
|
|
|
61
|
+
### Seeding: not from `on_install()`
|
|
62
|
+
|
|
63
|
+
`BiffoPluginBase` requires `on_install()`, `on_uninstall()` and `on_upgrade()`,
|
|
64
|
+
and they are **not invoked** — ADR-0003 §9 described a `biffo plugin install`
|
|
65
|
+
that would call them and the call site was never built. Define them as no-ops,
|
|
66
|
+
as `plugin.py` does. An earlier revision of this skeleton seeded through
|
|
67
|
+
`on_install()`; anyone who copied it got a seed that silently never ran, the
|
|
68
|
+
plugin deploying clean with empty tables and the symptom surfacing wherever
|
|
69
|
+
those rows were read
|
|
70
|
+
([#709](https://github.com/keiranholloway/biffo-template/issues/709)).
|
|
71
|
+
|
|
72
|
+
Baseline data goes in one of two places instead, and `seed_default_widget()` in
|
|
73
|
+
`plugin.py` documents both at the call site:
|
|
74
|
+
|
|
75
|
+
- **If your plugin declares an `api_ingress` ASGI app** (ADR-0021), seed from
|
|
76
|
+
that app's lifespan. The shared plugin host runs each mounted app's lifespan
|
|
77
|
+
itself, because Starlette's `Mount` never delivers the lifespan scope — until
|
|
78
|
+
[#948](https://github.com/keiranholloway/biffo-template/pull/948) a plugin's
|
|
79
|
+
own `@app.on_event("startup")` was equally dead. It fires on every cold start,
|
|
80
|
+
so the seed must be idempotent; Core's own
|
|
81
|
+
`POST /api/v1/internal/plugins/me/config/seed` was not idempotent until
|
|
82
|
+
[#1000](https://github.com/keiranholloway/biffo-template/pull/1000), so treat
|
|
83
|
+
this route as new and check your own.
|
|
84
|
+
- **If it doesn't** — like `example_plugin`, which is event-only — there is no
|
|
85
|
+
startup to hang anything on. Put the rows in a SQL module in the instance's
|
|
86
|
+
`db/imports/<name>/`, applied by `biffo data apply` on every deploy.
|
|
87
|
+
|
|
61
88
|
> Earlier revisions of this README pointed at an RBAC reference plugin at
|
|
62
89
|
> `services/rbac/`. That plugin was removed by
|
|
63
90
|
> [ADR-0011](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0011-authorization-is-a-core-concern.md)
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"name": "is_active",
|
|
25
25
|
"type": "Boolean",
|
|
26
26
|
"nullable": true,
|
|
27
|
-
"description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own
|
|
27
|
+
"description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own seed always sets it explicitly."
|
|
28
28
|
}
|
|
29
29
|
],
|
|
30
30
|
"indexes": [
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""ExamplePlugin — this plugin's BiffoPluginBase implementation.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Reacts to `biffo.core/UserCreated` by logging the new user, and carries an
|
|
4
|
+
idempotent seed for its baseline row. Replace both with real logic for your
|
|
5
|
+
own plugin — this exists to show a working, correctly-wired skeleton:
|
|
6
|
+
constructor -> manifest loading -> `self.api` -> `@self.subscribe`, exercised
|
|
7
|
+
end to end by the tests in `tests/test_example_plugin.py`.
|
|
8
|
+
|
|
9
|
+
**`on_install()` / `on_uninstall()` are not invoked.** They are required by
|
|
10
|
+
`BiffoPluginBase` and nothing calls them, so they are no-ops here and should
|
|
11
|
+
be no-ops in your plugin (biffo-template#709). An earlier version of this
|
|
12
|
+
skeleton seeded through `on_install()` — copying that gives you a seed that
|
|
13
|
+
silently never runs. `seed_default_widget()` below shows where seeding
|
|
14
|
+
actually goes.
|
|
9
15
|
|
|
10
16
|
Modelled directly on the RBAC reference plugin's
|
|
11
17
|
`services/rbac/src/rbac/plugin.py` (PR #76) in the biffo-template monorepo —
|
|
@@ -15,7 +21,6 @@ tables, an event-driven side effect, and an in-process (non-CRUD) helper.
|
|
|
15
21
|
|
|
16
22
|
from __future__ import annotations
|
|
17
23
|
|
|
18
|
-
import asyncio
|
|
19
24
|
from typing import Any
|
|
20
25
|
|
|
21
26
|
from aws_lambda_powertools import Logger
|
|
@@ -27,11 +32,11 @@ logger = Logger()
|
|
|
27
32
|
|
|
28
33
|
_PLUGIN_BASE_PATH = "/api/v1/plugins/example-plugin"
|
|
29
34
|
|
|
30
|
-
# Seeded by
|
|
31
|
-
# their feature needs
|
|
35
|
+
# Seeded by seed_default_widget(). Real plugins would seed whatever baseline
|
|
36
|
+
# data their feature needs.
|
|
32
37
|
_DEFAULT_WIDGET: dict[str, Any] = {
|
|
33
38
|
"name": "starter-widget",
|
|
34
|
-
"description": "Created
|
|
39
|
+
"description": "Created by this plugin's own seed. Safe to delete.",
|
|
35
40
|
"is_active": True,
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -48,23 +53,50 @@ class ExamplePlugin(BiffoPluginBase):
|
|
|
48
53
|
await self._log_user_created(event)
|
|
49
54
|
|
|
50
55
|
def on_install(self) -> None:
|
|
51
|
-
"""
|
|
56
|
+
"""No-op. **Not invoked** — nothing calls it (biffo-template#709).
|
|
52
57
|
|
|
53
|
-
`BiffoPluginBase
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
keep the actual async logic in the public `seed_default_widget()`,
|
|
57
|
-
which tests and any future async caller can `await` directly.
|
|
58
|
+
`BiffoPluginBase` requires it, so it is defined; `biffo plugin
|
|
59
|
+
install` does not run it, and neither does anything else. Put no
|
|
60
|
+
logic here. Seeding goes in `seed_default_widget()` below.
|
|
58
61
|
"""
|
|
59
|
-
|
|
62
|
+
return None
|
|
60
63
|
|
|
61
64
|
async def seed_default_widget(self) -> None:
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
"""Create this plugin's baseline row, idempotently.
|
|
66
|
+
|
|
67
|
+
Nothing in this skeleton calls this — deliberately. **Where you call
|
|
68
|
+
it from depends on what kind of plugin you are building**, and
|
|
69
|
+
neither answer is `on_install()`:
|
|
70
|
+
|
|
71
|
+
- **A plugin with an `api_ingress` ASGI app** (ADR-0021) seeds from
|
|
72
|
+
its app's *lifespan*, which the shared plugin host drives on the
|
|
73
|
+
first invocation of each process::
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
77
|
+
await plugin.seed_default_widget()
|
|
78
|
+
yield
|
|
79
|
+
|
|
80
|
+
app = FastAPI(lifespan=lifespan)
|
|
81
|
+
|
|
82
|
+
The host has to run that handshake itself, because Starlette's
|
|
83
|
+
`Mount` never delivers the lifespan scope to a mounted app — until
|
|
84
|
+
biffo-template#948 a plugin's own `@app.on_event("startup")` was as
|
|
85
|
+
dead as `on_install()`. It runs on every cold start, so the seed
|
|
86
|
+
must be idempotent (below), and Core's own seed endpoint
|
|
87
|
+
`POST /api/v1/internal/plugins/me/config/seed` was itself not
|
|
88
|
+
idempotent until #1000. Verify your seed; do not assume it.
|
|
89
|
+
|
|
90
|
+
- **An event-only plugin like this one** declares no `api_ingress`,
|
|
91
|
+
so it has no startup at all. Its baseline rows belong in a SQL
|
|
92
|
+
module in the *instance's* `db/imports/<name>/`, applied by
|
|
93
|
+
`biffo data apply` on every deploy — the mechanism the first-party
|
|
94
|
+
plugins use.
|
|
95
|
+
|
|
96
|
+
Idempotency: generic CRUD (issue #19) has no upsert — every POST
|
|
97
|
+
creates a new row — so this lists existing widgets first and only
|
|
98
|
+
creates the default one if it's missing by name, making a repeated
|
|
99
|
+
cold start a no-op instead of accumulating duplicates.
|
|
68
100
|
"""
|
|
69
101
|
existing = await self.api.get(f"{_PLUGIN_BASE_PATH}/widgets")
|
|
70
102
|
existing_names = {row.get("name") for row in existing}
|
|
@@ -72,13 +104,12 @@ class ExamplePlugin(BiffoPluginBase):
|
|
|
72
104
|
await self.api.post(f"{_PLUGIN_BASE_PATH}/widgets", json=dict(_DEFAULT_WIDGET))
|
|
73
105
|
|
|
74
106
|
def on_uninstall(self) -> None:
|
|
75
|
-
"""
|
|
107
|
+
"""No-op. **Not invoked** — nothing calls it (biffo-template#709).
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
own tables).
|
|
109
|
+
There is no teardown moment for it to belong to either: `biffo plugin
|
|
110
|
+
uninstall` removes the plugin's code and Terraform and deliberately
|
|
111
|
+
leaves its tables in place (ADR-0003 section 9). Cleanup beyond that
|
|
112
|
+
is a hand-written Alembic migration, not a hook.
|
|
82
113
|
"""
|
|
83
114
|
return None
|
|
84
115
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
"""Tests for ExamplePlugin: manifest loading,
|
|
2
|
-
|
|
1
|
+
"""Tests for ExamplePlugin: manifest loading, the seed, and the UserCreated
|
|
2
|
+
event subscription.
|
|
3
|
+
|
|
4
|
+
`on_install()` / `on_uninstall()` are asserted to be no-ops, not to seed:
|
|
5
|
+
they are **not invoked** by anything (biffo-template#709), so a test that
|
|
6
|
+
proved seeding through them would be proving something that never happens.
|
|
3
7
|
|
|
4
8
|
Modelled on the RBAC reference plugin's
|
|
5
9
|
`services/rbac/tests/test_rbac_plugin.py` (PR #76) in the biffo-template
|
|
@@ -48,7 +52,7 @@ class TestSubscription:
|
|
|
48
52
|
assert plugin.events.has_subscription("UserCreated")
|
|
49
53
|
|
|
50
54
|
|
|
51
|
-
class
|
|
55
|
+
class TestSeeding:
|
|
52
56
|
async def test_seeds_default_widget(self) -> None:
|
|
53
57
|
plugin, fake = _make_plugin()
|
|
54
58
|
|
|
@@ -73,22 +77,29 @@ class TestOnInstall:
|
|
|
73
77
|
widget = fake.tables["widgets"][0]
|
|
74
78
|
assert widget["is_active"] is True
|
|
75
79
|
|
|
80
|
+
|
|
81
|
+
class TestLifecycleHooksAreNoOps:
|
|
82
|
+
"""The ABC's hooks are **not invoked** by anything (biffo-template#709).
|
|
83
|
+
|
|
84
|
+
So the contract worth asserting is that they do nothing — in particular
|
|
85
|
+
that neither of them seeds. A plugin whose baseline data depends on one
|
|
86
|
+
of these ships a seed that never runs.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def test_on_install_is_a_noop_and_seeds_nothing(self) -> None:
|
|
90
|
+
plugin, fake = _make_plugin()
|
|
91
|
+
|
|
92
|
+
assert plugin.on_install() is None
|
|
93
|
+
assert fake.tables["widgets"] == []
|
|
94
|
+
|
|
76
95
|
def test_on_uninstall_is_a_noop(self) -> None:
|
|
77
96
|
plugin, _ = _make_plugin()
|
|
78
97
|
assert plugin.on_uninstall() is None
|
|
79
98
|
|
|
80
|
-
def
|
|
81
|
-
"""The public, ABC-compliant on_install() entrypoint (a plain sync
|
|
82
|
-
function calling asyncio.run(seed_default_widget()) internally) —
|
|
83
|
-
exercised from a genuinely synchronous test function, the same kind
|
|
84
|
-
of context the CLI would call it from, since asyncio.run() cannot be
|
|
85
|
-
nested inside pytest-asyncio's already-running loop."""
|
|
99
|
+
def test_on_upgrade_is_a_noop(self) -> None:
|
|
86
100
|
plugin, fake = _make_plugin()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
names = {r["name"] for r in fake.tables["widgets"]}
|
|
91
|
-
assert names == {_DEFAULT_WIDGET["name"]}
|
|
101
|
+
assert plugin.on_upgrade("0.0.1") is None
|
|
102
|
+
assert fake.tables["widgets"] == []
|
|
92
103
|
|
|
93
104
|
|
|
94
105
|
class TestUserCreatedLogsEvent:
|