deft-subspace-kernel 0.1.0a1__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.
- deft_subspace_kernel-0.1.0a1/.gitignore +16 -0
- deft_subspace_kernel-0.1.0a1/LICENSE +21 -0
- deft_subspace_kernel-0.1.0a1/PKG-INFO +41 -0
- deft_subspace_kernel-0.1.0a1/README.md +16 -0
- deft_subspace_kernel-0.1.0a1/pyproject.toml +36 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/__init__.py +93 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/abort.py +173 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/channel.py +244 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/clock.py +39 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/errors.py +114 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/framing.py +171 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/process.py +614 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/py.typed +0 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/rpc.py +815 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/testing.py +97 -0
- deft_subspace_kernel-0.1.0a1/src/deft_subspace/kernel/trace.py +143 -0
- deft_subspace_kernel-0.1.0a1/tests/test_abort.py +44 -0
- deft_subspace_kernel-0.1.0a1/tests/test_channel.py +35 -0
- deft_subspace_kernel-0.1.0a1/tests/test_framing.py +94 -0
- deft_subspace_kernel-0.1.0a1/tests/test_process.py +530 -0
- deft_subspace_kernel-0.1.0a1/tests/test_rpc.py +723 -0
- deft_subspace_kernel-0.1.0a1/tests/test_trace.py +83 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deft
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deft-subspace-kernel
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Protocol-neutral kernel: channels, NDJSON framing, a JSON-RPC peer with one owner for every request's lifecycle, and supervised agent processes. No dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/deftai/subspace/tree/main/python
|
|
6
|
+
Project-URL: Source, https://github.com/deftai/subspace
|
|
7
|
+
Project-URL: Issues, https://github.com/deftai/subspace/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/deftai/subspace/blob/main/python/CHANGELOG.md
|
|
9
|
+
Author: Deft
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: acp,agent-client-protocol,asyncio,json-rpc,ndjson
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# deft-subspace-kernel
|
|
27
|
+
|
|
28
|
+
The protocol-neutral kernel of [Deft Subspace for Python](https://github.com/deftai/subspace/tree/main/python): channels, NDJSON framing, a JSON-RPC 2.0 peer (timeouts, cancellation, middleware, one owner for every request), abort signals, traces, and supervised child processes. Plain asyncio, Python 3.11 or later, no dependencies.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from deft_subspace.kernel import RpcPeer, channel_pair
|
|
32
|
+
|
|
33
|
+
a, b = channel_pair()
|
|
34
|
+
server = RpcPeer(b).on_request("echo", lambda params, _ctx: params).start()
|
|
35
|
+
client = RpcPeer(a).start()
|
|
36
|
+
assert await client.request("echo", {"hi": 1}) == {"hi": 1}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Most programs want [`deft-subspace-acp`](https://pypi.org/project/deft-subspace-acp/), which builds the Agent Client Protocol on this.
|
|
40
|
+
|
|
41
|
+
MIT licensed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# deft-subspace-kernel
|
|
2
|
+
|
|
3
|
+
The protocol-neutral kernel of [Deft Subspace for Python](https://github.com/deftai/subspace/tree/main/python): channels, NDJSON framing, a JSON-RPC 2.0 peer (timeouts, cancellation, middleware, one owner for every request), abort signals, traces, and supervised child processes. Plain asyncio, Python 3.11 or later, no dependencies.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from deft_subspace.kernel import RpcPeer, channel_pair
|
|
7
|
+
|
|
8
|
+
a, b = channel_pair()
|
|
9
|
+
server = RpcPeer(b).on_request("echo", lambda params, _ctx: params).start()
|
|
10
|
+
client = RpcPeer(a).start()
|
|
11
|
+
assert await client.request("echo", {"hi": 1}) == {"hi": 1}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Most programs want [`deft-subspace-acp`](https://pypi.org/project/deft-subspace-acp/), which builds the Agent Client Protocol on this.
|
|
15
|
+
|
|
16
|
+
MIT licensed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "deft-subspace-kernel"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Protocol-neutral kernel: channels, NDJSON framing, a JSON-RPC peer with one owner for every request's lifecycle, and supervised agent processes. No dependencies."
|
|
5
|
+
keywords = ["json-rpc", "ndjson", "asyncio", "agent-client-protocol", "acp"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Deft" }]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: AsyncIO",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/deftai/subspace/tree/main/python"
|
|
27
|
+
Source = "https://github.com/deftai/subspace"
|
|
28
|
+
Issues = "https://github.com/deftai/subspace/issues"
|
|
29
|
+
Changelog = "https://github.com/deftai/subspace/blob/main/python/CHANGELOG.md"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling>=1.25"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/deft_subspace"]
|
|
@@ -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()
|