branchkit 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.
Files changed (33) hide show
  1. branchkit-0.1.0/LICENSE +21 -0
  2. branchkit-0.1.0/PKG-INFO +84 -0
  3. branchkit-0.1.0/README.md +71 -0
  4. branchkit-0.1.0/branchkit/__init__.py +87 -0
  5. branchkit-0.1.0/branchkit/closed_vocab_gen.py +95 -0
  6. branchkit-0.1.0/branchkit/collection.py +188 -0
  7. branchkit-0.1.0/branchkit/collection_log.py +97 -0
  8. branchkit-0.1.0/branchkit/commands.py +221 -0
  9. branchkit-0.1.0/branchkit/contracts_gen.py +666 -0
  10. branchkit-0.1.0/branchkit/correlation.py +28 -0
  11. branchkit-0.1.0/branchkit/debug.py +43 -0
  12. branchkit-0.1.0/branchkit/effects.py +73 -0
  13. branchkit-0.1.0/branchkit/harness.py +296 -0
  14. branchkit-0.1.0/branchkit/hud.py +18 -0
  15. branchkit-0.1.0/branchkit/listen.py +244 -0
  16. branchkit-0.1.0/branchkit/log.py +10 -0
  17. branchkit-0.1.0/branchkit/log_events_gen.py +138 -0
  18. branchkit-0.1.0/branchkit/methods_gen.py +4431 -0
  19. branchkit-0.1.0/branchkit/mirror.py +117 -0
  20. branchkit-0.1.0/branchkit/plugin.py +475 -0
  21. branchkit-0.1.0/branchkit/proxy.py +157 -0
  22. branchkit-0.1.0/branchkit/settings.py +97 -0
  23. branchkit-0.1.0/branchkit/settings_route.py +30 -0
  24. branchkit-0.1.0/branchkit/types_gen.py +4768 -0
  25. branchkit-0.1.0/branchkit/ui.py +149 -0
  26. branchkit-0.1.0/branchkit/upstream.py +65 -0
  27. branchkit-0.1.0/branchkit.egg-info/PKG-INFO +84 -0
  28. branchkit-0.1.0/branchkit.egg-info/SOURCES.txt +31 -0
  29. branchkit-0.1.0/branchkit.egg-info/dependency_links.txt +1 -0
  30. branchkit-0.1.0/branchkit.egg-info/top_level.txt +1 -0
  31. branchkit-0.1.0/pyproject.toml +22 -0
  32. branchkit-0.1.0/setup.cfg +4 -0
  33. branchkit-0.1.0/tests/test_kernel.py +225 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BranchKit
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.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: branchkit
3
+ Version: 0.1.0
4
+ Summary: BranchKit plugin SDK for Python
5
+ Author: BranchKit
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://branchkit.dev
8
+ Project-URL: Repository, https://github.com/branchkit/plugin-sdk-py
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # BranchKit Plugin SDK (Python)
15
+
16
+ The Python SDK for building [BranchKit](https://branchkit.dev)
17
+ plugins — processes (run under BranchKit's managed CPython) that add
18
+ voice commands, window management, browser integration, or anything
19
+ else to the BranchKit platform. MIT licensed. Feature-parity with the
20
+ Go and TypeScript SDKs, verified by a shared conformance harness.
21
+ Stdlib-only: importing the SDK forces no third-party dependency on any
22
+ plugin.
23
+
24
+ ## Start here
25
+
26
+ - **[Your First Plugin](https://branchkit.dev/guide/getting-started/your-first-plugin)** —
27
+ working plugin in ~10 minutes
28
+ - **[Plugin Anatomy](https://branchkit.dev/guide/getting-started/plugin-anatomy)** —
29
+ manifest, lifecycle, methods
30
+ - **[Plugin API Reference](https://branchkit.dev/reference/specs/plugin-api)** —
31
+ every wire method, generated from the OpenRPC spec
32
+
33
+ ## Minimal plugin
34
+
35
+ ```python
36
+ import asyncio
37
+ import branchkit
38
+
39
+ plugin = branchkit.Plugin()
40
+
41
+ @plugin.handle_action("myplugin.greet")
42
+ async def greet(req):
43
+ await plugin.input_type_text("Hello!")
44
+ return {"status": "ok"}
45
+
46
+ asyncio.run(plugin.run())
47
+ ```
48
+
49
+ Pair with a `plugin.json` manifest declaring the action and
50
+ `"runtimes": ["python"]` (`run: "python3 main.py"`) — see the tutorial.
51
+ `branchkit-gen` generates typed params (`TypedDict`) and registrars from
52
+ your manifest's `action_types`.
53
+
54
+ Handlers may be `async def` (run on the asyncio loop) or plain `def`
55
+ (auto-offloaded to a worker thread, so a blocking body cannot freeze the
56
+ plugin — use `plugin.call_sync(...)` there instead of `await`).
57
+
58
+ ## Key surfaces
59
+
60
+ | Need | API |
61
+ |---|---|
62
+ | Handle an action | `@plugin.handle_action("prefix.name")` (alias `@plugin.action`) |
63
+ | Handle an RPC method | `@plugin.handle("my_method")` |
64
+ | Listen for events | `@plugin.on(EVENT_COLLECTION_UPDATED)` |
65
+ | Call the actuator | generated wrappers (`await plugin.collection_get(...)`, 600+) or raw `plugin.call` |
66
+ | State verbs | `plugin.get/list/put/patch/delete/replace/...` |
67
+ | Logs (append-only) | `plugin.append/append_keyed/list_log/...` |
68
+ | Mirrors | `plugin.mirror_collection(name)`, `plugin.settings(name)` |
69
+ | Commands | `branchkit.command(...)` builder, `push_command_group` |
70
+ | Leveled logging | `await plugin.info(tag, data)` (trace/debug/info/warn/error) |
71
+ | Local listener | `branchkit.listen_local(plugin)` (serves inherited fds) |
72
+ | Outbound HTTP | `branchkit.UpstreamClient` / `urllib.request.urlopen` (both proxy-aware) |
73
+ | Author tests | `branchkit.harness.Harness` |
74
+
75
+ ## Development
76
+
77
+ ```sh
78
+ python3 -m unittest discover -s tests # the SDK's own suite
79
+ ```
80
+
81
+ The cross-language conformance suite lives in the app workspace
82
+ (`branchkit-sdk-test` against `sdk-test/testplugin-py`). Generated
83
+ files (`*_gen.py`) are emitted by `emit-sdk` — edit the inventory, not
84
+ the files.
@@ -0,0 +1,71 @@
1
+ # BranchKit Plugin SDK (Python)
2
+
3
+ The Python SDK for building [BranchKit](https://branchkit.dev)
4
+ plugins — processes (run under BranchKit's managed CPython) that add
5
+ voice commands, window management, browser integration, or anything
6
+ else to the BranchKit platform. MIT licensed. Feature-parity with the
7
+ Go and TypeScript SDKs, verified by a shared conformance harness.
8
+ Stdlib-only: importing the SDK forces no third-party dependency on any
9
+ plugin.
10
+
11
+ ## Start here
12
+
13
+ - **[Your First Plugin](https://branchkit.dev/guide/getting-started/your-first-plugin)** —
14
+ working plugin in ~10 minutes
15
+ - **[Plugin Anatomy](https://branchkit.dev/guide/getting-started/plugin-anatomy)** —
16
+ manifest, lifecycle, methods
17
+ - **[Plugin API Reference](https://branchkit.dev/reference/specs/plugin-api)** —
18
+ every wire method, generated from the OpenRPC spec
19
+
20
+ ## Minimal plugin
21
+
22
+ ```python
23
+ import asyncio
24
+ import branchkit
25
+
26
+ plugin = branchkit.Plugin()
27
+
28
+ @plugin.handle_action("myplugin.greet")
29
+ async def greet(req):
30
+ await plugin.input_type_text("Hello!")
31
+ return {"status": "ok"}
32
+
33
+ asyncio.run(plugin.run())
34
+ ```
35
+
36
+ Pair with a `plugin.json` manifest declaring the action and
37
+ `"runtimes": ["python"]` (`run: "python3 main.py"`) — see the tutorial.
38
+ `branchkit-gen` generates typed params (`TypedDict`) and registrars from
39
+ your manifest's `action_types`.
40
+
41
+ Handlers may be `async def` (run on the asyncio loop) or plain `def`
42
+ (auto-offloaded to a worker thread, so a blocking body cannot freeze the
43
+ plugin — use `plugin.call_sync(...)` there instead of `await`).
44
+
45
+ ## Key surfaces
46
+
47
+ | Need | API |
48
+ |---|---|
49
+ | Handle an action | `@plugin.handle_action("prefix.name")` (alias `@plugin.action`) |
50
+ | Handle an RPC method | `@plugin.handle("my_method")` |
51
+ | Listen for events | `@plugin.on(EVENT_COLLECTION_UPDATED)` |
52
+ | Call the actuator | generated wrappers (`await plugin.collection_get(...)`, 600+) or raw `plugin.call` |
53
+ | State verbs | `plugin.get/list/put/patch/delete/replace/...` |
54
+ | Logs (append-only) | `plugin.append/append_keyed/list_log/...` |
55
+ | Mirrors | `plugin.mirror_collection(name)`, `plugin.settings(name)` |
56
+ | Commands | `branchkit.command(...)` builder, `push_command_group` |
57
+ | Leveled logging | `await plugin.info(tag, data)` (trace/debug/info/warn/error) |
58
+ | Local listener | `branchkit.listen_local(plugin)` (serves inherited fds) |
59
+ | Outbound HTTP | `branchkit.UpstreamClient` / `urllib.request.urlopen` (both proxy-aware) |
60
+ | Author tests | `branchkit.harness.Harness` |
61
+
62
+ ## Development
63
+
64
+ ```sh
65
+ python3 -m unittest discover -s tests # the SDK's own suite
66
+ ```
67
+
68
+ The cross-language conformance suite lives in the app workspace
69
+ (`branchkit-sdk-test` against `sdk-test/testplugin-py`). Generated
70
+ files (`*_gen.py`) are emitted by `emit-sdk` — edit the inventory, not
71
+ the files.
@@ -0,0 +1,87 @@
1
+ """BranchKit plugin SDK for Python.
2
+
3
+ import asyncio
4
+ import branchkit
5
+
6
+ plugin = branchkit.Plugin()
7
+
8
+ @plugin.handle("my.method")
9
+ async def my_method(params):
10
+ return {"ok": True}
11
+
12
+ asyncio.run(plugin.run())
13
+
14
+ Handlers may be `async def` (run on the loop) or plain `def`
15
+ (auto-offloaded to a thread, so a blocking body cannot freeze the
16
+ plugin). Stdlib-only by design: importing the SDK forces no third-party
17
+ dependency on any plugin."""
18
+
19
+ from . import proxy as _proxy
20
+
21
+ # Route stdlib HTTP through BRANCHKIT_PROXY when sandboxed (per-host
22
+ # tier) — same import-time side effect as the TS SDK's entry module.
23
+ _proxy.install_proxy_from_env()
24
+
25
+ from .plugin import (
26
+ PluginCore,
27
+ RecordingDisabledError,
28
+ RpcCallError,
29
+ api_version,
30
+ error_kind_of,
31
+ models_dir,
32
+ plugin_data_dir,
33
+ plugin_dir,
34
+ )
35
+ from .collection import CollectionMixin, list_opts, scope_collection, scope_group
36
+ from .collection_log import CollectionLogMixin, log_list_opts
37
+ from .debug import DebugMixin
38
+ from .effects import EffectsMixin
39
+ from .hud import HudMixin
40
+ from .log import log
41
+ from .methods_gen import MethodsMixin
42
+ from .mirror import CollectionMirror, MirrorMixin
43
+ from .settings import SettingsMirror, SettingsMixin
44
+ from .commands import (
45
+ CommandBuilder,
46
+ capture,
47
+ command,
48
+ load_commands,
49
+ one_of,
50
+ push_command_group,
51
+ push_command_specs,
52
+ push_commands,
53
+ text,
54
+ word,
55
+ )
56
+ from .listen import Listener, inherited_listener_count, listen_local
57
+ from .settings_route import method_post, method_url
58
+ from .ui import (
59
+ Expr,
60
+ args,
61
+ confirm_button,
62
+ expr,
63
+ input_value,
64
+ post_button,
65
+ signal_button,
66
+ signal_name,
67
+ )
68
+ from .upstream import UpstreamClient, UpstreamResponse
69
+ from .closed_vocab_gen import * # noqa: F401,F403 — error kinds, directives, effects
70
+ from .contracts_gen import * # noqa: F401,F403 — method/hook/event/tag constants
71
+ from .log_events_gen import * # noqa: F401,F403 — observability log-event names
72
+
73
+
74
+ class Plugin(
75
+ CollectionMixin,
76
+ CollectionLogMixin,
77
+ EffectsMixin,
78
+ DebugMixin,
79
+ HudMixin,
80
+ MirrorMixin,
81
+ SettingsMixin,
82
+ MethodsMixin,
83
+ PluginCore,
84
+ ):
85
+ """The plugin: JSON-RPC transport plus the generated method wrappers
86
+ and the state/log/effects/debug/HUD/mirror/settings façades, one
87
+ class. See PluginCore for the transport contract."""
@@ -0,0 +1,95 @@
1
+ # AUTO-GENERATED by emit-sdk — do not edit.
2
+ # Run: just contracts
3
+
4
+ from typing import NotRequired, TypedDict
5
+
6
+ # INPUT_DIRECTIVE_* are the closed-vocabulary keys plugins may write
7
+ # into `_platform.input.directives`. Source of truth:
8
+ # `actuator/src/state.rs::InputDirectives::KNOWN_KEYS`.
9
+ INPUT_DIRECTIVE_LIFT_KEYS_ON_DISPATCH = "lift_keys_on_dispatch"
10
+
11
+ # EFFECT_* are the registered effect names plugins may declare in
12
+ # `provides.effects`. Source of truth:
13
+ # `actuator/src/effects.rs::EffectDef` inventory entries.
14
+ EFFECT_DISABLE_SCREEN_DIM = "disable_screen_dim"
15
+ EFFECT_MUTE_AUDIO_TO_OTHER_APPS = "mute_audio_to_other_apps"
16
+ EFFECT_PAUSE_MICROPHONE_INDICATOR = "pause_microphone_indicator"
17
+ EFFECT_PREVENT_FOCUS_STEAL = "prevent_focus_steal"
18
+ EFFECT_SIGNAL_RECORDING_ACTIVE = "signal_recording_active"
19
+ EFFECT_SUPPRESS_HUDS = "suppress_huds"
20
+ EFFECT_SUPPRESS_KEYBINDS = "suppress_keybinds"
21
+ EFFECT_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
22
+
23
+ # The full closed-vocabulary sets.
24
+ KNOWN_INPUT_DIRECTIVES = (
25
+ "lift_keys_on_dispatch",
26
+ )
27
+
28
+ KNOWN_EFFECTS = (
29
+ "disable_screen_dim",
30
+ "mute_audio_to_other_apps",
31
+ "pause_microphone_indicator",
32
+ "prevent_focus_steal",
33
+ "signal_recording_active",
34
+ "suppress_huds",
35
+ "suppress_keybinds",
36
+ "suppress_notifications",
37
+ )
38
+
39
+ # ERROR_KIND_* are the closed-vocabulary `kind` values the actuator puts
40
+ # in a JSON-RPC error's `data.kind`. Branch on RpcCallError.kind rather
41
+ # than matching the message prose. Source of truth:
42
+ # `actuator/src/fault.rs::ErrorKind`.
43
+ #
44
+ # NOTE: ErrorKind is `str`, deliberately NOT a Literal union of the
45
+ # values below. An actuator newer than this SDK may send a kind that is
46
+ # not listed here, and that must fall through a match rather than fail
47
+ # to parse.
48
+ ErrorKind = str
49
+ ERROR_KIND_NOT_PERMITTED = "not_permitted"
50
+ ERROR_KIND_RECORDING_DISABLED = "recording_disabled"
51
+ ERROR_KIND_NOT_FOUND = "not_found"
52
+ ERROR_KIND_VALIDATION = "validation"
53
+ ERROR_KIND_FORBIDDEN = "forbidden"
54
+ ERROR_KIND_STORAGE = "storage"
55
+ ERROR_KIND_METHOD_NOT_FOUND = "method_not_found"
56
+ ERROR_KIND_INVALID_PARAMS = "invalid_params"
57
+ ERROR_KIND_INTERNAL = "internal"
58
+
59
+ # ERROR_CODE_FOR maps a kind to the JSON-RPC error code the actuator
60
+ # sends with it. Derived from the kind actuator-side.
61
+ ERROR_CODE_FOR: dict[str, int] = {
62
+ "not_permitted": -32002,
63
+ "recording_disabled": -32006,
64
+ "not_found": -32001,
65
+ "validation": -32004,
66
+ "forbidden": -32003,
67
+ "storage": -32005,
68
+ "method_not_found": -32601,
69
+ "invalid_params": -32602,
70
+ "internal": -32603,
71
+ }
72
+
73
+ # KNOWN_ERROR_KINDS lists the full closed-vocabulary set.
74
+ KNOWN_ERROR_KINDS = (
75
+ "not_permitted",
76
+ "recording_disabled",
77
+ "not_found",
78
+ "validation",
79
+ "forbidden",
80
+ "storage",
81
+ "method_not_found",
82
+ "invalid_params",
83
+ "internal",
84
+ )
85
+
86
+ # FaultData is the structured payload of a JSON-RPC error's `data`
87
+ # member. Only `kind` is guaranteed; the rest are populated when they
88
+ # apply. Source of truth: `actuator/src/fault.rs::FaultData`.
89
+ FaultData = TypedDict("FaultData", {
90
+ "kind": str,
91
+ "collection": NotRequired[str],
92
+ "detail": NotRequired[str],
93
+ "id": NotRequired[str],
94
+ "op": NotRequired[str],
95
+ })
@@ -0,0 +1,188 @@
1
+ """State uniform helpers — the collection façade over the generated
2
+ `collection.*` wrappers. Python twin of plugin-sdk-go/collection.go and
3
+ plugin-sdk-ts/src/collection.ts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Callable
8
+
9
+ from .contracts_gen import EVENT_COLLECTION_UPDATED
10
+
11
+
12
+ def scope_collection() -> dict:
13
+ """Every other record THIS PLUGIN owns in the collection is the
14
+ complement: after the call, the records you own here are exactly the
15
+ ones you passed. Other plugins' records, and any the user added through
16
+ Settings, are untouched and invisible to the diff."""
17
+ return {"kind": "collection"}
18
+
19
+
20
+ def scope_group(group: str) -> dict:
21
+ """Narrows further, to this plugin's records carrying the named group
22
+ label — and stamps that label on every entry written. The label must be
23
+ non-empty; an empty one is indistinguishable from "ungrouped" and the
24
+ platform refuses it."""
25
+ return {"kind": "group", "value": group}
26
+
27
+
28
+ def list_opts(
29
+ *,
30
+ since_ms: int | None = None,
31
+ until_ms: int | None = None,
32
+ limit: int | None = None,
33
+ cursor: str | None = None,
34
+ writer: str | None = None,
35
+ ) -> dict:
36
+ """Build a ListOpts with typed scalar values. `writer` filters to
37
+ records owned by that writer — pair with `plugin.id` to ask for your
38
+ own records, the read half of a scoped write."""
39
+ out: dict[str, Any] = {}
40
+ if since_ms is not None:
41
+ out["since_ms"] = since_ms
42
+ if until_ms is not None:
43
+ out["until_ms"] = until_ms
44
+ if limit is not None:
45
+ out["limit"] = limit
46
+ if cursor is not None:
47
+ out["cursor"] = cursor
48
+ if writer is not None:
49
+ out["writer"] = writer
50
+ return out
51
+
52
+
53
+ class CollectionMixin:
54
+ """The uniform state verbs. Composed into `Plugin`."""
55
+
56
+ async def get(self, name: str, id: str) -> dict | None:
57
+ """The record with that id, or None. On a keyed (compacted-changelog)
58
+ log this is the RAW entry — use `get_compacted` for the folded
59
+ current state."""
60
+ res = await self.collection_fetch(id, name)
61
+ rec = (res or {}).get("record")
62
+ return rec if isinstance(rec, dict) else None
63
+
64
+ async def get_compacted(self, name: str, key: str) -> dict | None:
65
+ """A keyed log's folded CURRENT state for one key — the point-read
66
+ half of the compacted-changelog projection (pairs with
67
+ `list_compacted`)."""
68
+ res = await self.collection_fetch_compacted(key, name)
69
+ rec = (res or {}).get("record")
70
+ return rec if isinstance(rec, dict) else None
71
+
72
+ async def list(self, name: str, opts: dict | None = None) -> list[dict]:
73
+ res = await self.collection_list(name, opts)
74
+ return (res or {}).get("records") or []
75
+
76
+ async def list_compacted(self, name: str, opts: dict | None = None) -> list[dict]:
77
+ """The compacted-changelog projection of a keyed log — one folded
78
+ record per key instead of the raw append history."""
79
+ merged = dict(opts or {})
80
+ merged["compacted"] = True
81
+ res = await self.collection_list(name, merged)
82
+ return (res or {}).get("records") or []
83
+
84
+ async def list_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
85
+ """Like `list` but also returns the unfiltered total."""
86
+ res = await self.collection_list(name, opts)
87
+ res = res or {}
88
+ return res.get("records") or [], res.get("total") or 0
89
+
90
+ async def count(self, name: str) -> int:
91
+ res = await self.collection_count(name)
92
+ return (res or {}).get("count") or 0
93
+
94
+ async def put(self, name: str, id: str, payload: Any) -> None:
95
+ """Single-record upsert. An unregistered name auto-registers as a
96
+ record-keyed dynamic collection — memory-only and EPHEMERAL; declare
97
+ the collection in the manifest for durable storage."""
98
+ await self.collection_put(name, [{"id": id, "payload": payload}])
99
+
100
+ async def put_many(self, name: str, entries: list[dict]) -> int:
101
+ """Bulk upsert. Validation runs across all entries before any
102
+ commit, so a partial batch with one invalid entry leaves the
103
+ backend untouched."""
104
+ if not entries:
105
+ return 0
106
+ res = await self.collection_put(name, entries)
107
+ return (res or {}).get("count") or 0
108
+
109
+ async def replace(
110
+ self,
111
+ name: str,
112
+ entries: list[dict],
113
+ scope: dict,
114
+ *,
115
+ roles: dict | None = None,
116
+ label: str | None = None,
117
+ ) -> dict:
118
+ """Make the records in scope exactly `entries`: upsert what changed,
119
+ delete what is absent, skip what is byte-identical. Returns
120
+ ``{"put": n, "deleted": n, "skipped": n}``.
121
+
122
+ Scope is required and never inferred — pass `scope_collection()` or
123
+ `scope_group(...)`. See notes/DESIGN_COLLECTION_REPLACE.md."""
124
+ # Refused locally rather than sent: guessing between "everything I
125
+ # own here" and "the subset under this key space" is how a refresh
126
+ # silently becomes a wipe.
127
+ if not isinstance(scope, dict) or scope.get("kind") not in ("collection", "group"):
128
+ raise ValueError(
129
+ "replace: scope is required — use scope_collection() or scope_group(name)"
130
+ )
131
+ # No early return on empty `entries`: replacing with the empty set
132
+ # is how a caller CLEARS its scope.
133
+ res = await self.collection_replace(name, scope, entries, label, roles)
134
+ res = res or {}
135
+ return {
136
+ "put": res.get("put") or 0,
137
+ "deleted": res.get("deleted") or 0,
138
+ "skipped": res.get("skipped") or 0,
139
+ }
140
+
141
+ async def put_many_with_roles(self, name: str, entries: list[dict], roles: dict) -> int:
142
+ """Bulk upsert with per-payload-field display roles (field → role).
143
+ Roles persist on the collection — pass them on the first put to a
144
+ new name, then omit."""
145
+ return await self.put_many_with_display(name, entries, roles, "")
146
+
147
+ async def put_many_with_display(
148
+ self, name: str, entries: list[dict], roles: dict | None, label: str
149
+ ) -> int:
150
+ """Bulk upsert that also sets the collection's human-readable label.
151
+ Pass "" to leave the label unchanged; like roles, it persists."""
152
+ if not entries:
153
+ return 0
154
+ res = await self.collection_put(name, entries, None, label or None, roles)
155
+ return (res or {}).get("count") or 0
156
+
157
+ async def patch(self, name: str, id: str, fields: Any) -> None:
158
+ """Errors NOT_FOUND if no record with that id exists, or
159
+ OPERATION_NOT_PERMITTED on collections the state forbids patching."""
160
+ await self.collection_patch(fields, id, name)
161
+
162
+ async def delete(self, name: str, id: str) -> bool:
163
+ """Single-record delete. Returns whether the record existed."""
164
+ res = await self.collection_delete_records(name, [id])
165
+ return ((res or {}).get("deleted") or 0) > 0
166
+
167
+ async def delete_many(self, name: str, ids: list[str]) -> tuple[int, int]:
168
+ """Bulk delete. Returns (deleted, already_absent) so callers can
169
+ detect drift between their view and the platform's."""
170
+ if not ids:
171
+ return 0, 0
172
+ res = await self.collection_delete_records(name, ids)
173
+ res = res or {}
174
+ return res.get("deleted") or 0, res.get("already_absent") or 0
175
+
176
+ def subscribe(self, name: str, fn: Callable) -> None:
177
+ """Run `fn(evt)` whenever `_platform.collection.updated` fires for
178
+ this collection. The ordered pump awaits an async `fn`, so two rapid
179
+ updates cannot race. Subscriptions live for the process lifetime."""
180
+
181
+ async def _on_updated(params):
182
+ evt = params if isinstance(params, dict) else {}
183
+ if evt.get("collection") == name:
184
+ # Await, don't discard: this is what keeps wire order. Dual
185
+ # dispatch, same as any handler — a plain-`def` fn offloads.
186
+ await self._invoke(fn, evt)
187
+
188
+ self.on(EVENT_COLLECTION_UPDATED, _on_updated)
@@ -0,0 +1,97 @@
1
+ """Helpers for log-kind collections — append-only record stores declared
2
+ in the plugin manifest as `kind: "log"`. Sugar over the unified verbs
3
+ (the wire surface is collection.list / collection.fetch /
4
+ collection.delete_records; log-shaped reads are the same list with
5
+ time-window opts). Python twin of collection_log.{go,ts}."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def log_list_opts(
13
+ *,
14
+ since_ms: int | None = None,
15
+ until_ms: int | None = None,
16
+ limit: int | None = None,
17
+ cursor: str | None = None,
18
+ ) -> dict:
19
+ """Build a LogListOpts with typed scalar values — field-identical to
20
+ the unified list opts by design."""
21
+ out: dict[str, Any] = {}
22
+ if since_ms is not None:
23
+ out["since_ms"] = since_ms
24
+ if until_ms is not None:
25
+ out["until_ms"] = until_ms
26
+ if limit is not None:
27
+ out["limit"] = limit
28
+ if cursor is not None:
29
+ out["cursor"] = cursor
30
+ return out
31
+
32
+
33
+ def _record_to_log_entry(r: dict) -> dict:
34
+ """Project the unified record envelope onto the log view. Lossless: log
35
+ records carry their append time in timestamp_ms and owner in writer."""
36
+ return {
37
+ "id": r.get("id"),
38
+ "timestamp_ms": r.get("timestamp_ms"),
39
+ "payload": r.get("payload"),
40
+ "writer": r.get("writer"),
41
+ }
42
+
43
+
44
+ class CollectionLogMixin:
45
+ async def append(self, name: str, payload: Any) -> str:
46
+ """Append an entry to a log-kind collection; the actuator assigns a
47
+ ULID and timestamp. Returns the assigned entry id. Raises
48
+ RecordingDisabledError if the collection's recording flag is off."""
49
+ entry = await self.collection_append(name, payload)
50
+ if not entry:
51
+ raise RuntimeError("collection.append: actuator returned no entry")
52
+ return entry["id"]
53
+
54
+ async def append_entry(self, name: str, payload: Any) -> dict:
55
+ """Like `append` but returns the full LogEntry."""
56
+ entry = await self.collection_append(name, payload)
57
+ if not entry:
58
+ raise RuntimeError("collection.append: actuator returned no entry")
59
+ return entry
60
+
61
+ async def append_keyed(self, name: str, key: str, payload: Any) -> None:
62
+ """Annotate a keyed log (`log` preset, `id_strategy: by_field`):
63
+ appends `payload` with `key` stamped into the key field, as a fresh
64
+ append; same-key appends fold. Read the merged view with
65
+ `list_compacted`. See notes/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
66
+ await self.collection_append_keyed(key, name, payload)
67
+
68
+ async def list_log(self, name: str, opts: dict | None = None) -> list[dict]:
69
+ """List log entries newest-first."""
70
+ entries, _ = await self.list_log_page(name, opts)
71
+ return entries
72
+
73
+ async def list_log_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
74
+ """Like `list_log` but also returns the unfiltered total."""
75
+ records, total = await self.list_page(name, opts)
76
+ return [_record_to_log_entry(r) for r in records], total
77
+
78
+ async def get_log_entry(self, name: str, id: str) -> dict | None:
79
+ """One entry by id, or None. The RAW entry — on a keyed log use
80
+ `get_compacted` for a key's current state."""
81
+ rec = await self.get(name, id)
82
+ return _record_to_log_entry(rec) if rec else None
83
+
84
+ async def delete_log_entry(self, name: str, id: str) -> bool:
85
+ """Delete one entry by id. Returns whether it existed."""
86
+ return await self.delete(name, id)
87
+
88
+ async def set_collection_recording(self, name: str, enabled: bool) -> None:
89
+ """Toggle the recording flag on a log-kind collection. When False,
90
+ subsequent `append` calls raise RecordingDisabledError."""
91
+ await self.privacy_set_recording(enabled, name)
92
+
93
+ async def get_collection_recording(self, name: str) -> bool:
94
+ """The effective recording flag — the user override if set,
95
+ otherwise the manifest's `default_recording_enabled`."""
96
+ res = await self.privacy_get_recording(name)
97
+ return bool((res or {}).get("enabled", False))