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.
- bma_executor_sdk/__init__.py +77 -0
- bma_executor_sdk/_rpc.py +192 -0
- bma_executor_sdk/agent.py +558 -0
- bma_executor_sdk/client.py +675 -0
- bma_executor_sdk/generated.py +1155 -0
- bma_executor_sdk/harness.py +769 -0
- bma_executor_sdk/transport.py +189 -0
- bma_executor_sdk-0.4.0.dist-info/METADATA +28 -0
- bma_executor_sdk-0.4.0.dist-info/RECORD +10 -0
- bma_executor_sdk-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|