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.
Files changed (80) hide show
  1. branchkit-0.6.0/PKG-INFO +301 -0
  2. branchkit-0.6.0/README.md +288 -0
  3. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/__init__.py +12 -0
  4. branchkit-0.6.0/branchkit/_pipe.py +428 -0
  5. branchkit-0.6.0/branchkit/_sandbox_tmp.py +82 -0
  6. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/actor.py +1 -1
  7. branchkit-0.6.0/branchkit/caller.py +32 -0
  8. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/closed_vocab_gen.py +70 -8
  9. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/collection.py +16 -14
  10. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/collection_log.py +10 -9
  11. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/commands.py +26 -33
  12. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/contracts_gen.py +86 -28
  13. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/correlation.py +1 -1
  14. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/debug.py +3 -3
  15. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/effects.py +5 -4
  16. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/harness.py +80 -23
  17. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/hud.py +6 -5
  18. branchkit-0.6.0/branchkit/listen.py +438 -0
  19. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/log_events_gen.py +32 -18
  20. branchkit-0.6.0/branchkit/methods_gen.py +7579 -0
  21. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/mirror.py +22 -6
  22. branchkit-0.6.0/branchkit/origin.py +59 -0
  23. branchkit-0.6.0/branchkit/output.py +34 -0
  24. branchkit-0.6.0/branchkit/pipeline/__init__.py +99 -0
  25. branchkit-0.6.0/branchkit/pipeline/audio/__init__.py +4 -0
  26. branchkit-0.6.0/branchkit/pipeline/audio/events_gen.py +110 -0
  27. branchkit-0.6.0/branchkit/pipeline/credit.py +55 -0
  28. branchkit-0.6.0/branchkit/pipeline/events_gen.py +221 -0
  29. branchkit-0.6.0/branchkit/pipeline/monitors/__init__.py +4 -0
  30. branchkit-0.6.0/branchkit/pipeline/monitors/events_gen.py +131 -0
  31. branchkit-0.6.0/branchkit/pipeline/recognition/__init__.py +4 -0
  32. branchkit-0.6.0/branchkit/pipeline/recognition/events_gen.py +128 -0
  33. branchkit-0.6.0/branchkit/pipeline/request.py +86 -0
  34. branchkit-0.6.0/branchkit/pipeline/speech.py +263 -0
  35. branchkit-0.6.0/branchkit/pipeline/stage.py +381 -0
  36. branchkit-0.6.0/branchkit/pipeline/stagelog.py +56 -0
  37. branchkit-0.6.0/branchkit/pipeline/wire.py +281 -0
  38. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/plugin.py +211 -45
  39. branchkit-0.6.0/branchkit/proxy.py +316 -0
  40. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/settings.py +4 -5
  41. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/settings_route.py +2 -2
  42. branchkit-0.6.0/branchkit/topic.py +43 -0
  43. branchkit-0.6.0/branchkit/types_gen.py +8378 -0
  44. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/ui.py +2 -1
  45. branchkit-0.6.0/branchkit.egg-info/PKG-INFO +301 -0
  46. branchkit-0.6.0/branchkit.egg-info/SOURCES.txt +69 -0
  47. {branchkit-0.3.0 → branchkit-0.6.0}/pyproject.toml +11 -2
  48. branchkit-0.6.0/tests/test_call_timeout.py +26 -0
  49. branchkit-0.6.0/tests/test_caller.py +52 -0
  50. branchkit-0.6.0/tests/test_effects.py +84 -0
  51. branchkit-0.6.0/tests/test_harness.py +110 -0
  52. branchkit-0.6.0/tests/test_hud.py +42 -0
  53. branchkit-0.6.0/tests/test_kernel.py +878 -0
  54. branchkit-0.6.0/tests/test_listen_relay.py +192 -0
  55. branchkit-0.6.0/tests/test_mirror.py +209 -0
  56. branchkit-0.6.0/tests/test_output.py +64 -0
  57. branchkit-0.6.0/tests/test_pipe.py +476 -0
  58. branchkit-0.6.0/tests/test_pipeline.py +406 -0
  59. branchkit-0.6.0/tests/test_platform.py +58 -0
  60. branchkit-0.6.0/tests/test_request.py +72 -0
  61. branchkit-0.6.0/tests/test_sandbox_tmp.py +48 -0
  62. {branchkit-0.3.0 → branchkit-0.6.0}/tests/test_settings_tabs.py +1 -1
  63. branchkit-0.6.0/tests/test_speech.py +162 -0
  64. branchkit-0.6.0/tests/test_topic.py +38 -0
  65. branchkit-0.3.0/PKG-INFO +0 -84
  66. branchkit-0.3.0/README.md +0 -71
  67. branchkit-0.3.0/branchkit/listen.py +0 -244
  68. branchkit-0.3.0/branchkit/methods_gen.py +0 -4497
  69. branchkit-0.3.0/branchkit/proxy.py +0 -157
  70. branchkit-0.3.0/branchkit/types_gen.py +0 -4853
  71. branchkit-0.3.0/branchkit.egg-info/PKG-INFO +0 -84
  72. branchkit-0.3.0/branchkit.egg-info/SOURCES.txt +0 -34
  73. branchkit-0.3.0/tests/test_kernel.py +0 -325
  74. {branchkit-0.3.0 → branchkit-0.6.0}/LICENSE +0 -0
  75. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/log.py +0 -0
  76. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit/upstream.py +0 -0
  77. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit.egg-info/dependency_links.txt +0 -0
  78. {branchkit-0.3.0 → branchkit-0.6.0}/branchkit.egg-info/top_level.txt +0 -0
  79. {branchkit-0.3.0 → branchkit-0.6.0}/setup.cfg +0 -0
  80. {branchkit-0.3.0 → branchkit-0.6.0}/tests/test_collection.py +0 -0
@@ -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