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.
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ *.tsbuildinfo
5
+ .DS_Store
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ __pycache__/
10
+ *.pyc
11
+ .venv/
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+
15
+ # A stray self-symlink to the checkout
16
+ /subspace
@@ -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()