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,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
+ )