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.
- branchkit-0.1.0/LICENSE +21 -0
- branchkit-0.1.0/PKG-INFO +84 -0
- branchkit-0.1.0/README.md +71 -0
- branchkit-0.1.0/branchkit/__init__.py +87 -0
- branchkit-0.1.0/branchkit/closed_vocab_gen.py +95 -0
- branchkit-0.1.0/branchkit/collection.py +188 -0
- branchkit-0.1.0/branchkit/collection_log.py +97 -0
- branchkit-0.1.0/branchkit/commands.py +221 -0
- branchkit-0.1.0/branchkit/contracts_gen.py +666 -0
- branchkit-0.1.0/branchkit/correlation.py +28 -0
- branchkit-0.1.0/branchkit/debug.py +43 -0
- branchkit-0.1.0/branchkit/effects.py +73 -0
- branchkit-0.1.0/branchkit/harness.py +296 -0
- branchkit-0.1.0/branchkit/hud.py +18 -0
- branchkit-0.1.0/branchkit/listen.py +244 -0
- branchkit-0.1.0/branchkit/log.py +10 -0
- branchkit-0.1.0/branchkit/log_events_gen.py +138 -0
- branchkit-0.1.0/branchkit/methods_gen.py +4431 -0
- branchkit-0.1.0/branchkit/mirror.py +117 -0
- branchkit-0.1.0/branchkit/plugin.py +475 -0
- branchkit-0.1.0/branchkit/proxy.py +157 -0
- branchkit-0.1.0/branchkit/settings.py +97 -0
- branchkit-0.1.0/branchkit/settings_route.py +30 -0
- branchkit-0.1.0/branchkit/types_gen.py +4768 -0
- branchkit-0.1.0/branchkit/ui.py +149 -0
- branchkit-0.1.0/branchkit/upstream.py +65 -0
- branchkit-0.1.0/branchkit.egg-info/PKG-INFO +84 -0
- branchkit-0.1.0/branchkit.egg-info/SOURCES.txt +31 -0
- branchkit-0.1.0/branchkit.egg-info/dependency_links.txt +1 -0
- branchkit-0.1.0/branchkit.egg-info/top_level.txt +1 -0
- branchkit-0.1.0/pyproject.toml +22 -0
- branchkit-0.1.0/setup.cfg +4 -0
- branchkit-0.1.0/tests/test_kernel.py +225 -0
branchkit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
branchkit-0.1.0/PKG-INFO
ADDED
|
@@ -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))
|