@biffo/cli 0.199.0 → 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 `on_install`
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 on_install() always sets it explicitly."
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
- Seeds a single default widget on install and reacts to
4
- `biffo.core/UserCreated` by logging the new user. Replace both with real
5
- logic for your own plugin — this exists to show a working, correctly-wired
6
- skeleton: constructor -> manifest loading -> `self.api` -> `@self.subscribe`
7
- -> `on_install`/`on_uninstall`, exercised end to end by the tests in
8
- `tests/test_example_plugin.py`.
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 on_install(). Real plugins would seed whatever baseline data
31
- # their feature needs (RBAC's plugin.py seeds three roles, for example).
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 automatically by on_install(). Safe to delete.",
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
- """Called by the CLI when the plugin is installed.
56
+ """No-op. **Not invoked** nothing calls it (biffo-template#709).
52
57
 
53
- `BiffoPluginBase.on_install` is declared synchronous but
54
- `BiffoAPIClient` is entirely async (same constraint the RBAC
55
- reference plugin documents) bridge with `asyncio.run` here and
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
- asyncio.run(self.seed_default_widget())
62
+ return None
60
63
 
61
64
  async def seed_default_widget(self) -> None:
62
- """The async core of on_install().
63
-
64
- Generic CRUD (issue #19) has no upsert every POST creates a new
65
- row so this lists existing widgets first and only creates the
66
- default one if it's missing by name, making repeated installs a
67
- no-op instead of creating duplicate rows.
65
+ """Create this plugin's baseline row, idempotently.
66
+
67
+ Nothing in this skeleton calls thisdeliberately. **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
- """Called by the CLI when the plugin is uninstalled.
107
+ """No-op. **Not invoked** nothing calls it (biffo-template#709).
76
108
 
77
- No-op: dropping this plugin's tables is the CLI's job (ADR-0003
78
- section 9 `biffo plugin uninstall` applies a generated migration
79
- that drops them). Override this if your plugin needs additional
80
- cleanup beyond that (e.g. deleting objects it created outside its
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, lifecycle hooks, and the
2
- UserCreated event subscription.
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 TestOnInstall:
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 test_on_install_sync_bridge_seeds_widget(self) -> None:
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
- plugin.on_install()
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.199.0",
3
+ "version": "0.199.1",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",