deft-subspace-kernel 0.1.0a1__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,93 @@
1
+ """Protocol-neutral kernel: channels, NDJSON framing, a JSON-RPC peer, traces, and supervised processes."""
2
+
3
+ from .abort import AbortController, AbortError, AbortSignal
4
+ from .channel import (
5
+ Channel,
6
+ ChannelClosed,
7
+ EndOfChannel,
8
+ MemoryChannel,
9
+ StreamChannel,
10
+ Transform,
11
+ channel_pair,
12
+ iterate,
13
+ pipe_channel,
14
+ stream_channel,
15
+ )
16
+ from .clock import Clock, SystemClock, system_clock
17
+ from .errors import ErrorCode, LocalError, LocalErrorKind, RpcError, SubspaceError
18
+ from .framing import EncodeError, FramingDiagnostic, NdjsonDecoder, NdjsonEncoder, ndjson, ndjson_decoder, ndjson_encoder
19
+ from .process import ExitInfo, SpawnError, SupervisedProcess, spawn_process
20
+ from .rpc import (
21
+ NO_ID,
22
+ Call,
23
+ CancelInbound,
24
+ CancelOutbound,
25
+ CloseInfo,
26
+ InboundContext,
27
+ InboundMiddleware,
28
+ JsonRpcId,
29
+ NotificationHandler,
30
+ OutboundMiddleware,
31
+ PeerDiagnostic,
32
+ PeerState,
33
+ RequestHandler,
34
+ RpcPeer,
35
+ cancel_request,
36
+ )
37
+ from .trace import TRACE_VERSION, TraceRecord, TraceTap, format_trace, parse_trace, trace_tap
38
+
39
+ __all__ = [
40
+ "SubspaceError",
41
+ "AbortController",
42
+ "AbortError",
43
+ "AbortSignal",
44
+ "Call",
45
+ "CancelInbound",
46
+ "CancelOutbound",
47
+ "Channel",
48
+ "ChannelClosed",
49
+ "Clock",
50
+ "CloseInfo",
51
+ "EncodeError",
52
+ "EndOfChannel",
53
+ "ErrorCode",
54
+ "ExitInfo",
55
+ "FramingDiagnostic",
56
+ "InboundContext",
57
+ "InboundMiddleware",
58
+ "JsonRpcId",
59
+ "LocalError",
60
+ "LocalErrorKind",
61
+ "MemoryChannel",
62
+ "NO_ID",
63
+ "NdjsonDecoder",
64
+ "NdjsonEncoder",
65
+ "NotificationHandler",
66
+ "OutboundMiddleware",
67
+ "PeerDiagnostic",
68
+ "PeerState",
69
+ "RequestHandler",
70
+ "RpcError",
71
+ "RpcPeer",
72
+ "SpawnError",
73
+ "StreamChannel",
74
+ "SupervisedProcess",
75
+ "SystemClock",
76
+ "TRACE_VERSION",
77
+ "TraceRecord",
78
+ "TraceTap",
79
+ "Transform",
80
+ "cancel_request",
81
+ "channel_pair",
82
+ "format_trace",
83
+ "iterate",
84
+ "ndjson",
85
+ "ndjson_decoder",
86
+ "ndjson_encoder",
87
+ "parse_trace",
88
+ "pipe_channel",
89
+ "spawn_process",
90
+ "stream_channel",
91
+ "system_clock",
92
+ "trace_tap",
93
+ ]
@@ -0,0 +1,173 @@
1
+ """Cancellation as a value, like the Web's AbortController and AbortSignal.
2
+
3
+ asyncio cancels tasks; Subspace also needs to say *why* something stopped and
4
+ to let many waiters see one cancel (a turn's cancel answers every open
5
+ permission question). A signal fires once, keeps its reason, and runs its
6
+ listeners synchronously, in the order they were added.
7
+
8
+ controller = AbortController()
9
+ remove = controller.signal.add_listener(lambda reason: print("stopped", reason))
10
+ controller.abort("user pressed stop")
11
+ await controller.signal.wait() # returns at once now
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from collections.abc import Callable, Iterable
18
+ from typing import Any
19
+
20
+ from .errors import SubspaceError
21
+
22
+
23
+ class AbortError(SubspaceError):
24
+ """The default reason of a signal aborted without one."""
25
+
26
+ def __init__(self, message: str = "This operation was aborted") -> None:
27
+ super().__init__(message)
28
+
29
+
30
+ Listener = Callable[[Any], None]
31
+
32
+
33
+ class AbortSignal:
34
+ __slots__ = ("_aborted", "_reason", "_listeners", "_dispose", "__weakref__")
35
+
36
+ def __init__(self) -> None:
37
+ self._aborted = False
38
+ self._reason: Any = None
39
+ self._listeners: list[Listener] = []
40
+ #: For a signal from `any`: removes its listeners from the source signals.
41
+ self._dispose: Callable[[], None] | None = None
42
+
43
+ @property
44
+ def aborted(self) -> bool:
45
+ return self._aborted
46
+
47
+ @property
48
+ def reason(self) -> Any:
49
+ return self._reason
50
+
51
+ def add_listener(self, listener: Listener) -> Callable[[], None]:
52
+ """Call `listener(reason)` once, when the signal fires. Returns a function that removes it.
53
+
54
+ On a signal that has already fired, the listener is not called (as on the Web).
55
+ """
56
+ if self._aborted:
57
+ return lambda: None
58
+ self._listeners.append(listener)
59
+
60
+ def remove() -> None:
61
+ try:
62
+ self._listeners.remove(listener)
63
+ except ValueError:
64
+ pass
65
+
66
+ return remove
67
+
68
+ def dispose(self) -> None:
69
+ """Stop following the source signals (a signal from `any`): its listeners on them
70
+ are removed, so a long-lived source does not keep it alive. It will not fire after
71
+ this. A no-op for any other signal, and once it fired."""
72
+ dispose, self._dispose = self._dispose, None
73
+ if dispose is not None:
74
+ dispose()
75
+
76
+ def __enter__(self) -> AbortSignal:
77
+ return self
78
+
79
+ def __exit__(self, *_exc: object) -> None:
80
+ self.dispose()
81
+
82
+ def throw_if_aborted(self) -> None:
83
+ if self._aborted:
84
+ reason = self._reason
85
+ raise reason if isinstance(reason, BaseException) else AbortError(str(reason))
86
+
87
+ async def wait(self) -> Any:
88
+ """Wait until the signal fires; returns its reason."""
89
+ if self._aborted:
90
+ return self._reason
91
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
92
+
93
+ def done(reason: Any) -> None:
94
+ if not future.done():
95
+ future.set_result(reason)
96
+
97
+ remove = self.add_listener(done)
98
+ try:
99
+ return await future
100
+ finally:
101
+ remove()
102
+
103
+ def _fire(self, reason: Any) -> None:
104
+ if self._aborted:
105
+ return
106
+ self._aborted = True
107
+ self._reason = AbortError() if reason is None else reason
108
+ listeners, self._listeners = self._listeners, []
109
+ for listener in listeners:
110
+ try:
111
+ listener(self._reason)
112
+ except Exception: # noqa: BLE001 - a listener must not stop the others
113
+ pass
114
+
115
+ @staticmethod
116
+ def abort(reason: Any = None) -> AbortSignal:
117
+ """A signal that has already fired."""
118
+ signal = AbortSignal()
119
+ signal._fire(reason)
120
+ return signal
121
+
122
+ @staticmethod
123
+ def any(signals: Iterable[AbortSignal | None]) -> AbortSignal:
124
+ """A signal that fires when the first of `signals` fires, with its reason.
125
+
126
+ It listens on every source until one fires. When you are done with it before
127
+ then, `dispose()` it (or use it as a context manager) so a long-lived source
128
+ does not keep it, and whatever listens on it, alive:
129
+
130
+ with AbortSignal.any([turn_signal, connection_signal]) as signal:
131
+ ...
132
+ """
133
+ combined = AbortSignal()
134
+ live = [s for s in signals if s is not None]
135
+ for signal in live:
136
+ if signal.aborted:
137
+ combined._fire(signal.reason)
138
+ return combined
139
+ removers: list[Callable[[], None]] = []
140
+
141
+ def dispose() -> None:
142
+ for remove in removers:
143
+ remove()
144
+ removers.clear()
145
+
146
+ def fire(reason: Any) -> None:
147
+ combined.dispose()
148
+ combined._fire(reason)
149
+
150
+ for signal in live:
151
+ removers.append(signal.add_listener(fire))
152
+ combined._dispose = dispose
153
+ return combined
154
+
155
+ @staticmethod
156
+ def timeout(ms: float) -> AbortSignal:
157
+ """A signal that fires after `ms` milliseconds (needs a running loop)."""
158
+ signal = AbortSignal()
159
+ asyncio.get_running_loop().call_later(ms / 1000, lambda: signal._fire(TimeoutError(f"timed out after {ms} ms")))
160
+ return signal
161
+
162
+ def __repr__(self) -> str:
163
+ return f"AbortSignal(aborted={self._aborted})"
164
+
165
+
166
+ class AbortController:
167
+ __slots__ = ("signal",)
168
+
169
+ def __init__(self) -> None:
170
+ self.signal = AbortSignal()
171
+
172
+ def abort(self, reason: Any = None) -> None:
173
+ self.signal._fire(reason)
@@ -0,0 +1,244 @@
1
+ """Channels: read what the other side sent, send what you send.
2
+
3
+ Bytes on the edges (stdio, sockets), structured messages in process.
4
+
5
+ message = await channel.receive() # raises EndOfChannel when the other side has ended
6
+ await channel.send(message)
7
+ await channel.close() # end our side; the other side reads EndOfChannel
8
+
9
+ A channel may also have `abort()`: close at once, dropping unsent data. The
10
+ peer uses it when a write is stuck; without it, it calls `close()`.
11
+
12
+ `channel_pair()` connects two in-process ends; `stream_channel()` wraps
13
+ asyncio streams; `pipe_channel()` applies a transform to each direction (that
14
+ is how `ndjson()` turns bytes into JSON values).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from collections import deque
21
+ from collections.abc import AsyncIterator, Iterable
22
+ from typing import Any, Generic, Protocol, TypeVar
23
+
24
+ from .errors import SubspaceError
25
+
26
+ T = TypeVar("T")
27
+ A = TypeVar("A")
28
+ B = TypeVar("B")
29
+ In = TypeVar("In", contravariant=True)
30
+ Out = TypeVar("Out", covariant=True)
31
+
32
+
33
+ class EndOfChannel(SubspaceError):
34
+ """The other side ended its stream: nothing more will arrive."""
35
+
36
+
37
+ class ChannelClosed(SubspaceError):
38
+ """Sending on a channel whose sending side was closed (or whose reader went away)."""
39
+
40
+
41
+ class Channel(Protocol[T]):
42
+ async def receive(self) -> T:
43
+ """The next item. Raises `EndOfChannel` once the other side has ended."""
44
+ ...
45
+
46
+ async def send(self, item: T) -> None:
47
+ """Send an item, in order with earlier sends. Raises `ChannelClosed` when it cannot."""
48
+ ...
49
+
50
+ async def close(self) -> None:
51
+ """End this side's stream. Idempotent."""
52
+ ...
53
+
54
+
55
+ async def iterate(channel: Channel[T]) -> AsyncIterator[T]:
56
+ """Every item until the other side ends."""
57
+ while True:
58
+ try:
59
+ yield await channel.receive()
60
+ except EndOfChannel:
61
+ return
62
+
63
+
64
+ class _Pipe(Generic[T]):
65
+ """One direction: items in order, then the end."""
66
+
67
+ def __init__(self) -> None:
68
+ self.items: deque[T] = deque()
69
+ self.ended = False
70
+ # Every receive() waiting for an item. All are woken; each takes one or waits again.
71
+ self.waiters: list[asyncio.Future[None]] = []
72
+
73
+ def put(self, item: T) -> None:
74
+ if self.ended:
75
+ raise ChannelClosed("channel closed")
76
+ self.items.append(item)
77
+ self._wake()
78
+
79
+ def end(self) -> None:
80
+ self.ended = True
81
+ self._wake()
82
+
83
+ def _wake(self) -> None:
84
+ waiters, self.waiters = self.waiters, []
85
+ for waiter in waiters:
86
+ if not waiter.done():
87
+ waiter.set_result(None)
88
+
89
+ async def get(self) -> T:
90
+ while not self.items:
91
+ if self.ended:
92
+ raise EndOfChannel()
93
+ waiter = asyncio.get_running_loop().create_future()
94
+ self.waiters.append(waiter)
95
+ try:
96
+ await waiter
97
+ finally:
98
+ if waiter in self.waiters:
99
+ self.waiters.remove(waiter)
100
+ return self.items.popleft()
101
+
102
+
103
+ class MemoryChannel(Generic[T]):
104
+ """One end of an in-process pair."""
105
+
106
+ def __init__(self, inbox: _Pipe[T], outbox: _Pipe[T]) -> None:
107
+ self._inbox = inbox
108
+ self._outbox = outbox
109
+
110
+ async def receive(self) -> T:
111
+ return await self._inbox.get()
112
+
113
+ async def send(self, item: T) -> None:
114
+ self._outbox.put(item)
115
+
116
+ async def close(self) -> None:
117
+ self._outbox.end()
118
+
119
+ async def abort(self) -> None:
120
+ self._outbox.end()
121
+
122
+
123
+ def channel_pair() -> tuple[MemoryChannel[Any], MemoryChannel[Any]]:
124
+ """Two connected channels in one process: what `a` sends, `b` receives, and the other way round.
125
+
126
+ No bytes, no JSON. Use it to run a client and an agent together in tests or in one app.
127
+ """
128
+ a_to_b: _Pipe[Any] = _Pipe()
129
+ b_to_a: _Pipe[Any] = _Pipe()
130
+ return MemoryChannel(b_to_a, a_to_b), MemoryChannel(a_to_b, b_to_a)
131
+
132
+
133
+ class StreamChannel:
134
+ """asyncio streams (a socket, a child's stdio) as a byte channel."""
135
+
136
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter | Any, *, chunk_size: int = 65536) -> None:
137
+ self._reader = reader
138
+ self._writer = writer
139
+ self._chunk = chunk_size
140
+ self._closed = False
141
+
142
+ async def receive(self) -> bytes:
143
+ data = await self._reader.read(self._chunk)
144
+ if not data:
145
+ raise EndOfChannel()
146
+ return data
147
+
148
+ async def send(self, item: bytes) -> None:
149
+ if self._closed or self._writer.is_closing():
150
+ raise ChannelClosed("stream closed")
151
+ try:
152
+ self._writer.write(item)
153
+ await self._writer.drain()
154
+ except (ConnectionError, RuntimeError) as error: # BrokenPipe, reset, closed transport
155
+ raise ChannelClosed(str(error)) from error
156
+
157
+ async def close(self) -> None:
158
+ if self._closed:
159
+ return
160
+ self._closed = True
161
+ try:
162
+ if self._writer.can_write_eof():
163
+ self._writer.write_eof()
164
+ else:
165
+ self._writer.close()
166
+ except (OSError, RuntimeError):
167
+ pass
168
+
169
+ async def abort(self) -> None:
170
+ """Close at once, dropping anything not yet written."""
171
+ self._closed = True
172
+ transport = getattr(self._writer, "transport", None)
173
+ try:
174
+ if transport is not None:
175
+ transport.abort()
176
+ else:
177
+ self._writer.close()
178
+ except (OSError, RuntimeError):
179
+ pass
180
+
181
+
182
+ def stream_channel(reader: asyncio.StreamReader, writer: asyncio.StreamWriter | Any) -> StreamChannel:
183
+ return StreamChannel(reader, writer)
184
+
185
+
186
+ class Transform(Protocol[In, Out]):
187
+ """Turns items into items, keeping state between them (a decoder, a re-chunker)."""
188
+
189
+ def feed(self, item: In, /) -> Iterable[Out]: ...
190
+
191
+ def flush(self) -> Iterable[Out]:
192
+ """What is left once the input has ended."""
193
+ ...
194
+
195
+
196
+ class _Piped(Generic[A, B]):
197
+ def __init__(self, channel: Channel[A], read: Transform[A, B], write: Transform[B, A]) -> None:
198
+ self._channel = channel
199
+ self._read = read
200
+ self._write = write
201
+ self._ready: deque[B] = deque()
202
+ self._ended = False
203
+
204
+ async def receive(self) -> B:
205
+ while not self._ready:
206
+ if self._ended:
207
+ raise EndOfChannel()
208
+ try:
209
+ item = await self._channel.receive()
210
+ except EndOfChannel:
211
+ self._ended = True
212
+ self._ready.extend(self._read.flush())
213
+ continue
214
+ self._ready.extend(self._read.feed(item))
215
+ return self._ready.popleft()
216
+
217
+ async def send(self, item: B) -> None:
218
+ for out in self._write.feed(item):
219
+ await self._channel.send(out)
220
+
221
+ async def close(self) -> None:
222
+ try:
223
+ for out in self._write.flush():
224
+ await self._channel.send(out)
225
+ except ChannelClosed:
226
+ pass
227
+ await self._channel.close()
228
+
229
+ async def abort(self) -> None:
230
+ abort = getattr(self._channel, "abort", None)
231
+ await (abort() if abort is not None else self._channel.close())
232
+
233
+
234
+ def pipe_channel(channel: Channel[A], *, read: Transform[A, B], write: Transform[B, A]) -> Channel[B]:
235
+ """Apply a transform to each direction of a channel."""
236
+ return _Piped(channel, read, write)
237
+
238
+
239
+ class Identity(Generic[T]):
240
+ def feed(self, item: T) -> Iterable[T]:
241
+ return (item,)
242
+
243
+ def flush(self) -> Iterable[T]:
244
+ return ()
@@ -0,0 +1,39 @@
1
+ """Time, as something tests can control.
2
+
3
+ Every timer in the kernel goes through a Clock, so a test can fake it and
4
+ check timeout behaviour without waiting. Times are milliseconds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from collections.abc import Callable
12
+ from typing import Any, Protocol
13
+
14
+
15
+ class Clock(Protocol):
16
+ def now(self) -> float:
17
+ """Milliseconds since the epoch."""
18
+ ...
19
+
20
+ def set_timeout(self, callback: Callable[[], None], ms: float) -> Any:
21
+ """Run `callback` after `ms`; returns a handle for `clear_timeout`."""
22
+ ...
23
+
24
+ def clear_timeout(self, handle: Any) -> None: ...
25
+
26
+
27
+ class SystemClock:
28
+ def now(self) -> float:
29
+ return time.time() * 1000
30
+
31
+ def set_timeout(self, callback: Callable[[], None], ms: float) -> asyncio.TimerHandle:
32
+ return asyncio.get_running_loop().call_later(max(0.0, ms) / 1000, callback)
33
+
34
+ def clear_timeout(self, handle: Any) -> None:
35
+ if handle is not None:
36
+ handle.cancel()
37
+
38
+
39
+ system_clock: Clock = SystemClock()
@@ -0,0 +1,114 @@
1
+ """Two kinds of failure, kept apart on purpose.
2
+
3
+ - `RpcError` crosses the wire. The other side sent it (or we send it).
4
+ - `LocalError` never crosses the wire. It says why *this* side stopped
5
+ waiting: the caller aborted, a deadline passed, the connection closed, ...
6
+
7
+ Code that cares should look at `error.kind`, never at message text.
8
+
9
+ Every exception class the Subspace packages define is a `SubspaceError`, so `except SubspaceError`
10
+ catches all of them. Built-in errors still come through as they are: `TypeError` and `ValueError` for
11
+ wrong arguments or malformed input (such as `parse_trace()`), `OSError` from the file system.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Literal
17
+
18
+
19
+ class SubspaceError(Exception):
20
+ """The base of every exception class the Subspace packages define."""
21
+
22
+
23
+ class ErrorCode:
24
+ """Standard JSON-RPC 2.0 codes, plus the ones ACP and LSP use."""
25
+
26
+ PARSE_ERROR = -32700
27
+ INVALID_REQUEST = -32600
28
+ METHOD_NOT_FOUND = -32601
29
+ INVALID_PARAMS = -32602
30
+ INTERNAL_ERROR = -32603
31
+ #: ACP: the session (or other resource) does not exist.
32
+ RESOURCE_NOT_FOUND = -32002
33
+ #: ACP: authentication required.
34
+ AUTH_REQUIRED = -32000
35
+ #: LSP / ACP `$/cancel_request`: the request was cancelled.
36
+ REQUEST_CANCELLED = -32800
37
+
38
+
39
+ _MISSING: Any = object()
40
+
41
+
42
+ class RpcError(SubspaceError):
43
+ """An error the remote side returned, or one a handler raises to return."""
44
+
45
+ kind: Literal["remote"] = "remote"
46
+
47
+ def __init__(self, code: int, message: str, data: Any = _MISSING) -> None:
48
+ super().__init__(message)
49
+ self.code = code
50
+ self.message = message
51
+ self._data = data
52
+
53
+ @property
54
+ def data(self) -> Any:
55
+ return None if self._data is _MISSING else self._data
56
+
57
+ @property
58
+ def has_data(self) -> bool:
59
+ return self._data is not _MISSING
60
+
61
+ @classmethod
62
+ def method_not_found(cls, method: str) -> RpcError:
63
+ return cls(ErrorCode.METHOD_NOT_FOUND, f"Method not found: {method}")
64
+
65
+ @classmethod
66
+ def invalid_params(cls, message: str, data: Any = _MISSING) -> RpcError:
67
+ return cls(ErrorCode.INVALID_PARAMS, message, data)
68
+
69
+ @classmethod
70
+ def not_found(cls, message: str, data: Any = _MISSING) -> RpcError:
71
+ return cls(ErrorCode.RESOURCE_NOT_FOUND, message, data)
72
+
73
+ @classmethod
74
+ def internal(cls, message: str, data: Any = _MISSING) -> RpcError:
75
+ return cls(ErrorCode.INTERNAL_ERROR, message, data)
76
+
77
+ def to_json(self) -> dict[str, Any]:
78
+ out: dict[str, Any] = {"code": self.code, "message": self.message}
79
+ if self._data is not _MISSING:
80
+ out["data"] = self._data
81
+ return out
82
+
83
+ def __repr__(self) -> str:
84
+ return f"RpcError({self.code}, {self.message!r})"
85
+
86
+
87
+ LocalErrorKind = Literal["aborted", "timeout", "closed", "invalid-response", "send-failed"]
88
+ """Why this side gave up on a request.
89
+
90
+ - `aborted`: the caller's AbortSignal fired (or its task was cancelled).
91
+ - `timeout`: the request's deadline passed.
92
+ - `closed`: the connection closed before an answer arrived.
93
+ - `invalid-response`: the answer was not a valid JSON-RPC response.
94
+ - `send-failed`: the request could not be written.
95
+ """
96
+
97
+
98
+ class LocalError(SubspaceError):
99
+ def __init__(self, kind: LocalErrorKind, message: str, *, cause: Any = None, method: str | None = None) -> None:
100
+ super().__init__(message)
101
+ self.kind: LocalErrorKind = kind
102
+ self.message = message
103
+ self.method = method
104
+ self.cause = cause
105
+ if isinstance(cause, BaseException):
106
+ self.__cause__ = cause
107
+
108
+ @staticmethod
109
+ def is_(value: object, kind: LocalErrorKind | None = None) -> bool:
110
+ """Whether `value` is a LocalError (of `kind`, if given)."""
111
+ return isinstance(value, LocalError) and (kind is None or value.kind == kind)
112
+
113
+ def __repr__(self) -> str:
114
+ return f"LocalError({self.kind!r}, {self.message!r})"