branchkit 0.3.0__tar.gz → 0.6.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.6.0/PKG-INFO +301 -0
- branchkit-0.6.0/README.md +288 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/__init__.py +12 -0
- branchkit-0.6.0/branchkit/_pipe.py +428 -0
- branchkit-0.6.0/branchkit/_sandbox_tmp.py +82 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/actor.py +1 -1
- branchkit-0.6.0/branchkit/caller.py +32 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/closed_vocab_gen.py +70 -8
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/collection.py +16 -14
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/collection_log.py +10 -9
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/commands.py +26 -33
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/contracts_gen.py +86 -28
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/correlation.py +1 -1
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/debug.py +3 -3
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/effects.py +5 -4
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/harness.py +80 -23
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/hud.py +6 -5
- branchkit-0.6.0/branchkit/listen.py +438 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/log_events_gen.py +32 -18
- branchkit-0.6.0/branchkit/methods_gen.py +7579 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/mirror.py +22 -6
- branchkit-0.6.0/branchkit/origin.py +59 -0
- branchkit-0.6.0/branchkit/output.py +34 -0
- branchkit-0.6.0/branchkit/pipeline/__init__.py +99 -0
- branchkit-0.6.0/branchkit/pipeline/audio/__init__.py +4 -0
- branchkit-0.6.0/branchkit/pipeline/audio/events_gen.py +110 -0
- branchkit-0.6.0/branchkit/pipeline/credit.py +55 -0
- branchkit-0.6.0/branchkit/pipeline/events_gen.py +221 -0
- branchkit-0.6.0/branchkit/pipeline/monitors/__init__.py +4 -0
- branchkit-0.6.0/branchkit/pipeline/monitors/events_gen.py +131 -0
- branchkit-0.6.0/branchkit/pipeline/recognition/__init__.py +4 -0
- branchkit-0.6.0/branchkit/pipeline/recognition/events_gen.py +128 -0
- branchkit-0.6.0/branchkit/pipeline/request.py +86 -0
- branchkit-0.6.0/branchkit/pipeline/speech.py +263 -0
- branchkit-0.6.0/branchkit/pipeline/stage.py +381 -0
- branchkit-0.6.0/branchkit/pipeline/stagelog.py +56 -0
- branchkit-0.6.0/branchkit/pipeline/wire.py +281 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/plugin.py +211 -45
- branchkit-0.6.0/branchkit/proxy.py +316 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/settings.py +4 -5
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/settings_route.py +2 -2
- branchkit-0.6.0/branchkit/topic.py +43 -0
- branchkit-0.6.0/branchkit/types_gen.py +8378 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/ui.py +2 -1
- branchkit-0.6.0/branchkit.egg-info/PKG-INFO +301 -0
- branchkit-0.6.0/branchkit.egg-info/SOURCES.txt +69 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/pyproject.toml +11 -2
- branchkit-0.6.0/tests/test_call_timeout.py +26 -0
- branchkit-0.6.0/tests/test_caller.py +52 -0
- branchkit-0.6.0/tests/test_effects.py +84 -0
- branchkit-0.6.0/tests/test_harness.py +110 -0
- branchkit-0.6.0/tests/test_hud.py +42 -0
- branchkit-0.6.0/tests/test_kernel.py +878 -0
- branchkit-0.6.0/tests/test_listen_relay.py +192 -0
- branchkit-0.6.0/tests/test_mirror.py +209 -0
- branchkit-0.6.0/tests/test_output.py +64 -0
- branchkit-0.6.0/tests/test_pipe.py +476 -0
- branchkit-0.6.0/tests/test_pipeline.py +406 -0
- branchkit-0.6.0/tests/test_platform.py +58 -0
- branchkit-0.6.0/tests/test_request.py +72 -0
- branchkit-0.6.0/tests/test_sandbox_tmp.py +48 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/tests/test_settings_tabs.py +1 -1
- branchkit-0.6.0/tests/test_speech.py +162 -0
- branchkit-0.6.0/tests/test_topic.py +38 -0
- branchkit-0.3.0/PKG-INFO +0 -84
- branchkit-0.3.0/README.md +0 -71
- branchkit-0.3.0/branchkit/listen.py +0 -244
- branchkit-0.3.0/branchkit/methods_gen.py +0 -4497
- branchkit-0.3.0/branchkit/proxy.py +0 -157
- branchkit-0.3.0/branchkit/types_gen.py +0 -4853
- branchkit-0.3.0/branchkit.egg-info/PKG-INFO +0 -84
- branchkit-0.3.0/branchkit.egg-info/SOURCES.txt +0 -34
- branchkit-0.3.0/tests/test_kernel.py +0 -325
- {branchkit-0.3.0 → branchkit-0.6.0}/LICENSE +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/log.py +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/upstream.py +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit.egg-info/dependency_links.txt +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/branchkit.egg-info/top_level.txt +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/setup.cfg +0 -0
- {branchkit-0.3.0 → branchkit-0.6.0}/tests/test_collection.py +0 -0
branchkit-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: branchkit
|
|
3
|
+
Version: 0.6.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
|
+
BranchKit is an accessibility plugin platform for the desktop. The platform
|
|
17
|
+
loads plugins, confines each one to what it declared, tracks what is true right
|
|
18
|
+
now (which app has focus, which mode is active), and routes voice commands,
|
|
19
|
+
hotkeys and other input to whichever plugin claims them. This SDK is how a
|
|
20
|
+
Python program becomes one of those plugins. MIT licensed, standard library
|
|
21
|
+
only.
|
|
22
|
+
|
|
23
|
+
**Status:** BranchKit is pre-launch; the application is in private
|
|
24
|
+
development, and this SDK is published and usable today. Versions are 0.x, so a
|
|
25
|
+
minor release can break callers — [CHANGELOG.md](CHANGELOG.md) says what
|
|
26
|
+
changed and how to migrate. You can write and unit-test a plugin today;
|
|
27
|
+
loading it needs a BranchKit install, which is not yet publicly available.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.11 or later. A plugin carries its own copy of the SDK — a plugin runs
|
|
32
|
+
sandboxed, with no network and no pip — so install it into the plugin
|
|
33
|
+
directory:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
python3 -m pip install --target . branchkit # latest on PyPI
|
|
37
|
+
python3 -m pip install --target . git+https://github.com/branchkit/plugin-sdk-py # this repository's main
|
|
38
|
+
branchkit-cli runtime install python # once per machine
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
PyPI releases can lag this repository; the changelog says what each version
|
|
42
|
+
contains. The last command installs the CPython that BranchKit runs Python
|
|
43
|
+
plugins with, which the manifest asks for with `requires.runtimes`.
|
|
44
|
+
|
|
45
|
+
The fastest start is the scaffold, which writes a working plugin and vendors
|
|
46
|
+
the SDK for you:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
branchkit-cli dev init --name my-plugin --template py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## A minimal plugin
|
|
53
|
+
|
|
54
|
+
A plugin is a directory with a manifest, the commands it contributes, and a
|
|
55
|
+
program. This is what `dev init` writes, trimmed.
|
|
56
|
+
|
|
57
|
+
`plugin.json` declares who the plugin is, what it may do, and what it offers:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"id": "my-plugin",
|
|
62
|
+
"name": "My Plugin",
|
|
63
|
+
"version": "0.1.0",
|
|
64
|
+
"min_api_version": "0.2.0",
|
|
65
|
+
"requires": { "privileges": ["input"], "runtimes": ["python"] },
|
|
66
|
+
"run": "python3 main.py",
|
|
67
|
+
"action_prefix": "myplugin",
|
|
68
|
+
"action_types": {
|
|
69
|
+
"greet": { "label": "Greet", "fields": [{ "key": "name", "label": "Name", "field_type": "string" }] }
|
|
70
|
+
},
|
|
71
|
+
"collection_data": { "voice_commands": "commands.json" },
|
|
72
|
+
"implements": { "on_action": true }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`commands.json` maps a spoken phrase to an action:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
[
|
|
80
|
+
{
|
|
81
|
+
"pattern": ["hello", "branchkit"],
|
|
82
|
+
"action": { "type": "myplugin.greet", "params": { "name": "BranchKit" } },
|
|
83
|
+
"description": "Say Hello BranchKit"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`main.py` handles the action:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio
|
|
92
|
+
|
|
93
|
+
import branchkit
|
|
94
|
+
from actions_gen import GreetParams # generated from plugin.json by branchkit-gen
|
|
95
|
+
|
|
96
|
+
plugin = branchkit.Plugin()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@plugin.handle_action("myplugin.greet")
|
|
100
|
+
async def greet(req):
|
|
101
|
+
p: GreetParams = req["params"] or {}
|
|
102
|
+
name = p.get("name") or "BranchKit"
|
|
103
|
+
await plugin.input_type_text(text=f"Hello, {name}!")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
asyncio.run(plugin.run()) # returns when BranchKit stops the plugin
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Say "hello branchkit" and the plugin types `Hello, BranchKit!` at the cursor.
|
|
110
|
+
Typing needs the `input` privilege, which is why the manifest asks for it.
|
|
111
|
+
|
|
112
|
+
`actions_gen.py` comes from
|
|
113
|
+
[branchkit-gen](https://github.com/branchkit/branchkit-gen)
|
|
114
|
+
(`go install github.com/branchkit/branchkit-gen@latest`). It writes a
|
|
115
|
+
`TypedDict` for each entry in `action_types` and a `handle_<action>`
|
|
116
|
+
registrar, so the action string need not be spelled by hand; re-run
|
|
117
|
+
`branchkit-gen --plugin .` after editing the manifest.
|
|
118
|
+
|
|
119
|
+
Handlers may be `async def`, run on the event loop, or plain `def`, run on a
|
|
120
|
+
worker thread so a blocking body cannot stall the plugin. The generated
|
|
121
|
+
platform methods are coroutines, so a handler that calls the platform should
|
|
122
|
+
be `async def`; keep plain `def` for blocking work that does not.
|
|
123
|
+
|
|
124
|
+
## Calling the platform
|
|
125
|
+
|
|
126
|
+
Every platform method has a generated method on `Plugin`. Its arguments are
|
|
127
|
+
keyword-only, and it returns the typed result:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
rec = await plugin.collection_fetch(id=record_id, name="notes")
|
|
131
|
+
|
|
132
|
+
await plugin.hud_create_channel(channel="status", accepts_input=True)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Arguments are always named, never positional, so each one says what it is; a
|
|
136
|
+
positional call fails at once with a `TypeError`. Leave an optional argument
|
|
137
|
+
out to mean "absent". The generated methods are in
|
|
138
|
+
[branchkit/methods_gen.py](branchkit/methods_gen.py) and their result types in
|
|
139
|
+
[branchkit/types_gen.py](branchkit/types_gen.py), with the platform's own
|
|
140
|
+
description on every parameter.
|
|
141
|
+
|
|
142
|
+
**Errors** from the platform are `branchkit.RpcCallError` (`code`, `message`,
|
|
143
|
+
`kind`, `data`), and `branchkit.error_kind_of(e)` reads the kind from any
|
|
144
|
+
exception:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
try:
|
|
148
|
+
await plugin.input_type_text(text="hi")
|
|
149
|
+
except branchkit.RpcCallError as e:
|
|
150
|
+
if branchkit.error_kind_of(e) == "forbidden":
|
|
151
|
+
... # declare the privilege
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`UnsupportedError` (a method this OS does not provide) and
|
|
155
|
+
`RecordingDisabledError` are subclasses of `RpcCallError` you can catch
|
|
156
|
+
directly. A call that gets no answer in time raises `CallTimeoutError` (a
|
|
157
|
+
`TimeoutError`) instead; it is not a refusal, since the platform may have
|
|
158
|
+
carried the call out, so re-read before retrying a write.
|
|
159
|
+
|
|
160
|
+
`await plugin.call(method, params)` is the untyped escape hatch, for a method
|
|
161
|
+
too new to have a generated wrapper (`plugin.call_sync` is its blocking form
|
|
162
|
+
for a plain `def` handler). Prefer the wrapper whenever one exists.
|
|
163
|
+
|
|
164
|
+
## Permissions and the sandbox
|
|
165
|
+
|
|
166
|
+
Every plugin runs confined to what its manifest declares, and the platform,
|
|
167
|
+
not the SDK, enforces it. A plugin that cannot be sandboxed on the machine
|
|
168
|
+
does not start.
|
|
169
|
+
|
|
170
|
+
- **Privileges.** A call that needs a privilege not listed under
|
|
171
|
+
`requires.privileges` is refused before it runs, with an error of kind
|
|
172
|
+
`forbidden` naming the operation. Some privileges also ask the user the
|
|
173
|
+
first time, and the user can switch any grant off later.
|
|
174
|
+
- **Files.** The plugin reads its own directory (`branchkit.plugin_dir()`) and
|
|
175
|
+
reads and writes its own data directory (`branchkit.plugin_data_dir()`). The
|
|
176
|
+
home directory and other plugins' data are out of reach.
|
|
177
|
+
- **Network.** None unless `requires.network` asks for it: `"localhost"`, or
|
|
178
|
+
`{"hosts": ["api.example.com"]}`. Connections go through a per-plugin proxy
|
|
179
|
+
that checks each host, and the SDK routes `urllib.request` and
|
|
180
|
+
`branchkit.dial` through it for you. A host the manifest does not list, or
|
|
181
|
+
one the user has switched off, is refused with a `branchkit.HostRefusedError`.
|
|
182
|
+
|
|
183
|
+
## What the SDK covers
|
|
184
|
+
|
|
185
|
+
| Need | API |
|
|
186
|
+
|---|---|
|
|
187
|
+
| Handle an action | `@plugin.handle_action("prefix.name")` (alias `@plugin.action`), registrars from `actions_gen.py` |
|
|
188
|
+
| Serve your own method | `@plugin.handle("method")`, `plugin.handle_command` |
|
|
189
|
+
| React to events | `@plugin.on(event)`, `@plugin.on_pattern("ext.acme.**")`, `plugin.current_event_origin()`; emit with `events_emit` |
|
|
190
|
+
| Store state | `get` / `list` / `list_page` / `count` / `put` / `put_many` / `patch` / `delete` / `replace`, `subscribe` |
|
|
191
|
+
| Append-only logs | `append`, `append_keyed`, `list_log`, `get_log_entry`, `delete_log_entry` |
|
|
192
|
+
| Keep a live copy | `plugin.mirror_collection(name)`, `plugin.settings(name)` |
|
|
193
|
+
| Contribute commands | `branchkit.command(branchkit.word("open"), branchkit.capture("app", "apps")).action(…).build()`, `push_command_specs`, `push_command_group` |
|
|
194
|
+
| Bind keys and device buttons | manifest `collection_data["_platform.bindings"]`; a device plugin lists its triggers with `bindings_set_triggers`, reports presses with `bindings_report` and proposes settings from its own screen with `bindings_propose` (guides: *Triggers and authority*, *Make a device a binding source*) |
|
|
195
|
+
| A settings tab | `@plugin.settings_tab(key)` + `implements.settings_tabs` in the manifest; `post_button` / `signal_button` / `confirm_button` |
|
|
196
|
+
| Show something | `output_state(state=…)` with `say_action` / `dispatch_action`, `hud_push` |
|
|
197
|
+
| Hold a system effect | `assert_effect`, `retract_effect`, `is_effect_active`, `on_effect_displaced` |
|
|
198
|
+
| Trace a request | `plugin.current_correlation()` |
|
|
199
|
+
| Label calls made for something you host (a script, an extension) | `with branchkit.acting_for(actor):` |
|
|
200
|
+
| Find your files | `branchkit.plugin_dir()`, `branchkit.plugin_data_dir()`, `branchkit.api_version()` |
|
|
201
|
+
| Log | `await plugin.info` / `warn` / `error` / `debug` / `trace(tag, data)` to your plugin's log (debug and trace are off by default) |
|
|
202
|
+
| Outbound HTTP | `urllib.request.urlopen` (routed through the platform's proxy), `branchkit.UpstreamClient` |
|
|
203
|
+
| Raw TCP (MQTT, a local daemon) | `branchkit.dial(host, port)` |
|
|
204
|
+
| Accept local connections | `branchkit.listen_local(plugin)` with `requires.sockets.listen` |
|
|
205
|
+
| Test a plugin | `branchkit.harness.Harness` |
|
|
206
|
+
|
|
207
|
+
Package `branchkit.pipeline` is for pipeline stages (audio and monitor
|
|
208
|
+
processes on a separate wire), not for ordinary plugins.
|
|
209
|
+
|
|
210
|
+
## Testing a plugin
|
|
211
|
+
|
|
212
|
+
`Harness` loads your plugin against a simulated platform and matches phrases
|
|
213
|
+
the way the real matcher does, without audio:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
import unittest
|
|
217
|
+
|
|
218
|
+
from branchkit.harness import Harness, harness_binary_available
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@unittest.skipUnless(harness_binary_available(), "branchkit-test-harness not found")
|
|
222
|
+
class GreetTests(unittest.TestCase):
|
|
223
|
+
def test_greet_matches(self):
|
|
224
|
+
with Harness.start(".") as h:
|
|
225
|
+
result = h.must_simulate_command("hello branchkit")
|
|
226
|
+
self.assertEqual(result.action_type(), "myplugin.greet")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
It runs the `branchkit-test-harness` binary, which ships with the BranchKit app
|
|
230
|
+
beside `branchkit-cli`: inside `BranchKit.app/Contents/Resources` on macOS,
|
|
231
|
+
in `/usr/bin` with the Linux packages, and in the install folder
|
|
232
|
+
(`%LOCALAPPDATA%\Programs\BranchKit`) on Windows. Set
|
|
233
|
+
`BRANCHKIT_TEST_HARNESS` to its path anywhere else, the AppImage included. Because the app is not yet
|
|
234
|
+
publicly available, the guard above makes harness tests skip outside a
|
|
235
|
+
BranchKit install (`Harness.start` raises without the binary); set
|
|
236
|
+
`BRANCHKIT_REQUIRE_HARNESS=1` (in CI, say) to make a missing binary a failure
|
|
237
|
+
instead. `python3 -m unittest` runs your tests; `branchkit-cli dev test .`
|
|
238
|
+
checks the manifest and runs the platform's own conformance checks against the
|
|
239
|
+
plugin.
|
|
240
|
+
|
|
241
|
+
Against a running BranchKit:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
branchkit-cli plugin install . --build # install it
|
|
245
|
+
branchkit-cli dev watch . # reload on save
|
|
246
|
+
branchkit-cli dev say "hello branchkit" --simulate # match and report, execute nothing
|
|
247
|
+
branchkit-cli dev plog my-plugin --since 30s # read its log
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Learn more
|
|
251
|
+
|
|
252
|
+
- **API reference:** the docstrings on every export (`help(branchkit)`), and
|
|
253
|
+
the type hints your editor reads.
|
|
254
|
+
- **Local docs:** `branchkit-cli docs path` prints the documentation bundled
|
|
255
|
+
with your installed BranchKit, for reading or grepping offline.
|
|
256
|
+
- **Worked examples:** [helloworld-py](https://github.com/branchkit/branchkit-plugin-helloworld-py)
|
|
257
|
+
(exactly what `dev init` writes);
|
|
258
|
+
[snippets](https://github.com/branchkit/branchkit-plugin-snippets), the
|
|
259
|
+
teaching plugin; and real plugins built on the Go SDK with the same surface:
|
|
260
|
+
[keyboard](https://github.com/branchkit/branchkit-plugin-keyboard),
|
|
261
|
+
[system](https://github.com/branchkit/branchkit-plugin-system),
|
|
262
|
+
[placement](https://github.com/branchkit/branchkit-plugin-placement).
|
|
263
|
+
- **Tools:** [branchkit-cli](https://github.com/branchkit/branchkit-cli)
|
|
264
|
+
(scaffold, install, test, inspect, managed runtimes) and
|
|
265
|
+
[branchkit-gen](https://github.com/branchkit/branchkit-gen) (typed action
|
|
266
|
+
params, manifest validation).
|
|
267
|
+
|
|
268
|
+
## Versioning
|
|
269
|
+
|
|
270
|
+
Tags follow semver, 0.x for now: a minor release may break callers, and
|
|
271
|
+
CHANGELOG.md names every break with its migration. The SDK version is separate
|
|
272
|
+
from the platform contract version: the platform refuses to load a plugin whose
|
|
273
|
+
manifest `min_api_version` is newer than the contract it speaks, and
|
|
274
|
+
`branchkit.api_version()` reports that contract version at run time. The
|
|
275
|
+
contract itself changes without deprecation cycles until the first release. The
|
|
276
|
+
Go ([plugin-sdk-go](https://github.com/branchkit/plugin-sdk-go)), TypeScript
|
|
277
|
+
([plugin-sdk-ts](https://github.com/branchkit/plugin-sdk-ts)) and Python SDKs
|
|
278
|
+
implement the same surface and are held to it by one cross-language conformance
|
|
279
|
+
suite.
|
|
280
|
+
|
|
281
|
+
## Contributing
|
|
282
|
+
|
|
283
|
+
[Issues](https://github.com/branchkit/plugin-sdk-py/issues/new/choose) are welcome:
|
|
284
|
+
a bug in the SDK or its docs, or, most useful, something you tried to build
|
|
285
|
+
and couldn't, with what you needed from the platform. You don't need to know
|
|
286
|
+
how BranchKit is built to tell us that.
|
|
287
|
+
|
|
288
|
+
We don't take pull requests for code yet. Much of each SDK is generated from
|
|
289
|
+
BranchKit's platform contracts, which aren't public, and the three are kept in
|
|
290
|
+
step across Go, TypeScript and Python, so the maintainers make each change in
|
|
291
|
+
all three at once. Files ending in `_gen.py` are generated; don't edit them by hand.
|
|
292
|
+
|
|
293
|
+
Found a security problem, such as a way around the sandbox or a permission
|
|
294
|
+
check? Please [report it privately](https://github.com/branchkit/plugin-sdk-py/security/advisories/new),
|
|
295
|
+
not in a public issue.
|
|
296
|
+
|
|
297
|
+
To run this SDK's own tests:
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
python3 -m unittest discover -s tests
|
|
301
|
+
```
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# BranchKit Plugin SDK (Python)
|
|
2
|
+
|
|
3
|
+
BranchKit is an accessibility plugin platform for the desktop. The platform
|
|
4
|
+
loads plugins, confines each one to what it declared, tracks what is true right
|
|
5
|
+
now (which app has focus, which mode is active), and routes voice commands,
|
|
6
|
+
hotkeys and other input to whichever plugin claims them. This SDK is how a
|
|
7
|
+
Python program becomes one of those plugins. MIT licensed, standard library
|
|
8
|
+
only.
|
|
9
|
+
|
|
10
|
+
**Status:** BranchKit is pre-launch; the application is in private
|
|
11
|
+
development, and this SDK is published and usable today. Versions are 0.x, so a
|
|
12
|
+
minor release can break callers — [CHANGELOG.md](CHANGELOG.md) says what
|
|
13
|
+
changed and how to migrate. You can write and unit-test a plugin today;
|
|
14
|
+
loading it needs a BranchKit install, which is not yet publicly available.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Python 3.11 or later. A plugin carries its own copy of the SDK — a plugin runs
|
|
19
|
+
sandboxed, with no network and no pip — so install it into the plugin
|
|
20
|
+
directory:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
python3 -m pip install --target . branchkit # latest on PyPI
|
|
24
|
+
python3 -m pip install --target . git+https://github.com/branchkit/plugin-sdk-py # this repository's main
|
|
25
|
+
branchkit-cli runtime install python # once per machine
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
PyPI releases can lag this repository; the changelog says what each version
|
|
29
|
+
contains. The last command installs the CPython that BranchKit runs Python
|
|
30
|
+
plugins with, which the manifest asks for with `requires.runtimes`.
|
|
31
|
+
|
|
32
|
+
The fastest start is the scaffold, which writes a working plugin and vendors
|
|
33
|
+
the SDK for you:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
branchkit-cli dev init --name my-plugin --template py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## A minimal plugin
|
|
40
|
+
|
|
41
|
+
A plugin is a directory with a manifest, the commands it contributes, and a
|
|
42
|
+
program. This is what `dev init` writes, trimmed.
|
|
43
|
+
|
|
44
|
+
`plugin.json` declares who the plugin is, what it may do, and what it offers:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "my-plugin",
|
|
49
|
+
"name": "My Plugin",
|
|
50
|
+
"version": "0.1.0",
|
|
51
|
+
"min_api_version": "0.2.0",
|
|
52
|
+
"requires": { "privileges": ["input"], "runtimes": ["python"] },
|
|
53
|
+
"run": "python3 main.py",
|
|
54
|
+
"action_prefix": "myplugin",
|
|
55
|
+
"action_types": {
|
|
56
|
+
"greet": { "label": "Greet", "fields": [{ "key": "name", "label": "Name", "field_type": "string" }] }
|
|
57
|
+
},
|
|
58
|
+
"collection_data": { "voice_commands": "commands.json" },
|
|
59
|
+
"implements": { "on_action": true }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`commands.json` maps a spoken phrase to an action:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
"pattern": ["hello", "branchkit"],
|
|
69
|
+
"action": { "type": "myplugin.greet", "params": { "name": "BranchKit" } },
|
|
70
|
+
"description": "Say Hello BranchKit"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`main.py` handles the action:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
|
|
80
|
+
import branchkit
|
|
81
|
+
from actions_gen import GreetParams # generated from plugin.json by branchkit-gen
|
|
82
|
+
|
|
83
|
+
plugin = branchkit.Plugin()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@plugin.handle_action("myplugin.greet")
|
|
87
|
+
async def greet(req):
|
|
88
|
+
p: GreetParams = req["params"] or {}
|
|
89
|
+
name = p.get("name") or "BranchKit"
|
|
90
|
+
await plugin.input_type_text(text=f"Hello, {name}!")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
asyncio.run(plugin.run()) # returns when BranchKit stops the plugin
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Say "hello branchkit" and the plugin types `Hello, BranchKit!` at the cursor.
|
|
97
|
+
Typing needs the `input` privilege, which is why the manifest asks for it.
|
|
98
|
+
|
|
99
|
+
`actions_gen.py` comes from
|
|
100
|
+
[branchkit-gen](https://github.com/branchkit/branchkit-gen)
|
|
101
|
+
(`go install github.com/branchkit/branchkit-gen@latest`). It writes a
|
|
102
|
+
`TypedDict` for each entry in `action_types` and a `handle_<action>`
|
|
103
|
+
registrar, so the action string need not be spelled by hand; re-run
|
|
104
|
+
`branchkit-gen --plugin .` after editing the manifest.
|
|
105
|
+
|
|
106
|
+
Handlers may be `async def`, run on the event loop, or plain `def`, run on a
|
|
107
|
+
worker thread so a blocking body cannot stall the plugin. The generated
|
|
108
|
+
platform methods are coroutines, so a handler that calls the platform should
|
|
109
|
+
be `async def`; keep plain `def` for blocking work that does not.
|
|
110
|
+
|
|
111
|
+
## Calling the platform
|
|
112
|
+
|
|
113
|
+
Every platform method has a generated method on `Plugin`. Its arguments are
|
|
114
|
+
keyword-only, and it returns the typed result:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
rec = await plugin.collection_fetch(id=record_id, name="notes")
|
|
118
|
+
|
|
119
|
+
await plugin.hud_create_channel(channel="status", accepts_input=True)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Arguments are always named, never positional, so each one says what it is; a
|
|
123
|
+
positional call fails at once with a `TypeError`. Leave an optional argument
|
|
124
|
+
out to mean "absent". The generated methods are in
|
|
125
|
+
[branchkit/methods_gen.py](branchkit/methods_gen.py) and their result types in
|
|
126
|
+
[branchkit/types_gen.py](branchkit/types_gen.py), with the platform's own
|
|
127
|
+
description on every parameter.
|
|
128
|
+
|
|
129
|
+
**Errors** from the platform are `branchkit.RpcCallError` (`code`, `message`,
|
|
130
|
+
`kind`, `data`), and `branchkit.error_kind_of(e)` reads the kind from any
|
|
131
|
+
exception:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
try:
|
|
135
|
+
await plugin.input_type_text(text="hi")
|
|
136
|
+
except branchkit.RpcCallError as e:
|
|
137
|
+
if branchkit.error_kind_of(e) == "forbidden":
|
|
138
|
+
... # declare the privilege
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`UnsupportedError` (a method this OS does not provide) and
|
|
142
|
+
`RecordingDisabledError` are subclasses of `RpcCallError` you can catch
|
|
143
|
+
directly. A call that gets no answer in time raises `CallTimeoutError` (a
|
|
144
|
+
`TimeoutError`) instead; it is not a refusal, since the platform may have
|
|
145
|
+
carried the call out, so re-read before retrying a write.
|
|
146
|
+
|
|
147
|
+
`await plugin.call(method, params)` is the untyped escape hatch, for a method
|
|
148
|
+
too new to have a generated wrapper (`plugin.call_sync` is its blocking form
|
|
149
|
+
for a plain `def` handler). Prefer the wrapper whenever one exists.
|
|
150
|
+
|
|
151
|
+
## Permissions and the sandbox
|
|
152
|
+
|
|
153
|
+
Every plugin runs confined to what its manifest declares, and the platform,
|
|
154
|
+
not the SDK, enforces it. A plugin that cannot be sandboxed on the machine
|
|
155
|
+
does not start.
|
|
156
|
+
|
|
157
|
+
- **Privileges.** A call that needs a privilege not listed under
|
|
158
|
+
`requires.privileges` is refused before it runs, with an error of kind
|
|
159
|
+
`forbidden` naming the operation. Some privileges also ask the user the
|
|
160
|
+
first time, and the user can switch any grant off later.
|
|
161
|
+
- **Files.** The plugin reads its own directory (`branchkit.plugin_dir()`) and
|
|
162
|
+
reads and writes its own data directory (`branchkit.plugin_data_dir()`). The
|
|
163
|
+
home directory and other plugins' data are out of reach.
|
|
164
|
+
- **Network.** None unless `requires.network` asks for it: `"localhost"`, or
|
|
165
|
+
`{"hosts": ["api.example.com"]}`. Connections go through a per-plugin proxy
|
|
166
|
+
that checks each host, and the SDK routes `urllib.request` and
|
|
167
|
+
`branchkit.dial` through it for you. A host the manifest does not list, or
|
|
168
|
+
one the user has switched off, is refused with a `branchkit.HostRefusedError`.
|
|
169
|
+
|
|
170
|
+
## What the SDK covers
|
|
171
|
+
|
|
172
|
+
| Need | API |
|
|
173
|
+
|---|---|
|
|
174
|
+
| Handle an action | `@plugin.handle_action("prefix.name")` (alias `@plugin.action`), registrars from `actions_gen.py` |
|
|
175
|
+
| Serve your own method | `@plugin.handle("method")`, `plugin.handle_command` |
|
|
176
|
+
| React to events | `@plugin.on(event)`, `@plugin.on_pattern("ext.acme.**")`, `plugin.current_event_origin()`; emit with `events_emit` |
|
|
177
|
+
| Store state | `get` / `list` / `list_page` / `count` / `put` / `put_many` / `patch` / `delete` / `replace`, `subscribe` |
|
|
178
|
+
| Append-only logs | `append`, `append_keyed`, `list_log`, `get_log_entry`, `delete_log_entry` |
|
|
179
|
+
| Keep a live copy | `plugin.mirror_collection(name)`, `plugin.settings(name)` |
|
|
180
|
+
| Contribute commands | `branchkit.command(branchkit.word("open"), branchkit.capture("app", "apps")).action(…).build()`, `push_command_specs`, `push_command_group` |
|
|
181
|
+
| Bind keys and device buttons | manifest `collection_data["_platform.bindings"]`; a device plugin lists its triggers with `bindings_set_triggers`, reports presses with `bindings_report` and proposes settings from its own screen with `bindings_propose` (guides: *Triggers and authority*, *Make a device a binding source*) |
|
|
182
|
+
| A settings tab | `@plugin.settings_tab(key)` + `implements.settings_tabs` in the manifest; `post_button` / `signal_button` / `confirm_button` |
|
|
183
|
+
| Show something | `output_state(state=…)` with `say_action` / `dispatch_action`, `hud_push` |
|
|
184
|
+
| Hold a system effect | `assert_effect`, `retract_effect`, `is_effect_active`, `on_effect_displaced` |
|
|
185
|
+
| Trace a request | `plugin.current_correlation()` |
|
|
186
|
+
| Label calls made for something you host (a script, an extension) | `with branchkit.acting_for(actor):` |
|
|
187
|
+
| Find your files | `branchkit.plugin_dir()`, `branchkit.plugin_data_dir()`, `branchkit.api_version()` |
|
|
188
|
+
| Log | `await plugin.info` / `warn` / `error` / `debug` / `trace(tag, data)` to your plugin's log (debug and trace are off by default) |
|
|
189
|
+
| Outbound HTTP | `urllib.request.urlopen` (routed through the platform's proxy), `branchkit.UpstreamClient` |
|
|
190
|
+
| Raw TCP (MQTT, a local daemon) | `branchkit.dial(host, port)` |
|
|
191
|
+
| Accept local connections | `branchkit.listen_local(plugin)` with `requires.sockets.listen` |
|
|
192
|
+
| Test a plugin | `branchkit.harness.Harness` |
|
|
193
|
+
|
|
194
|
+
Package `branchkit.pipeline` is for pipeline stages (audio and monitor
|
|
195
|
+
processes on a separate wire), not for ordinary plugins.
|
|
196
|
+
|
|
197
|
+
## Testing a plugin
|
|
198
|
+
|
|
199
|
+
`Harness` loads your plugin against a simulated platform and matches phrases
|
|
200
|
+
the way the real matcher does, without audio:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import unittest
|
|
204
|
+
|
|
205
|
+
from branchkit.harness import Harness, harness_binary_available
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@unittest.skipUnless(harness_binary_available(), "branchkit-test-harness not found")
|
|
209
|
+
class GreetTests(unittest.TestCase):
|
|
210
|
+
def test_greet_matches(self):
|
|
211
|
+
with Harness.start(".") as h:
|
|
212
|
+
result = h.must_simulate_command("hello branchkit")
|
|
213
|
+
self.assertEqual(result.action_type(), "myplugin.greet")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
It runs the `branchkit-test-harness` binary, which ships with the BranchKit app
|
|
217
|
+
beside `branchkit-cli`: inside `BranchKit.app/Contents/Resources` on macOS,
|
|
218
|
+
in `/usr/bin` with the Linux packages, and in the install folder
|
|
219
|
+
(`%LOCALAPPDATA%\Programs\BranchKit`) on Windows. Set
|
|
220
|
+
`BRANCHKIT_TEST_HARNESS` to its path anywhere else, the AppImage included. Because the app is not yet
|
|
221
|
+
publicly available, the guard above makes harness tests skip outside a
|
|
222
|
+
BranchKit install (`Harness.start` raises without the binary); set
|
|
223
|
+
`BRANCHKIT_REQUIRE_HARNESS=1` (in CI, say) to make a missing binary a failure
|
|
224
|
+
instead. `python3 -m unittest` runs your tests; `branchkit-cli dev test .`
|
|
225
|
+
checks the manifest and runs the platform's own conformance checks against the
|
|
226
|
+
plugin.
|
|
227
|
+
|
|
228
|
+
Against a running BranchKit:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
branchkit-cli plugin install . --build # install it
|
|
232
|
+
branchkit-cli dev watch . # reload on save
|
|
233
|
+
branchkit-cli dev say "hello branchkit" --simulate # match and report, execute nothing
|
|
234
|
+
branchkit-cli dev plog my-plugin --since 30s # read its log
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Learn more
|
|
238
|
+
|
|
239
|
+
- **API reference:** the docstrings on every export (`help(branchkit)`), and
|
|
240
|
+
the type hints your editor reads.
|
|
241
|
+
- **Local docs:** `branchkit-cli docs path` prints the documentation bundled
|
|
242
|
+
with your installed BranchKit, for reading or grepping offline.
|
|
243
|
+
- **Worked examples:** [helloworld-py](https://github.com/branchkit/branchkit-plugin-helloworld-py)
|
|
244
|
+
(exactly what `dev init` writes);
|
|
245
|
+
[snippets](https://github.com/branchkit/branchkit-plugin-snippets), the
|
|
246
|
+
teaching plugin; and real plugins built on the Go SDK with the same surface:
|
|
247
|
+
[keyboard](https://github.com/branchkit/branchkit-plugin-keyboard),
|
|
248
|
+
[system](https://github.com/branchkit/branchkit-plugin-system),
|
|
249
|
+
[placement](https://github.com/branchkit/branchkit-plugin-placement).
|
|
250
|
+
- **Tools:** [branchkit-cli](https://github.com/branchkit/branchkit-cli)
|
|
251
|
+
(scaffold, install, test, inspect, managed runtimes) and
|
|
252
|
+
[branchkit-gen](https://github.com/branchkit/branchkit-gen) (typed action
|
|
253
|
+
params, manifest validation).
|
|
254
|
+
|
|
255
|
+
## Versioning
|
|
256
|
+
|
|
257
|
+
Tags follow semver, 0.x for now: a minor release may break callers, and
|
|
258
|
+
CHANGELOG.md names every break with its migration. The SDK version is separate
|
|
259
|
+
from the platform contract version: the platform refuses to load a plugin whose
|
|
260
|
+
manifest `min_api_version` is newer than the contract it speaks, and
|
|
261
|
+
`branchkit.api_version()` reports that contract version at run time. The
|
|
262
|
+
contract itself changes without deprecation cycles until the first release. The
|
|
263
|
+
Go ([plugin-sdk-go](https://github.com/branchkit/plugin-sdk-go)), TypeScript
|
|
264
|
+
([plugin-sdk-ts](https://github.com/branchkit/plugin-sdk-ts)) and Python SDKs
|
|
265
|
+
implement the same surface and are held to it by one cross-language conformance
|
|
266
|
+
suite.
|
|
267
|
+
|
|
268
|
+
## Contributing
|
|
269
|
+
|
|
270
|
+
[Issues](https://github.com/branchkit/plugin-sdk-py/issues/new/choose) are welcome:
|
|
271
|
+
a bug in the SDK or its docs, or, most useful, something you tried to build
|
|
272
|
+
and couldn't, with what you needed from the platform. You don't need to know
|
|
273
|
+
how BranchKit is built to tell us that.
|
|
274
|
+
|
|
275
|
+
We don't take pull requests for code yet. Much of each SDK is generated from
|
|
276
|
+
BranchKit's platform contracts, which aren't public, and the three are kept in
|
|
277
|
+
step across Go, TypeScript and Python, so the maintainers make each change in
|
|
278
|
+
all three at once. Files ending in `_gen.py` are generated; don't edit them by hand.
|
|
279
|
+
|
|
280
|
+
Found a security problem, such as a way around the sandbox or a permission
|
|
281
|
+
check? Please [report it privately](https://github.com/branchkit/plugin-sdk-py/security/advisories/new),
|
|
282
|
+
not in a public issue.
|
|
283
|
+
|
|
284
|
+
To run this SDK's own tests:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
python3 -m unittest discover -s tests
|
|
288
|
+
```
|
|
@@ -16,24 +16,33 @@ Handlers may be `async def` (run on the loop) or plain `def`
|
|
|
16
16
|
plugin). Stdlib-only by design: importing the SDK forces no third-party
|
|
17
17
|
dependency on any plugin."""
|
|
18
18
|
|
|
19
|
+
from . import _sandbox_tmp
|
|
19
20
|
from . import proxy as _proxy
|
|
20
21
|
|
|
22
|
+
# On macOS, point Apple's frameworks at the temporary directory the sandbox
|
|
23
|
+
# grants, before anything can load one (see _sandbox_tmp).
|
|
24
|
+
_sandbox_tmp.adopt()
|
|
25
|
+
|
|
21
26
|
# Route stdlib HTTP through BRANCHKIT_PROXY when sandboxed (per-host
|
|
22
27
|
# tier) — same import-time side effect as the TS SDK's entry module.
|
|
23
28
|
_proxy.install_proxy_from_env()
|
|
24
29
|
|
|
25
30
|
from .plugin import (
|
|
31
|
+
CallTimeoutError,
|
|
26
32
|
DetachedError,
|
|
27
33
|
PluginCore,
|
|
28
34
|
RecordingDisabledError,
|
|
29
35
|
RpcCallError,
|
|
36
|
+
UnsupportedError,
|
|
30
37
|
api_version,
|
|
31
38
|
error_kind_of,
|
|
39
|
+
artifacts_dir,
|
|
32
40
|
models_dir,
|
|
33
41
|
plugin_data_dir,
|
|
34
42
|
plugin_dir,
|
|
35
43
|
)
|
|
36
44
|
from .actor import acting_for, get_current_actor
|
|
45
|
+
from .origin import EventOrigin, get_current_event_origin
|
|
37
46
|
from .collection import CollectionMixin, list_opts, scope_collection, scope_group
|
|
38
47
|
from .collection_log import CollectionLogMixin, log_list_opts
|
|
39
48
|
from .debug import DebugMixin
|
|
@@ -41,6 +50,7 @@ from .effects import EffectsMixin
|
|
|
41
50
|
from .hud import HudMixin
|
|
42
51
|
from .log import log
|
|
43
52
|
from .methods_gen import MethodsMixin
|
|
53
|
+
from .caller import Caller, Client # noqa: F401 — the wrappers over any caller
|
|
44
54
|
from .mirror import CollectionMirror, MirrorMixin
|
|
45
55
|
from .settings import SettingsMirror, SettingsMixin
|
|
46
56
|
from .commands import (
|
|
@@ -69,6 +79,8 @@ from .ui import (
|
|
|
69
79
|
signal_name,
|
|
70
80
|
)
|
|
71
81
|
from .upstream import UpstreamClient, UpstreamResponse
|
|
82
|
+
from .proxy import HostRefusedError, dial
|
|
83
|
+
from .output import dispatch_action, say_action
|
|
72
84
|
from .closed_vocab_gen import * # noqa: F401,F403 — error kinds, directives, effects
|
|
73
85
|
from .contracts_gen import * # noqa: F401,F403 — method/hook/event/tag constants
|
|
74
86
|
from .log_events_gen import * # noqa: F401,F403 — observability log-event names
|