bma-executor-sdk 0.4.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.
@@ -0,0 +1,189 @@
1
+ """Transports for the executor protocol.
2
+
3
+ Every transport carries the same thing: WebSocket-style text messages, one
4
+ JSON-RPC (control) or raw MCP (mcp) frame per message, with WebSocket close
5
+ codes. Three are provided:
6
+
7
+ - ``ws://`` / ``wss://``: an outbound WebSocket (the BMA service, or a harness
8
+ on a TCP listener).
9
+ - ``unix:///path/to.sock``: the same WebSocket protocol over a Unix domain
10
+ socket, for a harness on the same machine. The query string selects the
11
+ channel exactly as on ``ws://``. Windows named pipes are not implemented yet.
12
+ - in-process: :meth:`Harness.inprocess_dialer` connects an executor to a
13
+ harness in the same event loop without a socket.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import Protocol
22
+ from urllib.parse import parse_qsl, urlencode, urlsplit
23
+
24
+ # WebSocket close codes (executor.openrpc.yaml): 4001 token expired and 4005
25
+ # drained reconnect with a fresh token; 4003 unsupported protocol and 4004
26
+ # replaced by a newer connection stop the executor, as do 1008 policy
27
+ # violation and 4002 forbidden.
28
+ CLOSE_NORMAL = 1000
29
+ CLOSE_POLICY_VIOLATION = 1008
30
+ CLOSE_INTERNAL_ERROR = 1011
31
+ CLOSE_TOKEN_EXPIRED = 4001
32
+ CLOSE_FORBIDDEN = 4002
33
+ CLOSE_UNSUPPORTED_PROTOCOL = 4003
34
+ CLOSE_REPLACED = 4004
35
+ CLOSE_DRAINED = 4005
36
+ TERMINAL_CLOSE_CODES = frozenset({CLOSE_POLICY_VIOLATION, CLOSE_FORBIDDEN, CLOSE_UNSUPPORTED_PROTOCOL, CLOSE_REPLACED})
37
+
38
+ MAX_FRAME_BYTES = 1 << 20
39
+
40
+
41
+ class ChannelClosed(Exception):
42
+ """The peer or the transport closed the channel."""
43
+
44
+ def __init__(self, code: int, reason: str = "") -> None:
45
+ super().__init__(f"channel closed: {code} {reason}".rstrip())
46
+ self.code = code
47
+ self.reason = reason
48
+
49
+
50
+ class Connection(Protocol):
51
+ """One open channel. recv raises ChannelClosed when the channel ends."""
52
+
53
+ @property
54
+ def subprotocol(self) -> str | None: ...
55
+
56
+ async def send(self, message: str) -> None: ...
57
+
58
+ async def recv(self) -> str: ...
59
+
60
+ async def close(self, code: int = CLOSE_NORMAL, reason: str = "") -> None: ...
61
+
62
+
63
+ # Dialer opens one channel: (url, bearer token or "", offered subprotocols).
64
+ Dialer = Callable[[str, str, Sequence[str]], Awaitable[Connection]]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ConnectRequest:
69
+ """What the harness sees of a channel upgrade: path, query and headers."""
70
+
71
+ path: str
72
+ query: Mapping[str, str]
73
+ headers: Mapping[str, str] = field(default_factory=dict)
74
+
75
+ @property
76
+ def bearer_token(self) -> str:
77
+ value = next((v for k, v in self.headers.items() if k.lower() == "authorization"), "")
78
+ return value[7:] if value[:7].lower() == "bearer " else ""
79
+
80
+
81
+ class WebSocketConnection:
82
+ """Connection over a ``websockets`` asyncio connection (client or server)."""
83
+
84
+ def __init__(self, ws: object) -> None:
85
+ self._ws = ws
86
+
87
+ @property
88
+ def subprotocol(self) -> str | None:
89
+ return self._ws.subprotocol # type: ignore[attr-defined]
90
+
91
+ async def send(self, message: str) -> None:
92
+ from websockets.exceptions import ConnectionClosed
93
+
94
+ try:
95
+ await self._ws.send(message) # type: ignore[attr-defined]
96
+ except ConnectionClosed as error:
97
+ raise _closed(error) from error
98
+
99
+ async def recv(self) -> str:
100
+ from websockets.exceptions import ConnectionClosed
101
+
102
+ try:
103
+ message = await self._ws.recv() # type: ignore[attr-defined]
104
+ except ConnectionClosed as error:
105
+ raise _closed(error) from error
106
+ return message if isinstance(message, str) else message.decode()
107
+
108
+ async def close(self, code: int = CLOSE_NORMAL, reason: str = "") -> None:
109
+ await self._ws.close(code, reason) # type: ignore[attr-defined]
110
+
111
+
112
+ def _closed(error: Exception) -> ChannelClosed:
113
+ received = getattr(error, "rcvd", None)
114
+ if received is None:
115
+ return ChannelClosed(1006, "abnormal closure")
116
+ return ChannelClosed(received.code, received.reason)
117
+
118
+
119
+ async def dial(url: str, token: str, subprotocols: Sequence[str], *, max_size: int = MAX_FRAME_BYTES) -> Connection:
120
+ """Default dialer: ``ws://``, ``wss://`` and ``unix:///path?query``."""
121
+ from websockets.asyncio.client import connect, unix_connect
122
+
123
+ headers = {"Authorization": f"Bearer {token}"} if token else None
124
+ parts = urlsplit(url)
125
+ offered = list(subprotocols)
126
+ if parts.scheme == "unix":
127
+ uri = "ws://localhost/v1/executors/channel" + (f"?{parts.query}" if parts.query else "")
128
+ ws = await unix_connect(parts.path, uri, additional_headers=headers, subprotocols=offered, max_size=max_size) # type: ignore[arg-type]
129
+ elif parts.scheme in ("ws", "wss"):
130
+ ws = await connect(url, additional_headers=headers, subprotocols=offered, max_size=max_size) # type: ignore[arg-type]
131
+ else:
132
+ raise ValueError(f"bma_executor_sdk: unsupported channel URL scheme {parts.scheme!r}")
133
+ return WebSocketConnection(ws)
134
+
135
+
136
+ def channel_url(base: str, **query: str) -> str:
137
+ """Return base with the given query members set (used for the MCP channel)."""
138
+ parts = urlsplit(base)
139
+ if parts.scheme not in ("ws", "wss", "unix", "inprocess"):
140
+ raise ValueError("bma_executor_sdk: channel URL must use ws, wss or unix")
141
+ merged = dict(parse_qsl(parts.query))
142
+ merged.update(query)
143
+ return parts._replace(query=urlencode(merged)).geturl()
144
+
145
+
146
+ # --- in-process ---
147
+
148
+ _EOF = object()
149
+
150
+
151
+ class _PipeEnd:
152
+ def __init__(self, inbox: asyncio.Queue[object], outbox: asyncio.Queue[object], subprotocol: str | None) -> None:
153
+ self._inbox = inbox
154
+ self._outbox = outbox
155
+ self._subprotocol = subprotocol
156
+ self._closed: ChannelClosed | None = None
157
+ self.peer: _PipeEnd | None = None
158
+
159
+ @property
160
+ def subprotocol(self) -> str | None:
161
+ return self._subprotocol
162
+
163
+ async def send(self, message: str) -> None:
164
+ if self._closed is not None:
165
+ raise self._closed
166
+ await self._outbox.put(message)
167
+
168
+ async def recv(self) -> str:
169
+ if self._closed is not None:
170
+ raise self._closed
171
+ item = await self._inbox.get()
172
+ if isinstance(item, ChannelClosed):
173
+ self._closed = item
174
+ raise item
175
+ return item # type: ignore[return-value]
176
+
177
+ async def close(self, code: int = CLOSE_NORMAL, reason: str = "") -> None:
178
+ if self._closed is not None:
179
+ return
180
+ self._closed = ChannelClosed(code, reason)
181
+ await self._outbox.put(ChannelClosed(code, reason))
182
+ await self._inbox.put(ChannelClosed(code, reason))
183
+
184
+
185
+ def pipe(subprotocol: str | None) -> tuple[Connection, Connection]:
186
+ """Return two connected in-process channel ends."""
187
+ a: asyncio.Queue[object] = asyncio.Queue()
188
+ b: asyncio.Queue[object] = asyncio.Queue()
189
+ return _PipeEnd(a, b, subprotocol), _PipeEnd(b, a, subprotocol)
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: bma-executor-sdk
3
+ Version: 0.4.0
4
+ Summary: BMA executor protocol (bma.executor.v1.1): executor, harness and agent ends on asyncio with WebSocket, Unix socket and in-process transports.
5
+ Project-URL: Repository, https://github.com/beeos-ai/bma-sdks
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: websockets<17,>=14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # bma-executor-sdk (Python)
13
+
14
+ asyncio executor (`ExecutorClient`), harness (`Harness`) and agent ends of the
15
+ BMA executor protocol `bma.executor.v1.1`, with TypedDict protocol types
16
+ generated from the BMA contract (`contracts/bma/executor`). Transports:
17
+ WebSocket, WebSocket over a Unix domain socket (`unix://`), in-process.
18
+
19
+ ```sh
20
+ pip install bma-executor-sdk
21
+ ```
22
+
23
+ ```python
24
+ import bma_executor_sdk as bx
25
+ ```
26
+
27
+ See the [repository README](../../README.md#python-executor) and
28
+ [examples](examples). The REST/façade client is the separate `bma-sdk` package.
@@ -0,0 +1,10 @@
1
+ bma_executor_sdk/__init__.py,sha256=tEYKoxwNUW0Hc4g6yau0UQah2r8QcW6IgpP0R7A-Nb8,1738
2
+ bma_executor_sdk/_rpc.py,sha256=XE-K28WCBo7Zwo8gC8D6xkTNqGpveCDikeoi4sAJjiw,7565
3
+ bma_executor_sdk/agent.py,sha256=lFADzZpKHNJXNWn6-FdFgCB0ew1gaZ7lanNuBkbG_ck,24554
4
+ bma_executor_sdk/client.py,sha256=Gh3yhttDiH98AHKs81UlGLCJVvOhHnwOZHHbkJJfqrw,28915
5
+ bma_executor_sdk/generated.py,sha256=v-4peyE88OCmpPM9u2x5g9S7CZ1tl9o83xTsYHEJni0,44815
6
+ bma_executor_sdk/harness.py,sha256=bfHmdF0Tq660Q7XqsWWuza1BKQXqqz9R_377LW0_4j8,32885
7
+ bma_executor_sdk/transport.py,sha256=qOaqzVaFd_f1wFmHi_PKAHb04gfFyjgICq94waXo69Q,6940
8
+ bma_executor_sdk-0.4.0.dist-info/METADATA,sha256=1klPrpEWf0nP2xKDl3C4oznuqv4jsQ8sUpKCMhfDyb4,990
9
+ bma_executor_sdk-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ bma_executor_sdk-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any