Coverage for core / src / sensorkit / data / streams.py: 78%
170 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2from __future__ import annotations
4import asyncio
5from collections.abc import Buffer
6from typing import Protocol, runtime_checkable
8type Buf = bytes | bytearray | memoryview
11@runtime_checkable
12class StreamReader(Protocol):
13 """A variant of the asyncio.StreamReader interface."""
15 def exception(self) -> Exception: ...
16 def at_eof(self) -> bool: ...
17 async def readline(self) -> Buf: ...
18 async def readuntil(self, separator=b"\n") -> Buf: ...
19 async def read(self, n=-1) -> Buf: ...
20 async def readexactly(self, n: int) -> Buf: ...
22 async def __aiter__(self):
23 while not self.at_eof():
24 yield await self.read()
27@runtime_checkable
28class StreamWriter(Protocol):
29 """A variant of the asyncio.StreamWriter interface."""
31 def write(self, data: bytes): ...
32 def close(self): ...
33 def is_closing(self) -> bool: ...
34 async def wait_closed(self): ...
35 async def drain(self): ...
38class BufferReader(StreamReader):
39 """A StreamReader that reads from an immutable input buffer."""
41 def __init__(self, data: Buffer):
42 self.data: Buf = memoryview(data)
43 self.ptr = 0
44 self._exception = None
46 def exception(self) -> Exception:
47 return self._exception
49 def at_eof(self) -> bool:
50 return self.ptr >= len(self.data)
52 async def readline(self) -> Buf:
53 return await self.readuntil()
55 async def readuntil(self, separator=b"\n") -> Buf:
56 try:
57 start = self.ptr
58 sep_len = len(separator)
60 # Find the separator in the buffer
61 for i in range(start, len(self.data) - sep_len + 1):
62 if self.data[i:i+sep_len] == separator:
63 self.ptr = i + sep_len
64 return self.data[start:self.ptr]
66 # Separator not found, return the rest of the buffer
67 self.ptr = len(self.data)
68 return self.data[start:]
69 except Exception as e:
70 self._exception = e
71 raise
73 async def read(self, n=-1) -> Buf:
74 try:
75 start = self.ptr
76 end = min(start + n, len(self.data)) if n >= 0 else len(self.data)
77 self.ptr = end
78 return self.data[start:end]
79 except Exception as e:
80 self._exception = e
81 raise
83 async def readexactly(self, n: int) -> Buf:
84 buf = await self.read(n)
86 if len(buf) != n:
87 raise asyncio.IncompleteReadError(bytes(buf), n)
89 return buf
92class BufferWriter(StreamWriter):
93 """A StreamWriter that writes to a buffer."""
95 def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
96 loop = loop or asyncio.get_event_loop()
97 self._future: asyncio.Future[Buffer] = loop.create_future()
98 self._bytes = bytearray()
100 def write(self, data: bytes):
101 self._bytes.extend(data)
103 def write_eof(self):
104 # No eof.
105 pass
107 def close(self):
108 self._future.set_result(self._bytes)
110 def is_closing(self) -> bool:
111 return self._future.done()
113 async def wait_closed(self):
114 await self._future
116 async def drain(self):
117 # Writes are immediate.
118 pass
120 def get_future(self):
121 """Return a Future that resolves to the accumulated buffer once the writer is closed."""
122 return self._future
125class QueueReader(StreamReader):
126 """A StreamReader that reads from a queue with minimal copying."""
128 def __init__(self, queue: asyncio.Queue[bytes | None]):
129 self._queue = queue
130 self._buffer = bytearray() # Only used for partial chunks
131 self._eof = False
132 self._exception: Exception | None = None
134 def exception(self) -> Exception:
135 return self._exception
137 def at_eof(self) -> bool:
138 return self._eof and len(self._buffer) == 0
140 async def _get_next_chunk(self):
141 """Get next chunk from queue or return None on EOF."""
142 if self._eof:
143 return None
145 try:
146 chunk = await self._queue.get()
147 self._queue.task_done()
149 # Check for end of stream marker.
150 if chunk is None:
151 self._eof = True
152 return None
154 return chunk
155 except Exception as e:
156 self._exception = e
157 raise
159 async def read(self, n=-1) -> Buf:
160 if n == 0:
161 return b""
163 # If we have data in the buffer, handle that first
164 if self._buffer:
165 if n == -1:
166 n = len(self._buffer)
168 if len(self._buffer) <= n:
169 remainder = bytearray()
170 else:
171 remainder = self._buffer[n:]
172 del self._buffer[n:]
174 result = self._buffer
175 self._buffer = remainder
176 return result
178 # No buffer data, read directly from queue
179 chunk = await self._get_next_chunk()
181 if chunk is None:
182 return b""
184 if n == -1:
185 n = len(chunk)
187 if len(chunk) <= n:
188 return chunk
190 # Store excess in buffer
191 view = memoryview(chunk)
192 self._buffer = bytearray(view[n:])
193 return view[:n]
195 async def readline(self) -> Buf:
196 return await self.readuntil()
198 async def readuntil(self, separator=b"\n") -> Buf:
199 idx = 0
201 while True:
202 # Check whether the newest part of the current buffer has the needle.
203 try:
204 idx = self._buffer.index(separator, idx) + len(separator)
205 remainder = self._buffer[idx:]
206 del self._buffer[idx:]
207 result = self._buffer
208 self._buffer = remainder
209 return result
210 except ValueError:
211 idx = len(self._buffer) - len(separator) + 1
213 # Read the next chunk from the queue.
214 chunk = await self._get_next_chunk()
216 if chunk is None:
217 result = self._buffer
218 self._buffer = bytearray()
219 return result
221 self._buffer.extend(chunk)
223 async def readexactly(self, n: int) -> Buf:
224 while len(self._buffer) < n:
225 chunk = await self._get_next_chunk()
227 if chunk is None:
228 partial = bytes(self._buffer)
229 self._buffer = bytearray()
230 raise asyncio.IncompleteReadError(partial, n)
232 self._buffer.extend(chunk)
234 remainder = self._buffer[n:]
235 del self._buffer[n:]
236 result = self._buffer
237 self._buffer = remainder
238 return result
241class QueueWriter(StreamWriter):
242 """A StreamWriter that writes to a queue."""
244 def __init__(self, queue: asyncio.Queue[bytes | None]):
245 self._queue = queue
246 self._closed = False
248 def write(self, data: bytes):
249 if self._closed:
250 raise RuntimeError("Writer is closed")
251 self._queue.put_nowait(data)
253 def close(self):
254 if not self._closed:
255 # Signal end of stream.
256 self._queue.put_nowait(None)
257 self._closed = True
259 def is_closing(self) -> bool:
260 return self._closed
262 async def wait_closed(self):
263 if self._closed:
264 await self._queue.join()
266 async def drain(self):
267 # Writes are immediate.
268 pass
271def create_connected_streams() -> tuple[StreamReader, StreamWriter]:
272 """Return a pair of streams that are connected by a queue."""
273 queue = asyncio.Queue()
274 return QueueReader(queue), QueueWriter(queue)