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.
- deft_subspace/kernel/__init__.py +93 -0
- deft_subspace/kernel/abort.py +173 -0
- deft_subspace/kernel/channel.py +244 -0
- deft_subspace/kernel/clock.py +39 -0
- deft_subspace/kernel/errors.py +114 -0
- deft_subspace/kernel/framing.py +171 -0
- deft_subspace/kernel/process.py +614 -0
- deft_subspace/kernel/py.typed +0 -0
- deft_subspace/kernel/rpc.py +815 -0
- deft_subspace/kernel/testing.py +97 -0
- deft_subspace/kernel/trace.py +143 -0
- deft_subspace_kernel-0.1.0a1.dist-info/METADATA +41 -0
- deft_subspace_kernel-0.1.0a1.dist-info/RECORD +15 -0
- deft_subspace_kernel-0.1.0a1.dist-info/WHEEL +4 -0
- deft_subspace_kernel-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""NDJSON: one JSON value per line, over a byte channel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from .channel import Channel, pipe_channel
|
|
12
|
+
from .errors import SubspaceError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class FramingDiagnostic:
|
|
17
|
+
"""Something on the wire that was skipped instead of delivered."""
|
|
18
|
+
|
|
19
|
+
kind: Literal["invalid-json", "too-large"]
|
|
20
|
+
#: Size of the skipped line in characters (close to bytes for ASCII).
|
|
21
|
+
size: int
|
|
22
|
+
#: The start of the skipped line, for logs. At most 200 characters.
|
|
23
|
+
preview: str
|
|
24
|
+
error: BaseException | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_MAX_LINE = 32 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _no_constant(name: str) -> Any:
|
|
31
|
+
# NaN and Infinity are not JSON.
|
|
32
|
+
raise ValueError(f"{name} is not JSON")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NdjsonDecoder:
|
|
36
|
+
"""Split bytes into JSON values, one per line.
|
|
37
|
+
|
|
38
|
+
- UTF-8 is decoded incrementally, so a character split across chunks survives.
|
|
39
|
+
- Each byte is looked at a bounded number of times, so long lines cost O(n).
|
|
40
|
+
- Lines that are not JSON are skipped and reported, not fatal.
|
|
41
|
+
- CRLF line endings and blank lines are accepted.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
max_line_length: int = DEFAULT_MAX_LINE,
|
|
48
|
+
on_diagnostic: Callable[[FramingDiagnostic], None] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._max = max_line_length
|
|
51
|
+
self._on_diagnostic = on_diagnostic
|
|
52
|
+
# utf-8-sig: a byte order mark at the very start is skipped, as TextDecoder does.
|
|
53
|
+
self._decoder = codecs.getincrementaldecoder("utf-8-sig")(errors="replace")
|
|
54
|
+
self._pieces: list[str] = []
|
|
55
|
+
self._pending = 0
|
|
56
|
+
self._skipping = False # inside an over-long line; drop until the next newline
|
|
57
|
+
|
|
58
|
+
def _report(self, diagnostic: FramingDiagnostic) -> None:
|
|
59
|
+
if self._on_diagnostic is None:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
self._on_diagnostic(diagnostic)
|
|
63
|
+
except Exception: # noqa: BLE001 - a broken diagnostics hook must not break the stream
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def _emit(self, line: str, out: list[Any]) -> None:
|
|
67
|
+
text = line[:-1] if line.endswith("\r") else line
|
|
68
|
+
if not text.strip():
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
out.append(json.loads(text, parse_constant=_no_constant))
|
|
72
|
+
except ValueError as error:
|
|
73
|
+
self._report(FramingDiagnostic("invalid-json", len(text), text[:200], error))
|
|
74
|
+
|
|
75
|
+
def _take(self, text: str, out: list[Any]) -> None:
|
|
76
|
+
start = 0
|
|
77
|
+
while True:
|
|
78
|
+
newline = text.find("\n", start)
|
|
79
|
+
if newline == -1:
|
|
80
|
+
break
|
|
81
|
+
tail = text[start:newline]
|
|
82
|
+
if self._skipping:
|
|
83
|
+
self._skipping = False
|
|
84
|
+
elif self._pending + len(tail) > self._max:
|
|
85
|
+
first = self._pieces[0] if self._pieces else tail
|
|
86
|
+
self._report(FramingDiagnostic("too-large", self._pending + len(tail), first[:200]))
|
|
87
|
+
else:
|
|
88
|
+
self._emit("".join(self._pieces) + tail if self._pieces else tail, out)
|
|
89
|
+
self._pieces = []
|
|
90
|
+
self._pending = 0
|
|
91
|
+
start = newline + 1
|
|
92
|
+
rest = text[start:]
|
|
93
|
+
if rest and not self._skipping:
|
|
94
|
+
if self._pending + len(rest) > self._max:
|
|
95
|
+
first = self._pieces[0] if self._pieces else rest
|
|
96
|
+
self._report(FramingDiagnostic("too-large", self._pending + len(rest), first[:200]))
|
|
97
|
+
self._pieces = []
|
|
98
|
+
self._pending = 0
|
|
99
|
+
self._skipping = True
|
|
100
|
+
else:
|
|
101
|
+
self._pieces.append(rest)
|
|
102
|
+
self._pending += len(rest)
|
|
103
|
+
|
|
104
|
+
def feed(self, chunk: bytes) -> list[Any]:
|
|
105
|
+
out: list[Any] = []
|
|
106
|
+
self._take(self._decoder.decode(chunk), out)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def flush(self) -> list[Any]:
|
|
110
|
+
out: list[Any] = []
|
|
111
|
+
self._take(self._decoder.decode(b"", final=True), out)
|
|
112
|
+
# A final line without a newline still counts.
|
|
113
|
+
if not self._skipping and self._pieces:
|
|
114
|
+
self._emit("".join(self._pieces), out)
|
|
115
|
+
self._pieces = []
|
|
116
|
+
self._pending = 0
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EncodeError(ValueError, SubspaceError):
|
|
121
|
+
"""A value that has no JSON form (a cycle, a datetime, NaN)."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class NdjsonEncoder:
|
|
125
|
+
"""JSON values as lines of UTF-8.
|
|
126
|
+
|
|
127
|
+
A value that cannot be serialized is reported and raises `EncodeError`, so
|
|
128
|
+
that one send fails; nothing is written and later values still go out.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, *, on_error: Callable[[BaseException, Any], None] | None = None) -> None:
|
|
132
|
+
self._on_error = on_error
|
|
133
|
+
|
|
134
|
+
def feed(self, value: Any) -> Iterable[bytes]:
|
|
135
|
+
try:
|
|
136
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
137
|
+
except (TypeError, ValueError, RecursionError) as error:
|
|
138
|
+
if self._on_error is not None:
|
|
139
|
+
try:
|
|
140
|
+
self._on_error(error, value)
|
|
141
|
+
except Exception: # noqa: BLE001 - a broken error hook must not break the stream
|
|
142
|
+
pass
|
|
143
|
+
raise EncodeError(f"Value cannot be encoded as JSON: {error}") from error
|
|
144
|
+
return ((text + "\n").encode("utf-8"),)
|
|
145
|
+
|
|
146
|
+
def flush(self) -> Iterable[bytes]:
|
|
147
|
+
return ()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ndjson_decoder(
|
|
151
|
+
*, max_line_length: int = DEFAULT_MAX_LINE, on_diagnostic: Callable[[FramingDiagnostic], None] | None = None
|
|
152
|
+
) -> NdjsonDecoder:
|
|
153
|
+
return NdjsonDecoder(max_line_length=max_line_length, on_diagnostic=on_diagnostic)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ndjson_encoder(*, on_error: Callable[[BaseException, Any], None] | None = None) -> NdjsonEncoder:
|
|
157
|
+
return NdjsonEncoder(on_error=on_error)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ndjson(
|
|
161
|
+
channel: Channel[bytes],
|
|
162
|
+
*,
|
|
163
|
+
max_line_length: int = DEFAULT_MAX_LINE,
|
|
164
|
+
on_diagnostic: Callable[[FramingDiagnostic], None] | None = None,
|
|
165
|
+
) -> Channel[Any]:
|
|
166
|
+
"""A byte channel (stdio, a socket) as a channel of JSON values."""
|
|
167
|
+
return pipe_channel(
|
|
168
|
+
channel,
|
|
169
|
+
read=NdjsonDecoder(max_line_length=max_line_length, on_diagnostic=on_diagnostic),
|
|
170
|
+
write=NdjsonEncoder(),
|
|
171
|
+
)
|