brickly-sdk 0.1.0__py3-none-any.whl

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.
brickly/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ from .api import (
2
+ BricklyRuntime,
3
+ CommandContext,
4
+ EventEnvelope,
5
+ EventsApi,
6
+ UiApi,
7
+ WebContentsApi,
8
+ WindowHandle,
9
+ )
10
+ from .errors import BppError, payload_to_error
11
+ from .protocol import PROTOCOL_VERSION, PlatformSystemPathName
12
+ from .runtime import BppTransport
13
+ from .system import ClipboardApi, PlatformApi, SystemApi
14
+
15
+ __all__ = [
16
+ "BppError",
17
+ "BppTransport",
18
+ "BricklyRuntime",
19
+ "ClipboardApi",
20
+ "CommandContext",
21
+ "EventEnvelope",
22
+ "EventsApi",
23
+ "PlatformApi",
24
+ "PROTOCOL_VERSION",
25
+ "PlatformSystemPathName",
26
+ "SystemApi",
27
+ "UiApi",
28
+ "WebContentsApi",
29
+ "WindowHandle",
30
+ "payload_to_error",
31
+ ]
brickly/api.py ADDED
@@ -0,0 +1,601 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import threading
5
+ import time
6
+ from contextvars import ContextVar
7
+ from collections import defaultdict
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any, DefaultDict, Dict, Iterator, List, Optional
11
+
12
+ from .errors import BppError
13
+ from .protocol import PROTOCOL_VERSION, BrickUiWindowOptions, InvokeStreamEvent
14
+ from .runtime import BppTransport
15
+ from .system import PlatformApi, SystemApi
16
+
17
+ CommandHandler = Callable[["CommandContext", Any], Any]
18
+ EventHandler = Callable[[Any, Dict[str, Any]], None]
19
+ _current_request_id: ContextVar[Optional[str]] = ContextVar("brickly_current_request_id", default=None)
20
+
21
+
22
+ def _current_parent_request_id() -> Optional[str]:
23
+ return _current_request_id.get()
24
+
25
+
26
+ def _require_parent_request_id(operation: str) -> str:
27
+ parent_request_id = _current_parent_request_id()
28
+ if not parent_request_id:
29
+ raise BppError(
30
+ "PARENT_INVOCATION_REQUIRED",
31
+ f"{operation} must run inside command handler; use invoke_root() for root calls",
32
+ )
33
+ return parent_request_id
34
+
35
+
36
+ def _explicit_payload_request_id(args: List[Any]) -> Optional[str]:
37
+ if len(args) < 2 or not isinstance(args[1], dict):
38
+ return None
39
+ request_id = args[1].get("requestId")
40
+ return request_id if isinstance(request_id, str) and request_id else None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class EventEnvelope:
45
+ event: str
46
+ payload: Any
47
+ source_brick_id: str
48
+ published_at: float
49
+
50
+
51
+ class CommandContext:
52
+ def __init__(self, runtime: "BricklyRuntime", request_id: str, command_id: str) -> None:
53
+ self.request_id = request_id
54
+ self.command_id = command_id
55
+ self._runtime = runtime
56
+ self._cancel_event = threading.Event()
57
+
58
+ @property
59
+ def ui(self) -> "UiApi":
60
+ return self._runtime.ui
61
+
62
+ @property
63
+ def events(self) -> "EventsApi":
64
+ return self._runtime.events
65
+
66
+ @property
67
+ def platform(self) -> PlatformApi:
68
+ return self._runtime.platform
69
+
70
+ @property
71
+ def system(self) -> SystemApi:
72
+ return self._runtime.system
73
+
74
+ @property
75
+ def config(self) -> Dict[str, Any]:
76
+ return self._runtime.config
77
+
78
+ @property
79
+ def cancel_event(self) -> threading.Event:
80
+ return self._cancel_event
81
+
82
+ def is_cancelled(self) -> bool:
83
+ return self._cancel_event.is_set()
84
+
85
+ def on_cancel(self, handler: Callable[[], None]) -> None:
86
+ self._runtime._set_cancel_handler(self.request_id, handler)
87
+ if self.is_cancelled():
88
+ handler()
89
+
90
+ def progress(self, value: float, message: Optional[str] = None) -> None:
91
+ payload: Dict[str, Any] = {"type": "command.progress", "id": self.request_id, "progress": value}
92
+ if message:
93
+ payload["message"] = message
94
+ self._runtime.transport.send(payload)
95
+
96
+ def chunk(self, chunk: Any, name: Optional[str] = None) -> None:
97
+ payload: Dict[str, Any] = {"type": "command.chunk", "id": self.request_id, "chunk": chunk}
98
+ if name:
99
+ payload["name"] = name
100
+ self._runtime.transport.send(payload)
101
+
102
+ def output(self, name: str, value: Any) -> None:
103
+ self._runtime.transport.send({"type": "command.output", "id": self.request_id, "name": name, "value": value})
104
+
105
+ def invoke(
106
+ self,
107
+ brick_id: str,
108
+ command_id: str,
109
+ input_value: Any = None,
110
+ profile_id: Optional[str] = None,
111
+ ) -> Any:
112
+ return self._runtime.invoke(brick_id, command_id, input_value, profile_id)
113
+
114
+ def invoke_stream(
115
+ self,
116
+ brick_id: str,
117
+ command_id: str,
118
+ input_value: Any = None,
119
+ profile_id: Optional[str] = None,
120
+ ) -> Iterator[InvokeStreamEvent]:
121
+ return self._runtime.invoke_stream(brick_id, command_id, input_value, profile_id)
122
+
123
+ def open_session(
124
+ self,
125
+ brick_id: str,
126
+ profile_id: Optional[str] = None,
127
+ ) -> "BrickSession":
128
+ return self._runtime.open_session(brick_id, profile_id)
129
+
130
+ def _cancel(self) -> None:
131
+ self._cancel_event.set()
132
+
133
+
134
+ class WebContentsApi:
135
+ def __init__(self, window: "WindowHandle") -> None:
136
+ self._window = window
137
+
138
+ def send(self, channel: str, *args: Any) -> None:
139
+ self._window.call("webContents.send", [channel, *args])
140
+
141
+ def execute_javascript(self, code: str, user_gesture: Optional[bool] = None) -> Any:
142
+ args: List[Any] = [code] if user_gesture is None else [code, user_gesture]
143
+ return self._window.call("webContents.executeJavaScript", args)
144
+
145
+ def open_dev_tools(self, options: Optional[Dict[str, Any]] = None) -> None:
146
+ self._window.call("webContents.openDevTools", [] if options is None else [options])
147
+
148
+ def close_dev_tools(self) -> None:
149
+ self._window.call("webContents.closeDevTools")
150
+
151
+ def toggle_dev_tools(self) -> None:
152
+ self._window.call("webContents.toggleDevTools")
153
+
154
+ def is_dev_tools_opened(self) -> bool:
155
+ return bool(self._window.call("webContents.isDevToolsOpened"))
156
+
157
+ def get_url(self) -> str:
158
+ return str(self._window.call("webContents.getURL"))
159
+
160
+ def get_title(self) -> str:
161
+ return str(self._window.call("webContents.getTitle"))
162
+
163
+
164
+ class WindowHandle:
165
+ def __init__(self, runtime: "BricklyRuntime", window_id: int) -> None:
166
+ self._runtime = runtime
167
+ self.id = window_id
168
+ self.closed = False
169
+ self._listeners: DefaultDict[str, List[Callable[[Any], None]]] = defaultdict(list)
170
+ self.web_contents = WebContentsApi(self)
171
+
172
+ def on(self, event: str, handler: Callable[[Any], None]) -> Callable[[], None]:
173
+ self._listeners[event].append(handler)
174
+
175
+ def off() -> None:
176
+ try:
177
+ self._listeners[event].remove(handler)
178
+ except ValueError:
179
+ pass
180
+
181
+ return off
182
+
183
+ def call(self, method: str, args: Optional[List[Any]] = None) -> Any:
184
+ if self.closed and method != "isDestroyed":
185
+ raise BppError("INVALID_INPUT", f"Window {self.id} already closed")
186
+ payload: Dict[str, Any] = {"type": "host.ui.callWindow", "windowId": self.id, "method": method, "args": args or []}
187
+ parent_request_id = _current_parent_request_id()
188
+ if method == "webContents.send" and not parent_request_id:
189
+ parent_request_id = _explicit_payload_request_id(args or [])
190
+ if method == "webContents.send" and not parent_request_id:
191
+ raise BppError(
192
+ "PARENT_INVOCATION_REQUIRED",
193
+ "web_contents.send() must run inside command handler or include payload.requestId",
194
+ )
195
+ if method == "webContents.send":
196
+ payload["parentRequestId"] = parent_request_id
197
+ return self._runtime.transport.host_call(
198
+ payload
199
+ )
200
+
201
+ def close(self) -> None:
202
+ if self.closed:
203
+ return
204
+ self.closed = True
205
+ self._runtime.transport.host_call({"type": "host.ui.closeWindow", "windowId": self.id})
206
+
207
+ def emit(self, event: str, payload: Any) -> None:
208
+ if event == "closed":
209
+ self.closed = True
210
+ self._runtime._windows.pop(self.id, None)
211
+ for handler in list(self._listeners.get(event, [])):
212
+ handler(payload)
213
+
214
+ def set_bounds(self, bounds: Dict[str, Any]) -> None:
215
+ self.call("setBounds", [bounds])
216
+
217
+ def get_bounds(self) -> Any:
218
+ return self.call("getBounds")
219
+
220
+ def set_position(self, x: int, y: int) -> None:
221
+ self.call("setPosition", [x, y])
222
+
223
+ def get_position(self) -> Any:
224
+ return self.call("getPosition")
225
+
226
+ def set_size(self, width: int, height: int) -> None:
227
+ self.call("setSize", [width, height])
228
+
229
+ def get_size(self) -> Any:
230
+ return self.call("getSize")
231
+
232
+ def set_opacity(self, opacity: float) -> None:
233
+ self.call("setOpacity", [opacity])
234
+
235
+ def get_opacity(self) -> float:
236
+ return float(self.call("getOpacity"))
237
+
238
+ def set_always_on_top(self, flag: bool, level: Optional[str] = None) -> None:
239
+ self.call("setAlwaysOnTop", [flag] if level is None else [flag, level])
240
+
241
+ def set_ignore_mouse_events(self, ignore: bool, options: Optional[Dict[str, Any]] = None) -> None:
242
+ self.call("setIgnoreMouseEvents", [ignore] if options is None else [ignore, options])
243
+
244
+ def minimize(self) -> None:
245
+ self.call("minimize")
246
+
247
+ def maximize(self) -> None:
248
+ self.call("maximize")
249
+
250
+ def restore(self) -> None:
251
+ self.call("restore")
252
+
253
+ def hide(self) -> None:
254
+ self.call("hide")
255
+
256
+ def show(self) -> None:
257
+ self.call("show")
258
+
259
+ def focus(self) -> None:
260
+ self.call("focus")
261
+
262
+ def load_url(self, url: str, options: Optional[Dict[str, Any]] = None) -> None:
263
+ self.call("loadURL", [url] if options is None else [url, options])
264
+
265
+ def load_file(self, file_path: str, options: Optional[Dict[str, Any]] = None) -> None:
266
+ self.call("loadFile", [file_path] if options is None else [file_path, options])
267
+
268
+ def reload(self) -> None:
269
+ self.call("reload")
270
+
271
+ def is_destroyed(self) -> bool:
272
+ return bool(self.call("isDestroyed"))
273
+
274
+
275
+ class UiApi:
276
+ def __init__(self, runtime: "BricklyRuntime") -> None:
277
+ self._runtime = runtime
278
+
279
+ def create_browser_window(self, url: str, options: Optional[BrickUiWindowOptions] = None) -> WindowHandle:
280
+ result = self._runtime.transport.host_call(
281
+ {"type": "host.ui.createBrowserWindow", "url": url, "options": options or {}}
282
+ )
283
+ window_id = int(result.get("windowId")) if isinstance(result, dict) else int(result)
284
+ handle = WindowHandle(self._runtime, window_id)
285
+ self._runtime._windows[window_id] = handle
286
+ return handle
287
+
288
+ def list_windows(self) -> Any:
289
+ return self._runtime.transport.host_call({"type": "host.ui.listWindows"})
290
+
291
+
292
+ class EventsApi:
293
+ def __init__(self, runtime: "BricklyRuntime") -> None:
294
+ self._runtime = runtime
295
+
296
+ def on(self, event: str, handler: EventHandler) -> Callable[[], None]:
297
+ self._runtime._event_listeners[event].append(handler)
298
+
299
+ def off() -> None:
300
+ try:
301
+ self._runtime._event_listeners[event].remove(handler)
302
+ except ValueError:
303
+ pass
304
+
305
+ return off
306
+
307
+ def publish(self, event: str, payload: Optional[Any] = None) -> None:
308
+ message: Dict[str, Any] = {"type": "host.event.publish", "event": event}
309
+ if payload is not None:
310
+ message["payload"] = payload
311
+ self._runtime.transport.host_call(message)
312
+
313
+
314
+ class BrickSession:
315
+ def __init__(
316
+ self,
317
+ runtime: "BricklyRuntime",
318
+ session_id: str,
319
+ brick_id: str,
320
+ profile_id: Optional[str] = None,
321
+ ) -> None:
322
+ self._runtime = runtime
323
+ self.id = session_id
324
+ self.brick_id = brick_id
325
+ self.profile_id = profile_id
326
+
327
+ def invoke(self, command_id: str, input_value: Any = None) -> Any:
328
+ parent_request_id = _require_parent_request_id("session.invoke()")
329
+ return self._runtime.transport.host_call(
330
+ {
331
+ "type": "host.session.invoke",
332
+ "sessionId": self.id,
333
+ "commandId": command_id,
334
+ "input": input_value,
335
+ "parentRequestId": parent_request_id,
336
+ }
337
+ )
338
+
339
+ def close(self) -> None:
340
+ self._runtime.transport.host_call({"type": "host.session.close", "sessionId": self.id})
341
+
342
+
343
+ class BricklyRuntime:
344
+ def __init__(
345
+ self,
346
+ brick_id: str,
347
+ protocol_version: str = PROTOCOL_VERSION,
348
+ transport: Optional[BppTransport] = None,
349
+ ) -> None:
350
+ self.brick_id = brick_id
351
+ self.protocol_version = protocol_version
352
+ self.transport = transport or BppTransport(brick_id)
353
+ self.ui = UiApi(self)
354
+ self.events = EventsApi(self)
355
+ self.platform = PlatformApi(self)
356
+ self.system = self.platform.system
357
+ self.config: Dict[str, Any] = {}
358
+ self._command_handlers: Dict[str, CommandHandler] = {}
359
+ self._cancel_handlers: Dict[str, Callable[[], None]] = {}
360
+ self._contexts: Dict[str, CommandContext] = {}
361
+ self._event_listeners: DefaultDict[str, List[EventHandler]] = defaultdict(list)
362
+ self._windows: Dict[int, WindowHandle] = {}
363
+ self._ready_handler: Optional[Callable[[], Any]] = None
364
+ self._shutdown_handler: Optional[Callable[[], Any]] = None
365
+ self._started = False
366
+ self._done = threading.Event()
367
+ self.transport.on("command", self._handle_command)
368
+ self.transport.on("event", self._handle_event)
369
+ self.transport.on("message", self._handle_message)
370
+ self.transport.on("shutdown", self._handle_shutdown)
371
+ self.transport.on("end", self._done.set)
372
+ self.transport.on("error", lambda error: self.transport.log("transport error:", repr(error)))
373
+
374
+ def on_command(self, command_id: str, handler: Optional[CommandHandler] = None):
375
+ def decorator(fn: CommandHandler) -> CommandHandler:
376
+ self._command_handlers[command_id] = fn
377
+ return fn
378
+
379
+ if handler is not None:
380
+ return decorator(handler)
381
+ return decorator
382
+
383
+ def on_ready(self, handler: Callable[[], Any]) -> Callable[[], Any]:
384
+ self._ready_handler = handler
385
+ return handler
386
+
387
+ def on_shutdown(self, handler: Callable[[], Any]) -> Callable[[], Any]:
388
+ self._shutdown_handler = handler
389
+ return handler
390
+
391
+ def start(self, block: bool = True) -> "BricklyRuntime":
392
+ if self._started:
393
+ return self
394
+ self._started = True
395
+ self.transport.start()
396
+ if block:
397
+ self._done.wait()
398
+ return self
399
+
400
+ run = start
401
+
402
+ def stop(self) -> None:
403
+ self.transport.stop("brick stopped")
404
+ self._done.set()
405
+
406
+ def log(self, *parts: Any) -> None:
407
+ self.transport.log(*parts)
408
+
409
+ def invoke(
410
+ self,
411
+ brick_id: str,
412
+ command_id: str,
413
+ input_value: Any = None,
414
+ profile_id: Optional[str] = None,
415
+ ) -> Any:
416
+ parent_request_id = _require_parent_request_id("invoke()")
417
+ message: Dict[str, Any] = {
418
+ "type": "host.invoke",
419
+ "brickId": brick_id,
420
+ "commandId": command_id,
421
+ "input": input_value,
422
+ "parentRequestId": parent_request_id,
423
+ }
424
+ if profile_id:
425
+ message["profileId"] = profile_id
426
+ return self.transport.host_call(message)
427
+
428
+ def invoke_root(
429
+ self,
430
+ brick_id: str,
431
+ command_id: str,
432
+ input_value: Any = None,
433
+ profile_id: Optional[str] = None,
434
+ ) -> Any:
435
+ message: Dict[str, Any] = {
436
+ "type": "host.invokeRoot",
437
+ "brickId": brick_id,
438
+ "commandId": command_id,
439
+ "input": input_value,
440
+ }
441
+ if profile_id:
442
+ message["profileId"] = profile_id
443
+ return self.transport.host_call(message)
444
+
445
+ def invoke_stream(
446
+ self,
447
+ brick_id: str,
448
+ command_id: str,
449
+ input_value: Any = None,
450
+ profile_id: Optional[str] = None,
451
+ ) -> Iterator[InvokeStreamEvent]:
452
+ def stream() -> Iterator[InvokeStreamEvent]:
453
+ parent_request_id = _require_parent_request_id("invoke_stream()")
454
+ message: Dict[str, Any] = {
455
+ "type": "host.invoke",
456
+ "brickId": brick_id,
457
+ "commandId": command_id,
458
+ "input": input_value,
459
+ "stream": True,
460
+ "parentRequestId": parent_request_id,
461
+ }
462
+ if profile_id:
463
+ message["profileId"] = profile_id
464
+ yield from self.transport.host_call_stream(message)
465
+
466
+ return stream()
467
+
468
+ def open_session(
469
+ self,
470
+ brick_id: str,
471
+ profile_id: Optional[str] = None,
472
+ ) -> BrickSession:
473
+ message: Dict[str, Any] = {"type": "host.session.open", "brickId": brick_id}
474
+ if profile_id:
475
+ message["profileId"] = profile_id
476
+ result = self.transport.host_call(message)
477
+ if not isinstance(result, dict):
478
+ raise BppError("PROTOCOL_ERROR", "host.session.open returned invalid result")
479
+ return BrickSession(
480
+ self,
481
+ str(result.get("sessionId") or ""),
482
+ str(result.get("brickId") or brick_id),
483
+ str(result["profileId"]) if result.get("profileId") else None,
484
+ )
485
+
486
+ def _handle_message(self, message: Dict[str, Any]) -> None:
487
+ if message.get("type") != "host.hello":
488
+ return
489
+ config = message.get("config")
490
+ self.config = config if isinstance(config, dict) else {}
491
+ self.transport.send(
492
+ {"type": "runtime.ready", "protocolVersion": self.protocol_version, "brickId": self.brick_id}
493
+ )
494
+ if self._ready_handler:
495
+ threading.Thread(target=self._run_ready, name="brickly-ready", daemon=True).start()
496
+
497
+ def _run_ready(self) -> None:
498
+ try:
499
+ assert self._ready_handler is not None
500
+ self._ready_handler()
501
+ except BaseException as error: # noqa: BLE001
502
+ self.transport.log("on_ready error:", BppError.from_exception(error).message)
503
+
504
+ def _handle_command(self, message: Dict[str, Any]) -> None:
505
+ msg_type = message.get("type")
506
+ if msg_type == "command.cancel":
507
+ req_id = str(message.get("id") or "")
508
+ ctx = self._contexts.get(req_id)
509
+ if ctx:
510
+ ctx._cancel()
511
+ handler = self._cancel_handlers.get(req_id)
512
+ if handler:
513
+ try:
514
+ handler()
515
+ except BaseException as error: # noqa: BLE001
516
+ self.transport.log("cancel handler error:", repr(error))
517
+ return
518
+ if msg_type != "command.invoke":
519
+ return
520
+ req_id = str(message.get("id") or "")
521
+ command_id = str(message.get("commandId") or "")
522
+ handler = self._command_handlers.get(command_id)
523
+ if handler is None:
524
+ self.transport.send(
525
+ {
526
+ "type": "command.error",
527
+ "id": req_id,
528
+ "error": {"code": "COMMAND_NOT_FOUND", "message": f"Unknown command: {command_id}"},
529
+ }
530
+ )
531
+ return
532
+ ctx = CommandContext(self, req_id, command_id)
533
+ self._contexts[req_id] = ctx
534
+ thread = threading.Thread(
535
+ target=self._run_command,
536
+ args=(handler, ctx, message.get("input")),
537
+ name=f"brickly-command-{command_id}",
538
+ daemon=True,
539
+ )
540
+ thread.start()
541
+
542
+ def _run_command(self, handler: CommandHandler, ctx: CommandContext, input_value: Any) -> None:
543
+ token = _current_request_id.set(ctx.request_id)
544
+ try:
545
+ result = handler(ctx, input_value)
546
+ self.transport.send({"type": "command.result", "id": ctx.request_id, "result": result})
547
+ except BaseException as error: # noqa: BLE001
548
+ self.transport.send(
549
+ {"type": "command.error", "id": ctx.request_id, "error": BppError.from_exception(error).to_json()}
550
+ )
551
+ finally:
552
+ _current_request_id.reset(token)
553
+ self._contexts.pop(ctx.request_id, None)
554
+ self._cancel_handlers.pop(ctx.request_id, None)
555
+
556
+ def _handle_event(self, message: Dict[str, Any]) -> None:
557
+ event = str(message.get("event") or "")
558
+ payload = message.get("payload")
559
+ if event.startswith("window.") and isinstance(payload, dict):
560
+ window_id = payload.get("windowId")
561
+ if isinstance(window_id, (int, float)):
562
+ handle = self._windows.get(int(window_id))
563
+ if handle:
564
+ handle.emit(event.removeprefix("window."), payload)
565
+
566
+ envelope = {
567
+ "event": event,
568
+ "payload": payload,
569
+ "sourceBrickId": message.get("sourceBrickId", ""),
570
+ "publishedAt": message.get("publishedAt", 0),
571
+ }
572
+ for handler in list(self._event_listeners.get(event, [])):
573
+ threading.Thread(
574
+ target=self._run_event_handler,
575
+ args=(handler, payload, envelope),
576
+ name=f"brickly-event-{event}",
577
+ daemon=True,
578
+ ).start()
579
+
580
+ def _handle_shutdown(self) -> None:
581
+ threading.Thread(target=self._run_shutdown, name="brickly-shutdown", daemon=True).start()
582
+
583
+ def _run_event_handler(self, handler: EventHandler, payload: Any, envelope: Dict[str, Any]) -> None:
584
+ try:
585
+ handler(payload, envelope)
586
+ except BaseException as error: # noqa: BLE001
587
+ self.transport.log("event handler error:", repr(error))
588
+
589
+ def _run_shutdown(self) -> None:
590
+ try:
591
+ if self._shutdown_handler:
592
+ self._shutdown_handler()
593
+ except BaseException as error: # noqa: BLE001
594
+ self.transport.log("shutdown handler error:", repr(error))
595
+ self.transport.send({"type": "runtime.bye"})
596
+ time.sleep(0.05)
597
+ self._done.set()
598
+ sys.exit(0)
599
+
600
+ def _set_cancel_handler(self, req_id: str, handler: Callable[[], None]) -> None:
601
+ self._cancel_handlers[req_id] = handler
brickly/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class BppError(Exception):
7
+ """SDK error type that serializes directly to BPP error payloads."""
8
+
9
+ def __init__(self, code: str, message: str, details: Optional[Any] = None) -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.message = message
13
+ self.details = details
14
+
15
+ def to_json(self) -> Dict[str, Any]:
16
+ payload: Dict[str, Any] = {"code": self.code, "message": self.message}
17
+ if self.details is not None:
18
+ payload["details"] = self.details
19
+ return payload
20
+
21
+ @classmethod
22
+ def from_exception(cls, error: BaseException) -> "BppError":
23
+ if isinstance(error, BppError):
24
+ return error
25
+ return cls("INTERNAL_ERROR", str(error) or error.__class__.__name__)
26
+
27
+
28
+ def payload_to_error(payload: Optional[Dict[str, Any]]) -> BppError:
29
+ if not payload:
30
+ return BppError("INTERNAL_ERROR", "Unknown BPP error")
31
+ return BppError(
32
+ str(payload.get("code") or "INTERNAL_ERROR"),
33
+ str(payload.get("message") or "Unknown BPP error"),
34
+ payload.get("details"),
35
+ )
36
+
brickly/protocol.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Literal, TypedDict
4
+
5
+ PROTOCOL_VERSION = "0.1.0"
6
+
7
+ BridgeErrorCode = str
8
+
9
+
10
+ class BridgeErrorPayload(TypedDict, total=False):
11
+ code: BridgeErrorCode
12
+ message: str
13
+ details: Any
14
+
15
+
16
+ BppMessage = Dict[str, Any]
17
+ BrickUiWindowOptions = Dict[str, Any]
18
+ BrickWindowMethod = str
19
+ ClipboardContent = Any
20
+ ClipboardReadResult = Dict[str, Any]
21
+ ClipboardSetResult = Dict[str, Any]
22
+ HostResourceRegisterPayload = Dict[str, Any]
23
+ EventName = str
24
+ InvokeStreamEvent = Dict[str, Any]
25
+ CommandMessageType = Literal["command.invoke", "command.cancel"]
26
+ PlatformSystemPathName = Literal[
27
+ "home",
28
+ "appData",
29
+ "assets",
30
+ "userData",
31
+ "sessionData",
32
+ "temp",
33
+ "exe",
34
+ "module",
35
+ "desktop",
36
+ "documents",
37
+ "downloads",
38
+ "music",
39
+ "pictures",
40
+ "videos",
41
+ "recent",
42
+ "logs",
43
+ "crashDumps",
44
+ ]
brickly/runtime.py ADDED
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import itertools
5
+ import json
6
+ import os
7
+ import queue
8
+ import sys
9
+ import threading
10
+ from collections import defaultdict
11
+ from collections.abc import Callable
12
+ from typing import Any, DefaultDict, Dict, Iterator, List, Optional, TextIO, Tuple
13
+
14
+ from .errors import BppError, payload_to_error
15
+
16
+
17
+ MessageHandler = Callable[[Dict[str, Any]], None]
18
+ VoidHandler = Callable[[], None]
19
+ ErrorHandler = Callable[[BaseException], None]
20
+ DEFAULT_HOST_CALL_TIMEOUT_SECONDS = 10.0
21
+
22
+
23
+ def _ensure_utf8_stdio() -> None:
24
+ """Re-open stdout/stderr with UTF-8 encoding so Chinese text survives pipe transport on Windows.
25
+
26
+ Uses surrogatepass so lone surrogates (from malformed data) are encoded into their
27
+ well-defined UTF-8 byte sequences instead of crashing the write. V8's UTF-8 decoder
28
+ handles these gracefully, and JavaScript strings natively support lone surrogates."""
29
+ for attr in ('stdout', 'stderr'):
30
+ stream = getattr(sys, attr)
31
+ if (
32
+ hasattr(stream, 'buffer')
33
+ and hasattr(stream, 'encoding')
34
+ and stream.encoding is not None
35
+ and stream.encoding.lower() != 'utf-8'
36
+ ):
37
+ try:
38
+ setattr(
39
+ sys,
40
+ attr,
41
+ io.TextIOWrapper(stream.buffer, encoding='utf-8', errors='surrogatepass'),
42
+ )
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ _ensure_utf8_stdio()
48
+
49
+
50
+ class BppTransport:
51
+ """NDJSON-over-stdio transport for Brickly brick runtimes."""
52
+
53
+ def __init__(
54
+ self,
55
+ brick_id: str,
56
+ stdin: Optional[TextIO] = None,
57
+ stdout: Optional[TextIO] = None,
58
+ stderr: Optional[TextIO] = None,
59
+ ) -> None:
60
+ self.brick_id = brick_id
61
+ self.stdin = stdin or sys.stdin
62
+ self.stdout = stdout or sys.stdout
63
+ self.stderr = stderr or sys.stderr
64
+ self._id_counter = itertools.count(1)
65
+ self._stdout_lock = threading.Lock()
66
+ self._pending: Dict[str, queue.Queue[Tuple[bool, Any]]] = {}
67
+ self._stream_pending: Dict[str, queue.Queue[Tuple[str, Any]]] = {}
68
+ self._pending_lock = threading.Lock()
69
+ self._handlers: DefaultDict[str, List[Callable[..., None]]] = defaultdict(list)
70
+ self._started = False
71
+ self._stop_event = threading.Event()
72
+ self._reader_thread: Optional[threading.Thread] = None
73
+
74
+ def on(self, event: str, handler: Callable[..., None]) -> None:
75
+ self._handlers[event].append(handler)
76
+
77
+ def emit(self, event: str, *args: Any) -> None:
78
+ for handler in list(self._handlers.get(event, [])):
79
+ try:
80
+ handler(*args)
81
+ except BaseException as error: # noqa: BLE001
82
+ self.log("handler error:", repr(error))
83
+
84
+ def start(self) -> None:
85
+ if self._started:
86
+ return
87
+ self._started = True
88
+ self._reader_thread = threading.Thread(target=self._read_loop, name="brickly-bpp-reader", daemon=True)
89
+ self._reader_thread.start()
90
+
91
+ def stop(self, reason: str = "transport stopped") -> None:
92
+ if not self._started:
93
+ return
94
+ self._started = False
95
+ self._stop_event.set()
96
+ with self._pending_lock:
97
+ pending = list(self._pending.values())
98
+ self._pending.clear()
99
+ stream_pending = list(self._stream_pending.values())
100
+ self._stream_pending.clear()
101
+ error = BppError("PROCESS_EXITED", reason)
102
+ for waiter in pending:
103
+ waiter.put((False, error))
104
+ for waiter in stream_pending:
105
+ waiter.put(("error", error))
106
+
107
+ def wait(self) -> None:
108
+ if self._reader_thread:
109
+ self._reader_thread.join()
110
+
111
+ def send(self, message: Dict[str, Any]) -> None:
112
+ line = json.dumps(message, ensure_ascii=False, default=str) + "\n"
113
+ with self._stdout_lock:
114
+ self.stdout.write(line)
115
+ self.stdout.flush()
116
+
117
+ def log(self, *parts: Any) -> None:
118
+ rendered = " ".join(part if isinstance(part, str) else json.dumps(part, ensure_ascii=False, default=str) for part in parts)
119
+ self.stderr.write(f"{rendered}\n")
120
+ self.stderr.flush()
121
+
122
+ def host_call(self, message: Dict[str, Any], timeout: Optional[float] = DEFAULT_HOST_CALL_TIMEOUT_SECONDS) -> Any:
123
+ req_id = self._next_id()
124
+ waiter: queue.Queue[Tuple[bool, Any]] = queue.Queue(maxsize=1)
125
+ with self._pending_lock:
126
+ self._pending[req_id] = waiter
127
+ self.send({**message, "id": req_id})
128
+ try:
129
+ ok, value = waiter.get(timeout=timeout)
130
+ except queue.Empty as exc:
131
+ with self._pending_lock:
132
+ self._pending.pop(req_id, None)
133
+ raise BppError("TIMEOUT", f"Timed out waiting for host response: {message.get('type')}") from exc
134
+ if ok:
135
+ return value
136
+ raise value
137
+
138
+ def host_call_stream(
139
+ self,
140
+ message: Dict[str, Any],
141
+ timeout: Optional[float] = DEFAULT_HOST_CALL_TIMEOUT_SECONDS,
142
+ ) -> Iterator[Dict[str, Any]]:
143
+ req_id = self._next_id()
144
+ waiter: queue.Queue[Tuple[str, Any]] = queue.Queue()
145
+ with self._pending_lock:
146
+ self._stream_pending[req_id] = waiter
147
+ self.send({**message, "id": req_id})
148
+ try:
149
+ while True:
150
+ try:
151
+ kind, value = waiter.get(timeout=timeout)
152
+ except queue.Empty as exc:
153
+ with self._pending_lock:
154
+ self._stream_pending.pop(req_id, None)
155
+ raise BppError("TIMEOUT", f"Timed out waiting for host response: {message.get('type')}") from exc
156
+ if kind == "error":
157
+ raise value
158
+ yield value
159
+ if kind == "result":
160
+ break
161
+ finally:
162
+ with self._pending_lock:
163
+ self._stream_pending.pop(req_id, None)
164
+
165
+ def _next_id(self) -> str:
166
+ return f"{self.brick_id}-{os.getpid()}-{next(self._id_counter)}"
167
+
168
+ def _read_loop(self) -> None:
169
+ try:
170
+ for raw in self.stdin:
171
+ if self._stop_event.is_set():
172
+ break
173
+ line = raw.strip()
174
+ if not line:
175
+ continue
176
+ try:
177
+ message = json.loads(line)
178
+ except json.JSONDecodeError as exc:
179
+ self.emit("error", BppError("PROTOCOL_ERROR", f"Invalid JSON line: {exc}"))
180
+ continue
181
+ if isinstance(message, dict):
182
+ self._dispatch(message)
183
+ finally:
184
+ self.emit("end")
185
+
186
+ def _dispatch(self, message: Dict[str, Any]) -> None:
187
+ msg_type = message.get("type")
188
+ if msg_type in ("host.invoke.progress", "host.invoke.chunk", "host.invoke.output"):
189
+ self._resolve_stream_event(str(message.get("id", "")), message)
190
+ return
191
+ if msg_type == "host.result":
192
+ if self._resolve_stream_result(str(message.get("id", "")), True, message.get("result")):
193
+ return
194
+ self._resolve_pending(str(message.get("id", "")), True, message.get("result"))
195
+ return
196
+ if msg_type == "host.error":
197
+ if self._resolve_stream_result(
198
+ str(message.get("id", "")),
199
+ False,
200
+ payload_to_error(message.get("error")),
201
+ ):
202
+ return
203
+ self._resolve_pending(str(message.get("id", "")), False, payload_to_error(message.get("error")))
204
+ return
205
+ if msg_type == "runtime.ping":
206
+ self.send({"type": "runtime.pong", "id": message.get("id", "")})
207
+ return
208
+ if msg_type == "runtime.shutdown":
209
+ self.emit("shutdown")
210
+ return
211
+ if msg_type == "event.notify":
212
+ self.emit("event", message)
213
+ self.emit("message", message)
214
+ return
215
+ if msg_type in ("command.invoke", "command.cancel"):
216
+ self.emit("command", message)
217
+ self.emit("message", message)
218
+ return
219
+ self.emit("message", message)
220
+
221
+ def _resolve_pending(self, req_id: str, ok: bool, value: Any) -> None:
222
+ with self._pending_lock:
223
+ waiter = self._pending.pop(req_id, None)
224
+ if waiter:
225
+ waiter.put((ok, value))
226
+
227
+ def _resolve_stream_event(self, req_id: str, message: Dict[str, Any]) -> None:
228
+ with self._pending_lock:
229
+ waiter = self._stream_pending.get(req_id)
230
+ if not waiter:
231
+ return
232
+ msg_type = message.get("type")
233
+ event_type = str(msg_type).removeprefix("host.invoke.")
234
+ event = {key: value for key, value in message.items() if key not in ("type", "id")}
235
+ event["type"] = event_type
236
+ waiter.put(("event", event))
237
+
238
+ def _resolve_stream_result(self, req_id: str, ok: bool, value: Any) -> bool:
239
+ with self._pending_lock:
240
+ waiter = self._stream_pending.get(req_id)
241
+ if not waiter:
242
+ return False
243
+ if ok:
244
+ waiter.put(("result", {"type": "result", "result": value}))
245
+ else:
246
+ waiter.put(("error", value))
247
+ return True
brickly/system.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from .protocol import ClipboardContent, ClipboardReadResult, ClipboardSetResult, PlatformSystemPathName
6
+
7
+ if TYPE_CHECKING:
8
+ from .api import BricklyRuntime
9
+
10
+
11
+ class PlatformApi:
12
+ def __init__(self, runtime: "BricklyRuntime") -> None:
13
+ self.system = SystemApi(runtime)
14
+ self.clipboard = ClipboardApi(runtime)
15
+
16
+
17
+ class ClipboardApi:
18
+ def __init__(self, runtime: "BricklyRuntime") -> None:
19
+ self._runtime = runtime
20
+
21
+ def read_content(self) -> ClipboardReadResult:
22
+ return self._runtime.transport.host_call({"type": "host.platform.clipboard.readContent"})
23
+
24
+ def set_content(self, content: ClipboardContent) -> ClipboardSetResult:
25
+ return self._runtime.transport.host_call(
26
+ {"type": "host.platform.clipboard.setContent", "content": content}
27
+ )
28
+
29
+
30
+ class SystemApi:
31
+ def __init__(self, runtime: "BricklyRuntime") -> None:
32
+ self._runtime = runtime
33
+
34
+ def show_notification(self, body: str, click_feature_code: Optional[str] = None) -> None:
35
+ message: dict[str, str] = {"type": "host.platform.system.showNotification", "body": body}
36
+ if click_feature_code:
37
+ message["clickFeatureCode"] = click_feature_code
38
+ self._runtime.transport.host_call(message)
39
+
40
+ def shell_open_path(self, full_path: str) -> None:
41
+ self._runtime.transport.host_call(
42
+ {"type": "host.platform.system.shellOpenPath", "fullPath": full_path}
43
+ )
44
+
45
+ def shell_trash_item(self, full_path: str) -> None:
46
+ self._runtime.transport.host_call(
47
+ {"type": "host.platform.system.shellTrashItem", "fullPath": full_path}
48
+ )
49
+
50
+ def shell_show_item_in_folder(self, full_path: str) -> None:
51
+ self._runtime.transport.host_call(
52
+ {"type": "host.platform.system.shellShowItemInFolder", "fullPath": full_path}
53
+ )
54
+
55
+ def shell_open_external(self, url: str) -> None:
56
+ self._runtime.transport.host_call({"type": "host.platform.system.shellOpenExternal", "url": url})
57
+
58
+ def shell_beep(self) -> None:
59
+ self._runtime.transport.host_call({"type": "host.platform.system.shellBeep"})
60
+
61
+ def get_native_id(self) -> str:
62
+ return self._string_call("host.platform.system.getNativeId")
63
+
64
+ def get_app_name(self) -> str:
65
+ return self._string_call("host.platform.system.getAppName")
66
+
67
+ def get_app_version(self) -> str:
68
+ return self._string_call("host.platform.system.getAppVersion")
69
+
70
+ def get_path(self, name: PlatformSystemPathName) -> str:
71
+ result = self._runtime.transport.host_call({"type": "host.platform.system.getPath", "name": name})
72
+ return "" if result is None else str(result)
73
+
74
+ def get_file_icon(self, file_path: str) -> str:
75
+ result = self._runtime.transport.host_call(
76
+ {"type": "host.platform.system.getFileIcon", "filePath": file_path}
77
+ )
78
+ return "" if result is None else str(result)
79
+
80
+ def read_current_folder_path(self) -> str:
81
+ return self._string_call("host.platform.system.readCurrentFolderPath")
82
+
83
+ def read_current_browser_url(self) -> str:
84
+ return self._string_call("host.platform.system.readCurrentBrowserUrl")
85
+
86
+ def is_dev(self) -> bool:
87
+ return bool(self._runtime.transport.host_call({"type": "host.platform.system.isDev"}))
88
+
89
+ def is_macos(self) -> bool:
90
+ return bool(self._runtime.transport.host_call({"type": "host.platform.system.isMacOS"}))
91
+
92
+ def is_windows(self) -> bool:
93
+ return bool(self._runtime.transport.host_call({"type": "host.platform.system.isWindows"}))
94
+
95
+ def is_linux(self) -> bool:
96
+ return bool(self._runtime.transport.host_call({"type": "host.platform.system.isLinux"}))
97
+
98
+ def _string_call(self, message_type: str) -> str:
99
+ result = self._runtime.transport.host_call({"type": message_type})
100
+ return "" if result is None else str(result)
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: brickly-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Brickly brick runtimes.
5
+ Author: Brickly
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # brickly-sdk-python
11
+
12
+ Python SDK for Brickly brick runtimes. It speaks BPP over stdin/stdout and keeps stdout reserved for protocol messages.
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ from brickly import BricklyRuntime
18
+
19
+ brick = BricklyRuntime("com.example.python")
20
+
21
+
22
+ @brick.on_command("hello")
23
+ def hello(ctx, input):
24
+ ctx.progress(0.5, "working...")
25
+ ctx.chunk("hello\n")
26
+ return {"ok": True, "input": input}
27
+
28
+
29
+ brick.run()
30
+ ```
31
+
32
+ The SDK handles:
33
+
34
+ - `host.hello` -> `runtime.ready`
35
+ - `runtime.ping` -> `runtime.pong`
36
+ - `command.invoke` dispatch
37
+ - `command.cancel` via `ctx.cancel_event`, `ctx.is_cancelled()`, and `ctx.on_cancel(...)`
38
+ - `command.progress`, `command.chunk`, and `command.output`
39
+ - `host.*` request id allocation and `host.result` / `host.error` routing
40
+ - `runtime.shutdown` -> optional shutdown hook -> `runtime.bye`
41
+ - `ui.create_browser_window`, `WindowHandle.call(...)`, and event routing for `window.*`
42
+ - `events.publish(...)` and `events.on(...)`
43
+ - `brick.platform.system.*` / `ctx.platform.system.*`, plus `brick.system.*` / `ctx.system.*` aliases
44
+ - `brick.invoke(...)` / `ctx.invoke(...)` for child cross-brick command calls inside command scope
45
+ - `brick.invoke_root(...)` for root cross-brick command calls outside command scope
46
+ - `brick.invoke_stream(...)` / `ctx.invoke_stream(...)` for streaming child cross-brick command calls
47
+ - `brick.open_session(...)` / `ctx.open_session(...)` for stateful cross-brick sessions
48
+ - `brick.platform.clipboard.read_content()` / `set_content(...)`
49
+
50
+ ## Logging
51
+
52
+ `brick.log(...)` writes plain messages to stderr. Stdout is reserved for BPP protocol messages. The Brickly host log center attaches the brick id, source, stream, and scope metadata when collecting stderr, so brick code should not add a `[brickId]` prefix by itself.
53
+
54
+ ## Window Example
55
+
56
+ ```python
57
+ from brickly import BricklyRuntime
58
+
59
+ brick = BricklyRuntime("com.example.window")
60
+
61
+
62
+ @brick.on_command("open")
63
+ def open_window(ctx, input):
64
+ win = ctx.ui.create_browser_window("ui/index.html", {"width": 640, "height": 480})
65
+ win.on("closed", lambda payload: brick.log("closed", payload))
66
+ win.set_title("Hello from Python")
67
+ return {"windowId": win.id}
68
+
69
+
70
+ brick.run()
71
+ ```
72
+
73
+ ## Cross-Brick Invoke
74
+
75
+ Inside a command handler, use `ctx.invoke` to call another brick command. The SDK automatically sends the current `parentRequestId`, so the host attaches the child call to the same invocation graph. Brickly starts, reuses, and recycles the target brick instance automatically. Pass `profile_id` when the target brick should run with a specific Profile; omit it to use the target brick's default Profile.
76
+
77
+ ```python
78
+ result = ctx.invoke(
79
+ "com.brickly.openai",
80
+ "chat",
81
+ {"prompt": "hello"},
82
+ profile_id="work",
83
+ )
84
+ ```
85
+
86
+ Use `invoke_stream` when the target command emits progress, chunks, or named outputs before its final result. The SDK sends `host.invoke` with `stream: true` and yields events in host order:
87
+
88
+ ```python
89
+ for event in ctx.invoke_stream("com.brickly.openai", "chat", {"prompt": "hello"}):
90
+ if event["type"] == "progress":
91
+ ctx.progress(event.get("progress", 0), event.get("message"))
92
+ elif event["type"] == "chunk":
93
+ ctx.chunk(event.get("chunk"))
94
+ elif event["type"] == "output":
95
+ ctx.output(event["name"], event.get("value"))
96
+ elif event["type"] == "result":
97
+ return event["result"]
98
+ ```
99
+
100
+ If the host returns `host.error`, `invoke_stream` raises `BppError`, matching normal `invoke` error handling.
101
+
102
+ To create a top-level cross-brick call outside command scope, use the explicit root API:
103
+
104
+ ```python
105
+ result = brick.invoke_root(
106
+ "com.brickly.openai",
107
+ "chat",
108
+ {"prompt": "hello"},
109
+ profile_id="work",
110
+ )
111
+ ```
112
+
113
+ The caller manifest must declare the target brick and allowed commands in `dependencies`:
114
+
115
+ ```json
116
+ "dependencies": {
117
+ "com.brickly.openai": {
118
+ "commands": ["chat"]
119
+ }
120
+ }
121
+ ```
122
+
123
+ ## Platform System API
124
+
125
+ `brick.platform.system.*`, `brick.system.*`, `ctx.platform.system.*`, and `ctx.system.*` call host system capabilities through BPP `host.platform.system.*`:
126
+
127
+ ```python
128
+ @brick.on_command("show-app-info")
129
+ def show_app_info(ctx, _input):
130
+ return {
131
+ "appName": ctx.system.get_app_name(),
132
+ "appVersion": ctx.system.get_app_version(),
133
+ "userData": ctx.system.get_path("userData"),
134
+ "isMacOS": ctx.system.is_macos(),
135
+ }
136
+ ```
137
+
138
+ Current methods are `show_notification`, `shell_open_path`, `shell_trash_item`, `shell_show_item_in_folder`, `shell_open_external`, `shell_beep`, `get_native_id`, `get_app_name`, `get_app_version`, `get_path`, `get_file_icon`, `read_current_folder_path`, `read_current_browser_url`, `is_dev`, `is_macos`, `is_windows`, and `is_linux`.
139
+
140
+ `get_path()` supports `home`, `appData`, `assets`, `userData`, `sessionData`, `temp`, `exe`, `module`, `desktop`, `documents`, `downloads`, `music`, `pictures`, `videos`, `recent`, `logs`, and `crashDumps`. Runtime calls are still checked by manifest permissions on the host side: notifications require `os.notification`; Shell capabilities require `os.exec`; app info, paths, native ID, platform checks, and current folder path reads require `os.env`; file icons require `fs.read`. `read_current_folder_path()` works when Finder is frontmost on macOS or Explorer is frontmost on Windows; it raises `CURRENT_FOLDER_UNAVAILABLE` when no readable foreground file-manager folder is available. `read_current_browser_url()` is reserved and currently returns `UNSUPPORTED_PLATFORM`.
141
+
142
+ ## Platform Clipboard API
143
+
144
+ `brick.platform.clipboard.*` and `ctx.platform.clipboard.*` call host clipboard capabilities through BPP `host.platform.clipboard.*`:
145
+
146
+ ```python
147
+ @brick.on_command("replace-clipboard")
148
+ def replace_clipboard(ctx, _input):
149
+ previous = ctx.platform.clipboard.read_content()
150
+ updated = ctx.platform.clipboard.set_content({"kind": "text", "text": "Updated by Python"})
151
+ return {"previous": previous, "updated": updated}
152
+ ```
153
+
154
+ Current methods are `read_content()` and `set_content(content)`.
155
+
156
+ ## Cross-Brick Sessions
157
+
158
+ Use `ctx.open_session` when the target brick keeps in-memory state that must survive across multiple command calls. Calls through the same session are routed to the same target brick instance, and each `session.invoke` automatically carries the current `parentRequestId`, until `close()` is called or the caller brick instance exits.
159
+
160
+ ```python
161
+ session = ctx.open_session("com.brickly.openai", profile_id="work")
162
+
163
+ try:
164
+ session.invoke("start-thread", {"title": "Draft"})
165
+ reply = session.invoke("chat", {"prompt": "continue the thread"})
166
+ finally:
167
+ session.close()
168
+ ```
169
+
170
+ `profile_id` is the target brick Profile ID. Session invokes are checked against the caller manifest's `dependencies[target].commands` on every command call.
171
+
172
+ ## Errors
173
+
174
+ Raise `BppError` to preserve a specific Brickly error code:
175
+
176
+ ```python
177
+ from brickly import BppError
178
+
179
+ raise BppError("INVALID_INPUT", "url is required")
180
+ ```
@@ -0,0 +1,9 @@
1
+ brickly/__init__.py,sha256=S3ouUBU8SRYpcPwDCKZ2JUeCNvNl1Qf4S-tmUa7x9PE,667
2
+ brickly/api.py,sha256=i2YmfwmqPHAPFtE9DC5khxbbavEI5QpGP1h_bf-jYHk,21380
3
+ brickly/errors.py,sha256=tHgmiCYX4JpYGKb6bXzlhTxAyZN8Z-GxzB_upVF9gBo,1167
4
+ brickly/protocol.py,sha256=-hnB5mbh7vXW3Z_6hCEDCxrZhB6vjEVOkv2gzpFPgpA,882
5
+ brickly/runtime.py,sha256=lHnILaNrUipJ6nDLiv8gAaHh9NdxCLZaR69eC8XLgN0,9290
6
+ brickly/system.py,sha256=lMfMHR4bfaGXolPIpfoLWn32QJpMbqGyumSPNWoOE8Y,3953
7
+ brickly_sdk-0.1.0.dist-info/METADATA,sha256=bk0_L4wVkCE1tYvckKk2jriX_E2GP6Uj7Lr0fRTF8AI,7116
8
+ brickly_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ brickly_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any