axio 0.3.4__tar.gz → 0.3.5__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.
- {axio-0.3.4 → axio-0.3.5}/PKG-INFO +1 -1
- {axio-0.3.4 → axio-0.3.5}/pyproject.toml +1 -1
- {axio-0.3.4 → axio-0.3.5}/src/axio/events.py +33 -1
- axio-0.3.5/src/axio/tool_args.py +243 -0
- axio-0.3.5/tests/test_tool_args.py +753 -0
- {axio-0.3.4 → axio-0.3.5}/.github/workflows/publish.yml +0 -0
- {axio-0.3.4 → axio-0.3.5}/.github/workflows/tests.yml +0 -0
- {axio-0.3.4 → axio-0.3.5}/.gitignore +0 -0
- {axio-0.3.4 → axio-0.3.5}/LICENSE +0 -0
- {axio-0.3.4 → axio-0.3.5}/Makefile +0 -0
- {axio-0.3.4 → axio-0.3.5}/README.md +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/__init__.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/agent.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/blocks.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/context.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/exceptions.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/messages.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/models.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/permission.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/py.typed +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/selector.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/stream.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/testing.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/tool.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/transport.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/src/axio/types.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/conftest.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_agent_branch.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_agent_permission.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_agent_run.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_agent_stream.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_agent_tools.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_blocks.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_context.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_events.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_exceptions.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_permission.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_selector.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_stream.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_tool.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_transport.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/tests/test_types.py +0 -0
- {axio-0.3.4 → axio-0.3.5}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axio
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/axio-agent/axio
|
|
6
6
|
Project-URL: Repository, https://github.com/axio-agent/axio
|
|
@@ -34,6 +34,28 @@ class ToolInputDelta:
|
|
|
34
34
|
partial_json: str
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ToolFieldStart:
|
|
39
|
+
index: int
|
|
40
|
+
tool_use_id: ToolCallID
|
|
41
|
+
key: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ToolFieldDelta:
|
|
46
|
+
index: int
|
|
47
|
+
tool_use_id: ToolCallID
|
|
48
|
+
key: str
|
|
49
|
+
text: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class ToolFieldEnd:
|
|
54
|
+
index: int
|
|
55
|
+
tool_use_id: ToolCallID
|
|
56
|
+
key: str
|
|
57
|
+
|
|
58
|
+
|
|
37
59
|
@dataclass(frozen=True, slots=True)
|
|
38
60
|
class ToolResult:
|
|
39
61
|
tool_use_id: ToolCallID
|
|
@@ -62,5 +84,15 @@ class SessionEndEvent:
|
|
|
62
84
|
|
|
63
85
|
|
|
64
86
|
type StreamEvent = (
|
|
65
|
-
ReasoningDelta
|
|
87
|
+
ReasoningDelta
|
|
88
|
+
| TextDelta
|
|
89
|
+
| ToolUseStart
|
|
90
|
+
| ToolInputDelta
|
|
91
|
+
| ToolFieldStart
|
|
92
|
+
| ToolFieldDelta
|
|
93
|
+
| ToolFieldEnd
|
|
94
|
+
| ToolResult
|
|
95
|
+
| IterationEnd
|
|
96
|
+
| Error
|
|
97
|
+
| SessionEndEvent
|
|
66
98
|
)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Incremental streaming parser for tool call JSON arguments.
|
|
2
|
+
|
|
3
|
+
Feeds partial JSON chunks (from ``ToolInputDelta.partial_json``) and emits
|
|
4
|
+
structured ``ToolField*`` events as top-level object fields are discovered.
|
|
5
|
+
|
|
6
|
+
Top-level *string* values are decoded (escape sequences resolved, quotes
|
|
7
|
+
stripped). All other top-level values are emitted as raw JSON fragments.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
|
|
16
|
+
from axio.events import ToolFieldDelta, ToolFieldEnd, ToolFieldStart
|
|
17
|
+
|
|
18
|
+
type ToolFieldEvent = ToolFieldStart | ToolFieldDelta | ToolFieldEnd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class State(IntEnum):
|
|
22
|
+
INIT = 0
|
|
23
|
+
OBJ = 1
|
|
24
|
+
KEY = 2
|
|
25
|
+
COLON = 3
|
|
26
|
+
VAL = 4
|
|
27
|
+
STR = 5
|
|
28
|
+
RAW = 6
|
|
29
|
+
AFTER = 7
|
|
30
|
+
ESC = 8
|
|
31
|
+
UESC = 9
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ESCAPES: Mapping[str, str] = MappingProxyType(
|
|
35
|
+
{
|
|
36
|
+
"n": "\n",
|
|
37
|
+
"t": "\t",
|
|
38
|
+
"r": "\r",
|
|
39
|
+
"b": "\b",
|
|
40
|
+
"f": "\f",
|
|
41
|
+
'"': '"',
|
|
42
|
+
"\\": "\\",
|
|
43
|
+
"/": "/",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolArgStream:
|
|
49
|
+
"""O(1)-per-character streaming parser for tool argument JSON.
|
|
50
|
+
|
|
51
|
+
Usage::
|
|
52
|
+
|
|
53
|
+
stream = ToolArgStream("call_1")
|
|
54
|
+
events = stream.feed('{"path":"/tmp/f')
|
|
55
|
+
# [ToolFieldStart(0, "call_1", "path"), ToolFieldDelta(0, "call_1", "path", "/tmp/f")]
|
|
56
|
+
events = stream.feed('oo.py"}')
|
|
57
|
+
# [ToolFieldDelta(0, "call_1", "path", "oo.py"), ToolFieldEnd(0, "call_1", "path")]
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
__slots__ = (
|
|
61
|
+
"_id",
|
|
62
|
+
"_idx",
|
|
63
|
+
"_st",
|
|
64
|
+
"_key_chars",
|
|
65
|
+
"_key",
|
|
66
|
+
"_buf",
|
|
67
|
+
"_u",
|
|
68
|
+
"_high",
|
|
69
|
+
"_depth",
|
|
70
|
+
"_raw_str",
|
|
71
|
+
"_raw_esc",
|
|
72
|
+
"_esc_key",
|
|
73
|
+
"_esc_ret",
|
|
74
|
+
"_events",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def __init__(self, tool_use_id: str, index: int = 0) -> None:
|
|
78
|
+
self._id = tool_use_id
|
|
79
|
+
self._idx = index
|
|
80
|
+
self._st = State.INIT
|
|
81
|
+
self._key_chars: list[str] = []
|
|
82
|
+
self._key = ""
|
|
83
|
+
self._buf: list[str] = []
|
|
84
|
+
self._u: list[str] = []
|
|
85
|
+
self._high = 0
|
|
86
|
+
self._depth = 0
|
|
87
|
+
self._raw_str = False
|
|
88
|
+
self._raw_esc = False
|
|
89
|
+
self._esc_key = False
|
|
90
|
+
self._esc_ret = State.KEY
|
|
91
|
+
self._events: list[ToolFieldEvent] = []
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def current_key(self) -> str:
|
|
95
|
+
"""The field currently being streamed, or ``""``."""
|
|
96
|
+
return self._key
|
|
97
|
+
|
|
98
|
+
def feed(self, chunk: str) -> list[ToolFieldEvent]:
|
|
99
|
+
"""Process a partial JSON chunk and return any field events produced."""
|
|
100
|
+
self._events = []
|
|
101
|
+
for ch in chunk:
|
|
102
|
+
self._step(ch)
|
|
103
|
+
self._flush()
|
|
104
|
+
return self._events
|
|
105
|
+
|
|
106
|
+
def _flush(self) -> None:
|
|
107
|
+
if self._buf:
|
|
108
|
+
self._events.append(ToolFieldDelta(self._idx, self._id, self._key, "".join(self._buf)))
|
|
109
|
+
self._buf.clear()
|
|
110
|
+
|
|
111
|
+
def _start(self) -> None:
|
|
112
|
+
self._flush()
|
|
113
|
+
self._events.append(ToolFieldStart(self._idx, self._id, self._key))
|
|
114
|
+
|
|
115
|
+
def _end(self) -> None:
|
|
116
|
+
self._flush()
|
|
117
|
+
self._events.append(ToolFieldEnd(self._idx, self._id, self._key))
|
|
118
|
+
|
|
119
|
+
def _step(self, ch: str) -> None: # noqa: PLR0912
|
|
120
|
+
match self._st:
|
|
121
|
+
case State.INIT:
|
|
122
|
+
if ch == "{":
|
|
123
|
+
self._st = State.OBJ
|
|
124
|
+
|
|
125
|
+
case State.OBJ:
|
|
126
|
+
if ch == '"':
|
|
127
|
+
self._key_chars.clear()
|
|
128
|
+
self._st = State.KEY
|
|
129
|
+
elif ch == "}":
|
|
130
|
+
self._st = State.INIT
|
|
131
|
+
|
|
132
|
+
case State.KEY:
|
|
133
|
+
if ch == "\\":
|
|
134
|
+
self._esc_key = True
|
|
135
|
+
self._esc_ret = State.KEY
|
|
136
|
+
self._st = State.ESC
|
|
137
|
+
elif ch == '"':
|
|
138
|
+
self._key = "".join(self._key_chars)
|
|
139
|
+
self._st = State.COLON
|
|
140
|
+
else:
|
|
141
|
+
self._key_chars.append(ch)
|
|
142
|
+
|
|
143
|
+
case State.COLON:
|
|
144
|
+
if ch == ":":
|
|
145
|
+
self._start()
|
|
146
|
+
self._st = State.VAL
|
|
147
|
+
|
|
148
|
+
case State.VAL:
|
|
149
|
+
if ch in " \t\r\n":
|
|
150
|
+
pass
|
|
151
|
+
elif ch == '"':
|
|
152
|
+
self._st = State.STR
|
|
153
|
+
else:
|
|
154
|
+
self._buf.append(ch)
|
|
155
|
+
self._depth = 1 if ch in "{[" else 0
|
|
156
|
+
self._raw_str = False
|
|
157
|
+
self._raw_esc = False
|
|
158
|
+
self._st = State.RAW
|
|
159
|
+
|
|
160
|
+
case State.STR:
|
|
161
|
+
if ch == "\\":
|
|
162
|
+
self._esc_key = False
|
|
163
|
+
self._esc_ret = State.STR
|
|
164
|
+
self._st = State.ESC
|
|
165
|
+
elif ch == '"':
|
|
166
|
+
if self._high:
|
|
167
|
+
self._buf.append("\ufffd")
|
|
168
|
+
self._high = 0
|
|
169
|
+
self._end()
|
|
170
|
+
self._st = State.AFTER
|
|
171
|
+
else:
|
|
172
|
+
if self._high:
|
|
173
|
+
self._buf.append("\ufffd")
|
|
174
|
+
self._high = 0
|
|
175
|
+
self._buf.append(ch)
|
|
176
|
+
|
|
177
|
+
case State.RAW:
|
|
178
|
+
if self._raw_str:
|
|
179
|
+
self._buf.append(ch)
|
|
180
|
+
if self._raw_esc:
|
|
181
|
+
self._raw_esc = False
|
|
182
|
+
elif ch == "\\":
|
|
183
|
+
self._raw_esc = True
|
|
184
|
+
elif ch == '"':
|
|
185
|
+
self._raw_str = False
|
|
186
|
+
elif self._depth == 0 and ch in " \t\r\n,}":
|
|
187
|
+
# simple value (number/bool/null) ends on whitespace or delimiter
|
|
188
|
+
self._end()
|
|
189
|
+
self._st = State.AFTER
|
|
190
|
+
if ch in ",}":
|
|
191
|
+
self._step(ch) # reprocess delimiter
|
|
192
|
+
elif ch == '"':
|
|
193
|
+
self._buf.append(ch)
|
|
194
|
+
self._raw_str = True
|
|
195
|
+
elif ch in "{[":
|
|
196
|
+
self._buf.append(ch)
|
|
197
|
+
self._depth += 1
|
|
198
|
+
elif ch in "}]":
|
|
199
|
+
self._buf.append(ch)
|
|
200
|
+
self._depth -= 1
|
|
201
|
+
if self._depth == 0:
|
|
202
|
+
self._end()
|
|
203
|
+
self._st = State.AFTER
|
|
204
|
+
else:
|
|
205
|
+
self._buf.append(ch)
|
|
206
|
+
|
|
207
|
+
case State.AFTER:
|
|
208
|
+
if ch == ",":
|
|
209
|
+
self._st = State.OBJ
|
|
210
|
+
elif ch == "}":
|
|
211
|
+
self._st = State.INIT
|
|
212
|
+
|
|
213
|
+
case State.ESC:
|
|
214
|
+
if ch == "u":
|
|
215
|
+
self._u.clear()
|
|
216
|
+
self._st = State.UESC
|
|
217
|
+
else:
|
|
218
|
+
dec = ESCAPES.get(ch, ch)
|
|
219
|
+
if self._esc_key:
|
|
220
|
+
self._key_chars.append(dec)
|
|
221
|
+
else:
|
|
222
|
+
self._buf.append(dec)
|
|
223
|
+
self._st = self._esc_ret
|
|
224
|
+
|
|
225
|
+
case State.UESC:
|
|
226
|
+
self._u.append(ch)
|
|
227
|
+
if len(self._u) == 4:
|
|
228
|
+
code = int("".join(self._u), 16)
|
|
229
|
+
if self._esc_key:
|
|
230
|
+
self._key_chars.append(chr(code))
|
|
231
|
+
elif self._high:
|
|
232
|
+
if 0xDC00 <= code <= 0xDFFF:
|
|
233
|
+
full = 0x10000 + (self._high - 0xD800) * 0x400 + (code - 0xDC00)
|
|
234
|
+
self._buf.append(chr(full))
|
|
235
|
+
else:
|
|
236
|
+
self._buf.append("\ufffd")
|
|
237
|
+
self._buf.append(chr(code))
|
|
238
|
+
self._high = 0
|
|
239
|
+
elif 0xD800 <= code <= 0xDBFF:
|
|
240
|
+
self._high = code
|
|
241
|
+
else:
|
|
242
|
+
self._buf.append(chr(code))
|
|
243
|
+
self._st = self._esc_ret
|
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"""Tests for ToolArgStream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cProfile
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from axio.events import ToolFieldDelta, ToolFieldEnd, ToolFieldStart
|
|
12
|
+
from axio.tool_args import ToolArgStream, ToolFieldEvent
|
|
13
|
+
|
|
14
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def collect(payload: str, chunk_size: int | None = None) -> dict[str, str]:
|
|
18
|
+
"""Feed payload through a fresh stream, return {key: decoded_text}."""
|
|
19
|
+
s = ToolArgStream("c")
|
|
20
|
+
events: list[ToolFieldEvent] = []
|
|
21
|
+
if chunk_size:
|
|
22
|
+
for i in range(0, len(payload), chunk_size):
|
|
23
|
+
events.extend(s.feed(payload[i : i + chunk_size]))
|
|
24
|
+
else:
|
|
25
|
+
events = s.feed(payload)
|
|
26
|
+
buf: dict[str, list[str]] = {}
|
|
27
|
+
for e in events:
|
|
28
|
+
if isinstance(e, ToolFieldStart):
|
|
29
|
+
buf[e.key] = []
|
|
30
|
+
elif isinstance(e, ToolFieldDelta):
|
|
31
|
+
buf[e.key].append(e.text)
|
|
32
|
+
return {k: "".join(v) for k, v in buf.items()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def S(key: str, idx: int = 0, tid: str = "c1") -> ToolFieldStart:
|
|
36
|
+
return ToolFieldStart(idx, tid, key)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def D(key: str, text: str, idx: int = 0, tid: str = "c1") -> ToolFieldDelta:
|
|
40
|
+
return ToolFieldDelta(idx, tid, key, text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def E(key: str, idx: int = 0, tid: str = "c1") -> ToolFieldEnd:
|
|
44
|
+
return ToolFieldEnd(idx, tid, key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def feed(json: str, tid: str = "c1") -> list[ToolFieldEvent]:
|
|
48
|
+
return ToolArgStream(tid).feed(json)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Basic ──────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestBasic:
|
|
55
|
+
def test_single_string(self) -> None:
|
|
56
|
+
assert feed('{"path": "/tmp/foo.py"}') == [
|
|
57
|
+
S("path"),
|
|
58
|
+
D("path", "/tmp/foo.py"),
|
|
59
|
+
E("path"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
def test_two_fields(self) -> None:
|
|
63
|
+
assert feed('{"a": "x", "b": "y"}') == [
|
|
64
|
+
S("a"),
|
|
65
|
+
D("a", "x"),
|
|
66
|
+
E("a"),
|
|
67
|
+
S("b"),
|
|
68
|
+
D("b", "y"),
|
|
69
|
+
E("b"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def test_empty_object(self) -> None:
|
|
73
|
+
assert feed("{}") == []
|
|
74
|
+
|
|
75
|
+
def test_current_key(self) -> None:
|
|
76
|
+
s = ToolArgStream("c1")
|
|
77
|
+
s.feed('{"path": "/a"}')
|
|
78
|
+
assert s.current_key == "path"
|
|
79
|
+
|
|
80
|
+
def test_index_propagated(self) -> None:
|
|
81
|
+
events = ToolArgStream("c1", index=3).feed('{"k": "v"}')
|
|
82
|
+
assert all(e.index == 3 for e in events)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Chunked ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestChunked:
|
|
89
|
+
def test_split_mid_value(self) -> None:
|
|
90
|
+
s = ToolArgStream("c1")
|
|
91
|
+
e1 = s.feed('{"path":"/tmp/f')
|
|
92
|
+
e2 = s.feed('oo.py"}')
|
|
93
|
+
assert e1 == [S("path"), D("path", "/tmp/f")]
|
|
94
|
+
assert e2 == [D("path", "oo.py"), E("path")]
|
|
95
|
+
|
|
96
|
+
def test_split_mid_key(self) -> None:
|
|
97
|
+
s = ToolArgStream("c1")
|
|
98
|
+
e1 = s.feed('{"pa')
|
|
99
|
+
e2 = s.feed('th":"v"}')
|
|
100
|
+
assert e1 == []
|
|
101
|
+
assert e2 == [S("path"), D("path", "v"), E("path")]
|
|
102
|
+
|
|
103
|
+
def test_one_char_at_a_time(self) -> None:
|
|
104
|
+
"""Char-by-char and batch produce same semantic output (Delta batching may differ)."""
|
|
105
|
+
|
|
106
|
+
def summarise(events: list[ToolFieldEvent]) -> dict[str, object]:
|
|
107
|
+
starts = {e.key for e in events if isinstance(e, ToolFieldStart)}
|
|
108
|
+
ends = {e.key for e in events if isinstance(e, ToolFieldEnd)}
|
|
109
|
+
text = {
|
|
110
|
+
key: "".join(e.text for e in events if isinstance(e, ToolFieldDelta) and e.key == key)
|
|
111
|
+
for key in starts
|
|
112
|
+
}
|
|
113
|
+
return {"starts": starts, "ends": ends, "text": text}
|
|
114
|
+
|
|
115
|
+
full = ToolArgStream("c1").feed('{"k":"hello"}')
|
|
116
|
+
char = ToolArgStream("c1")
|
|
117
|
+
events: list[ToolFieldEvent] = []
|
|
118
|
+
for ch in '{"k":"hello"}':
|
|
119
|
+
events.extend(char.feed(ch))
|
|
120
|
+
assert summarise(events) == summarise(full)
|
|
121
|
+
|
|
122
|
+
def test_empty_chunks(self) -> None:
|
|
123
|
+
s = ToolArgStream("c1")
|
|
124
|
+
s.feed("")
|
|
125
|
+
s.feed("")
|
|
126
|
+
events = s.feed('{"x":"y"}')
|
|
127
|
+
assert events == [S("x"), D("x", "y"), E("x")]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Raw values (non-string) ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestRaw:
|
|
134
|
+
def test_integer(self) -> None:
|
|
135
|
+
events = feed('{"n": 42}')
|
|
136
|
+
assert events == [S("n"), D("n", "42"), E("n")]
|
|
137
|
+
|
|
138
|
+
def test_float(self) -> None:
|
|
139
|
+
events = feed('{"x": 3.14}')
|
|
140
|
+
assert events == [S("x"), D("x", "3.14"), E("x")]
|
|
141
|
+
|
|
142
|
+
def test_boolean_true(self) -> None:
|
|
143
|
+
events = feed('{"ok": true}')
|
|
144
|
+
assert events == [S("ok"), D("ok", "true"), E("ok")]
|
|
145
|
+
|
|
146
|
+
def test_null(self) -> None:
|
|
147
|
+
events = feed('{"v": null}')
|
|
148
|
+
assert events == [S("v"), D("v", "null"), E("v")]
|
|
149
|
+
|
|
150
|
+
def test_nested_object(self) -> None:
|
|
151
|
+
events = feed('{"x": {"a": 1}}')
|
|
152
|
+
assert events == [S("x"), D("x", '{"a": 1}'), E("x")]
|
|
153
|
+
|
|
154
|
+
def test_array(self) -> None:
|
|
155
|
+
events = feed('{"xs": [1, 2]}')
|
|
156
|
+
assert events == [S("xs"), D("xs", "[1, 2]"), E("xs")]
|
|
157
|
+
|
|
158
|
+
def test_string_inside_nested_object(self) -> None:
|
|
159
|
+
# Braces/brackets inside strings within raw values must not confuse depth
|
|
160
|
+
events = feed('{"x": {"k": "a}b"}}')
|
|
161
|
+
assert events == [S("x"), D("x", '{"k": "a}b"}'), E("x")]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Escape sequences ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestEscapes:
|
|
168
|
+
def test_simple_escapes(self) -> None:
|
|
169
|
+
events = feed(r'{"s": "a\nb\tc"}')
|
|
170
|
+
assert events == [S("s"), D("s", "a\nb\tc"), E("s")]
|
|
171
|
+
|
|
172
|
+
def test_unicode_escape(self) -> None:
|
|
173
|
+
events = feed(r'{"s": "\u0041"}') # A
|
|
174
|
+
assert events == [S("s"), D("s", "A"), E("s")]
|
|
175
|
+
|
|
176
|
+
def test_surrogate_pair(self) -> None:
|
|
177
|
+
events = feed(r'{"s": "\uD83D\uDE00"}') # 😀
|
|
178
|
+
assert events == [S("s"), D("s", "😀"), E("s")]
|
|
179
|
+
|
|
180
|
+
def test_lone_high_surrogate_replaced(self) -> None:
|
|
181
|
+
events = feed('{"s": "\\uD800x"}')
|
|
182
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
183
|
+
assert "\ufffd" in text
|
|
184
|
+
|
|
185
|
+
def test_escaped_key(self) -> None:
|
|
186
|
+
events = feed(r'{"k\u0065y": "v"}') # "key"
|
|
187
|
+
assert events[0] == S("key")
|
|
188
|
+
|
|
189
|
+
def test_escape_split_across_chunks(self) -> None:
|
|
190
|
+
s = ToolArgStream("c1")
|
|
191
|
+
e1 = s.feed('{"s": "\\')
|
|
192
|
+
e2 = s.feed('n"}')
|
|
193
|
+
combined = "".join(ev.text for ev in e1 + e2 if isinstance(ev, ToolFieldDelta))
|
|
194
|
+
assert combined == "\n"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── Truncated / broken JSON ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestBroken:
|
|
201
|
+
"""Parser must never raise; partial output for truncated input is acceptable."""
|
|
202
|
+
|
|
203
|
+
@pytest.mark.parametrize(
|
|
204
|
+
"payload",
|
|
205
|
+
[
|
|
206
|
+
"",
|
|
207
|
+
"{",
|
|
208
|
+
'{"',
|
|
209
|
+
'{"k',
|
|
210
|
+
'{"key"',
|
|
211
|
+
'{"key":',
|
|
212
|
+
'{"key": ',
|
|
213
|
+
'{"key": "',
|
|
214
|
+
'{"key": "val', # truncated mid-string (no closing " or })
|
|
215
|
+
'{"key": "val"', # missing closing }
|
|
216
|
+
'{"key": 42', # truncated mid-raw-number
|
|
217
|
+
'{"key": tru', # truncated mid-literal
|
|
218
|
+
'{"key": nul',
|
|
219
|
+
'{"a": 1, "b":', # second field truncated after colon
|
|
220
|
+
'{"a": "x", "b": "y', # second field string truncated
|
|
221
|
+
"[1, 2, 3]", # not an object
|
|
222
|
+
'"string"', # not an object
|
|
223
|
+
"42", # not an object
|
|
224
|
+
"null",
|
|
225
|
+
"{{{{",
|
|
226
|
+
'{"k": "v"} extra', # trailing garbage — first field already complete
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
def test_no_exception(self, payload: str) -> None:
|
|
230
|
+
s = ToolArgStream("c")
|
|
231
|
+
try:
|
|
232
|
+
s.feed(payload)
|
|
233
|
+
except Exception as exc: # noqa: BLE001
|
|
234
|
+
pytest.fail(f"raised {type(exc).__name__}: {exc!r} for payload={payload!r}")
|
|
235
|
+
|
|
236
|
+
def test_truncated_string_emits_partial_delta(self) -> None:
|
|
237
|
+
"""Truncated stream: Start and partial Delta are emitted, End is not."""
|
|
238
|
+
s = ToolArgStream("c")
|
|
239
|
+
events = s.feed('{"path": "/tmp/f')
|
|
240
|
+
assert any(isinstance(e, ToolFieldStart) for e in events)
|
|
241
|
+
assert any(isinstance(e, ToolFieldDelta) for e in events)
|
|
242
|
+
assert not any(isinstance(e, ToolFieldEnd) for e in events)
|
|
243
|
+
|
|
244
|
+
def test_truncated_after_colon_emits_start_only(self) -> None:
|
|
245
|
+
s = ToolArgStream("c")
|
|
246
|
+
events = s.feed('{"key":')
|
|
247
|
+
assert events == [ToolFieldStart(0, "c", "key")]
|
|
248
|
+
|
|
249
|
+
def test_truncated_raw_emits_partial(self) -> None:
|
|
250
|
+
s = ToolArgStream("c")
|
|
251
|
+
events = s.feed('{"n": 123') # no closing }
|
|
252
|
+
starts = [e for e in events if isinstance(e, ToolFieldStart)]
|
|
253
|
+
assert len(starts) == 1 and starts[0].key == "n"
|
|
254
|
+
# no End emitted for incomplete value
|
|
255
|
+
assert not any(isinstance(e, ToolFieldEnd) for e in events)
|
|
256
|
+
|
|
257
|
+
def test_resumed_after_truncation(self) -> None:
|
|
258
|
+
"""Completing a truncated stream by feeding the rest gives full events."""
|
|
259
|
+
s = ToolArgStream("c")
|
|
260
|
+
s.feed('{"k": "hel')
|
|
261
|
+
events = s.feed('lo"}')
|
|
262
|
+
combined = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
263
|
+
assert combined == "lo"
|
|
264
|
+
assert any(isinstance(e, ToolFieldEnd) for e in events)
|
|
265
|
+
|
|
266
|
+
def test_invalid_escape_sequence(self) -> None:
|
|
267
|
+
"""Unknown escape like \\q: must not raise, best-effort passthrough."""
|
|
268
|
+
s = ToolArgStream("c")
|
|
269
|
+
events = s.feed(r'{"s": "\q"}')
|
|
270
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
271
|
+
assert "q" in text # \q → 'q' (passthrough)
|
|
272
|
+
|
|
273
|
+
def test_lone_low_surrogate(self) -> None:
|
|
274
|
+
r"""\\uDC00 with no preceding high surrogate: no crash."""
|
|
275
|
+
s = ToolArgStream("c")
|
|
276
|
+
events = s.feed(r'{"s": "\uDC00x"}')
|
|
277
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
278
|
+
assert "x" in text
|
|
279
|
+
|
|
280
|
+
def test_double_high_surrogate(self) -> None:
|
|
281
|
+
r"""\\uD800\\uD800: second high replaces first (no low between them)."""
|
|
282
|
+
s = ToolArgStream("c")
|
|
283
|
+
events = s.feed(r'{"s": "\uD800\uD800x"}')
|
|
284
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
285
|
+
assert "x" in text
|
|
286
|
+
|
|
287
|
+
def test_truncated_unicode_escape(self) -> None:
|
|
288
|
+
r"""\\u with fewer than 4 hex digits at end of stream: no crash."""
|
|
289
|
+
for partial in [r"\u", r"\uD", r"\uD8", r"\uD83"]:
|
|
290
|
+
s = ToolArgStream("c")
|
|
291
|
+
s.feed('{"s": "' + partial) # no closing anything
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── Round-trip against json.loads ─────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TestRoundTrip:
|
|
298
|
+
"""The ground truth: whatever json.dumps produces, our parser must decode correctly."""
|
|
299
|
+
|
|
300
|
+
@pytest.mark.parametrize(
|
|
301
|
+
"s",
|
|
302
|
+
[
|
|
303
|
+
"",
|
|
304
|
+
"hello",
|
|
305
|
+
"with spaces",
|
|
306
|
+
'has "quotes"',
|
|
307
|
+
"newline\nand\ttab",
|
|
308
|
+
"backslash\\here",
|
|
309
|
+
"unicode 😀 and 日本語",
|
|
310
|
+
"all escapes \n\t\r\b\f",
|
|
311
|
+
"braces { } [ ] inside string",
|
|
312
|
+
"colon : comma , inside string",
|
|
313
|
+
"null bytes \x00 inside",
|
|
314
|
+
"a" * 10_000,
|
|
315
|
+
],
|
|
316
|
+
)
|
|
317
|
+
def test_string_field(self, s: str) -> None:
|
|
318
|
+
payload = json.dumps({"v": s})
|
|
319
|
+
assert collect(payload)["v"] == s
|
|
320
|
+
|
|
321
|
+
@pytest.mark.parametrize(
|
|
322
|
+
"s",
|
|
323
|
+
[
|
|
324
|
+
"",
|
|
325
|
+
"hello",
|
|
326
|
+
'has "quotes"',
|
|
327
|
+
"newline\nand\ttab",
|
|
328
|
+
"a" * 1_000,
|
|
329
|
+
],
|
|
330
|
+
)
|
|
331
|
+
def test_string_field_chunked(self, s: str) -> None:
|
|
332
|
+
payload = json.dumps({"v": s})
|
|
333
|
+
for chunk_size in (1, 3, 7, 64):
|
|
334
|
+
assert collect(payload, chunk_size)["v"] == s, f"chunk_size={chunk_size}"
|
|
335
|
+
|
|
336
|
+
@pytest.mark.parametrize(
|
|
337
|
+
"obj",
|
|
338
|
+
[
|
|
339
|
+
{"n": 0},
|
|
340
|
+
{"n": -1},
|
|
341
|
+
{"n": 42},
|
|
342
|
+
{"n": 1_000_000},
|
|
343
|
+
{"f": 3.14},
|
|
344
|
+
{"f": -2.718},
|
|
345
|
+
{"f": 1e10},
|
|
346
|
+
{"f": 1.5e-3},
|
|
347
|
+
{"b": True},
|
|
348
|
+
{"b": False},
|
|
349
|
+
{"z": None},
|
|
350
|
+
],
|
|
351
|
+
)
|
|
352
|
+
def test_scalar_raw(self, obj: dict[str, object]) -> None:
|
|
353
|
+
payload = json.dumps(obj)
|
|
354
|
+
key = next(iter(obj))
|
|
355
|
+
raw = collect(payload)[key]
|
|
356
|
+
assert json.loads(raw) == obj[key]
|
|
357
|
+
|
|
358
|
+
@pytest.mark.parametrize(
|
|
359
|
+
"obj",
|
|
360
|
+
[
|
|
361
|
+
{"a": []},
|
|
362
|
+
{"a": [1]},
|
|
363
|
+
{"a": [1, 2, 3]},
|
|
364
|
+
{"a": ["x", "y"]},
|
|
365
|
+
{"a": [{"b": 1}, {"c": 2}]},
|
|
366
|
+
{"a": {}},
|
|
367
|
+
{"a": {"b": 1}},
|
|
368
|
+
{"a": {"b": {"c": {"d": 4}}}},
|
|
369
|
+
{"a": [[1, 2], [3, 4]]},
|
|
370
|
+
{"a": {"s": "a}b{c[d]e"}},
|
|
371
|
+
],
|
|
372
|
+
)
|
|
373
|
+
def test_nested_raw(self, obj: dict[str, object]) -> None:
|
|
374
|
+
payload = json.dumps(obj)
|
|
375
|
+
key = next(iter(obj))
|
|
376
|
+
raw = collect(payload)[key]
|
|
377
|
+
assert json.loads(raw) == obj[key]
|
|
378
|
+
|
|
379
|
+
def test_many_fields_all_types(self) -> None:
|
|
380
|
+
obj = {
|
|
381
|
+
"str": "hello world",
|
|
382
|
+
"int": 42,
|
|
383
|
+
"float": 3.14,
|
|
384
|
+
"true": True,
|
|
385
|
+
"false": False,
|
|
386
|
+
"null": None,
|
|
387
|
+
"arr": [1, 2, 3],
|
|
388
|
+
"obj": {"x": 1},
|
|
389
|
+
"empty_str": "",
|
|
390
|
+
"empty_arr": [],
|
|
391
|
+
"empty_obj": {},
|
|
392
|
+
}
|
|
393
|
+
payload = json.dumps(obj)
|
|
394
|
+
got = collect(payload)
|
|
395
|
+
assert got["str"] == "hello world"
|
|
396
|
+
assert json.loads(got["int"]) == 42
|
|
397
|
+
assert json.loads(got["float"]) == 3.14
|
|
398
|
+
assert json.loads(got["true"]) is True
|
|
399
|
+
assert json.loads(got["false"]) is False
|
|
400
|
+
assert json.loads(got["null"]) is None
|
|
401
|
+
assert json.loads(got["arr"]) == [1, 2, 3]
|
|
402
|
+
assert json.loads(got["obj"]) == {"x": 1}
|
|
403
|
+
assert got["empty_str"] == ""
|
|
404
|
+
assert json.loads(got["empty_arr"]) == []
|
|
405
|
+
assert json.loads(got["empty_obj"]) == {}
|
|
406
|
+
|
|
407
|
+
def test_split_at_every_position(self) -> None:
|
|
408
|
+
"""Chunk boundary at every byte position must give identical result."""
|
|
409
|
+
obj = {"path": "/tmp/foo.py", "content": "line1\nline2\n", "n": 42}
|
|
410
|
+
payload = json.dumps(obj)
|
|
411
|
+
expected = collect(payload)
|
|
412
|
+
for pos in range(1, len(payload)):
|
|
413
|
+
s = ToolArgStream("c")
|
|
414
|
+
events = s.feed(payload[:pos]) + s.feed(payload[pos:])
|
|
415
|
+
got = {}
|
|
416
|
+
buf: dict[str, list[str]] = {}
|
|
417
|
+
for e in events:
|
|
418
|
+
if isinstance(e, ToolFieldStart):
|
|
419
|
+
buf[e.key] = []
|
|
420
|
+
elif isinstance(e, ToolFieldDelta):
|
|
421
|
+
buf[e.key].append(e.text)
|
|
422
|
+
got = {k: "".join(v) for k, v in buf.items()}
|
|
423
|
+
assert got == expected, f"mismatch at split pos={pos}"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ── JSON validity edge cases ───────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestValidity:
|
|
430
|
+
def test_empty_string_value(self) -> None:
|
|
431
|
+
assert collect('{"k": ""}') == {"k": ""}
|
|
432
|
+
|
|
433
|
+
def test_whitespace_heavy(self) -> None:
|
|
434
|
+
assert collect(' { "k" : "v" } ') == {"k": "v"}
|
|
435
|
+
|
|
436
|
+
def test_whitespace_inside_value_preserved(self) -> None:
|
|
437
|
+
assert collect('{"k": " spaces "}') == {"k": " spaces "}
|
|
438
|
+
|
|
439
|
+
def test_many_fields(self) -> None:
|
|
440
|
+
obj = {f"field_{i}": f"value_{i}" for i in range(50)}
|
|
441
|
+
payload = json.dumps(obj)
|
|
442
|
+
got = collect(payload)
|
|
443
|
+
assert got == {k: v for k, v in obj.items()}
|
|
444
|
+
|
|
445
|
+
def test_key_order_preserved(self) -> None:
|
|
446
|
+
"""Events must arrive in document order."""
|
|
447
|
+
payload = '{"z": "last", "a": "first", "m": "mid"}'
|
|
448
|
+
s = ToolArgStream("c")
|
|
449
|
+
events = s.feed(payload)
|
|
450
|
+
keys = [e.key for e in events if isinstance(e, ToolFieldStart)]
|
|
451
|
+
assert keys == ["z", "a", "m"]
|
|
452
|
+
|
|
453
|
+
def test_start_end_pair_for_every_field(self) -> None:
|
|
454
|
+
obj = {f"f{i}": i for i in range(20)}
|
|
455
|
+
events = ToolArgStream("c").feed(json.dumps(obj))
|
|
456
|
+
starts = [e.key for e in events if isinstance(e, ToolFieldStart)]
|
|
457
|
+
ends = [e.key for e in events if isinstance(e, ToolFieldEnd)]
|
|
458
|
+
assert starts == ends # same keys, same order
|
|
459
|
+
|
|
460
|
+
def test_scientific_notation(self) -> None:
|
|
461
|
+
raw = collect('{"x": 1.5e10}')["x"]
|
|
462
|
+
assert json.loads(raw) == 1.5e10
|
|
463
|
+
|
|
464
|
+
def test_negative_zero(self) -> None:
|
|
465
|
+
raw = collect('{"x": -0}')["x"]
|
|
466
|
+
assert json.loads(raw) == 0
|
|
467
|
+
|
|
468
|
+
def test_deeply_nested(self) -> None:
|
|
469
|
+
depth = 20
|
|
470
|
+
inner: dict[str, object] = {"leaf": 1}
|
|
471
|
+
for _ in range(depth):
|
|
472
|
+
inner = {"child": inner}
|
|
473
|
+
obj = {"root": inner}
|
|
474
|
+
payload = json.dumps(obj)
|
|
475
|
+
got = json.loads(collect(payload)["root"])
|
|
476
|
+
assert got == inner
|
|
477
|
+
|
|
478
|
+
def test_string_with_json_special_chars(self) -> None:
|
|
479
|
+
cases = ['{}[],:"\\ are fine', "null true false 0 1.0"]
|
|
480
|
+
for s in cases:
|
|
481
|
+
assert collect(json.dumps({"v": s}))["v"] == s
|
|
482
|
+
|
|
483
|
+
def test_unicode_bmp(self) -> None:
|
|
484
|
+
s = "\u4e2d\u6587" # 中文
|
|
485
|
+
assert collect(json.dumps({"v": s}))["v"] == s
|
|
486
|
+
|
|
487
|
+
def test_unicode_supplementary_plane(self) -> None:
|
|
488
|
+
s = "emoji: 😀🎉🚀"
|
|
489
|
+
assert collect(json.dumps({"v": s}))["v"] == s
|
|
490
|
+
|
|
491
|
+
def test_surrogate_pair_split_across_chunks(self) -> None:
|
|
492
|
+
payload = r'{"s": "\uD83D\uDE00"}' # 😀 as surrogate pair
|
|
493
|
+
assert collect(payload, chunk_size=1)["s"] == "😀"
|
|
494
|
+
|
|
495
|
+
def test_escaped_backslash_in_raw(self) -> None:
|
|
496
|
+
# backslash inside a string that's inside a nested object stays raw
|
|
497
|
+
obj = {"x": {"path": "C:\\Users"}}
|
|
498
|
+
payload = json.dumps(obj)
|
|
499
|
+
raw = collect(payload)["x"]
|
|
500
|
+
assert json.loads(raw) == {"path": "C:\\Users"}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ── Newlines ──────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class TestNewlines:
|
|
507
|
+
r"""JSON encodes newlines as \n, \r, \r\n; also tests literal control chars."""
|
|
508
|
+
|
|
509
|
+
def test_lf(self) -> None:
|
|
510
|
+
assert collect(r'{"s": "line1\nline2"}')["s"] == "line1\nline2"
|
|
511
|
+
|
|
512
|
+
def test_cr(self) -> None:
|
|
513
|
+
assert collect(r'{"s": "line1\rline2"}')["s"] == "line1\rline2"
|
|
514
|
+
|
|
515
|
+
def test_crlf(self) -> None:
|
|
516
|
+
assert collect(r'{"s": "line1\r\nline2"}')["s"] == "line1\r\nline2"
|
|
517
|
+
|
|
518
|
+
def test_only_newlines(self) -> None:
|
|
519
|
+
assert collect(r'{"s": "\n\n\n"}')["s"] == "\n\n\n"
|
|
520
|
+
|
|
521
|
+
def test_newline_at_chunk_boundary(self) -> None:
|
|
522
|
+
r"""\\n split so backslash is in one chunk, 'n' in the next."""
|
|
523
|
+
s = ToolArgStream("c")
|
|
524
|
+
e1 = s.feed('{"s": "a\\')
|
|
525
|
+
e2 = s.feed('nb"}')
|
|
526
|
+
text = "".join(e.text for e in e1 + e2 if isinstance(e, ToolFieldDelta))
|
|
527
|
+
assert text == "a\nb"
|
|
528
|
+
|
|
529
|
+
def test_multiline_string(self) -> None:
|
|
530
|
+
src = "first line\nsecond line\nthird line"
|
|
531
|
+
assert collect(json.dumps({"s": src}))["s"] == src
|
|
532
|
+
|
|
533
|
+
def test_mixed_whitespace_escapes(self) -> None:
|
|
534
|
+
r"""All whitespace escapes in one string: \n \r \t \b \f."""
|
|
535
|
+
payload = r'{"s": "\n\r\t\b\f"}'
|
|
536
|
+
assert collect(payload)["s"] == "\n\r\t\b\f"
|
|
537
|
+
|
|
538
|
+
def test_escaped_backslash_vs_newline(self) -> None:
|
|
539
|
+
r"""\\\\n is escaped backslash + literal 'n', not a newline."""
|
|
540
|
+
payload = r'{"s": "\\n"}'
|
|
541
|
+
assert collect(payload)["s"] == "\\n"
|
|
542
|
+
|
|
543
|
+
def test_escaped_backslash_then_real_newline_escape(self) -> None:
|
|
544
|
+
r"""\\\\\\n → backslash + newline."""
|
|
545
|
+
payload = r'{"s": "\\\n"}'
|
|
546
|
+
assert collect(payload)["s"] == "\\\n"
|
|
547
|
+
|
|
548
|
+
def test_newline_inside_nested_raw(self) -> None:
|
|
549
|
+
r"""\\n inside a nested object value stays as \\n in raw output."""
|
|
550
|
+
obj = {"x": {"msg": "line1\nline2"}}
|
|
551
|
+
raw = collect(json.dumps(obj))["x"]
|
|
552
|
+
assert json.loads(raw) == {"msg": "line1\nline2"}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ── Complex escapes ────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class TestComplexEscapes:
|
|
559
|
+
def test_all_simple_escapes_together(self) -> None:
|
|
560
|
+
r"""All six JSON escape sequences: \" \\ \/ \b \f \n \r \t."""
|
|
561
|
+
payload = r'{"s": "\"\\\/\b\f\n\r\t"}'
|
|
562
|
+
got = collect(payload)["s"]
|
|
563
|
+
assert got == '"\\/\b\f\n\r\t'
|
|
564
|
+
|
|
565
|
+
def test_escaped_quote_inside_string(self) -> None:
|
|
566
|
+
payload = r'{"s": "say \"hello\""}'
|
|
567
|
+
assert collect(payload)["s"] == 'say "hello"'
|
|
568
|
+
|
|
569
|
+
def test_escaped_backslash_chain(self) -> None:
|
|
570
|
+
r"""Four backslashes in JSON (\\\\\\\\) → two backslashes in Python."""
|
|
571
|
+
payload = r'{"s": "\\\\"}'
|
|
572
|
+
assert collect(payload)["s"] == "\\\\"
|
|
573
|
+
|
|
574
|
+
@pytest.mark.parametrize(
|
|
575
|
+
"code,expected",
|
|
576
|
+
[
|
|
577
|
+
(r"\u0000", "\x00"), # null character
|
|
578
|
+
(r"\u001F", "\x1f"), # control character
|
|
579
|
+
(r"\u007F", "\x7f"), # DEL
|
|
580
|
+
(r"\u00E9", "\xe9"), # é
|
|
581
|
+
(r"\u4E2D", "\u4e2d"), # 中
|
|
582
|
+
(r"\uFFFF", "\uffff"), # BMP max
|
|
583
|
+
],
|
|
584
|
+
)
|
|
585
|
+
def test_unicode_codepoints(self, code: str, expected: str) -> None:
|
|
586
|
+
payload = f'{{"s": "{code}"}}'
|
|
587
|
+
assert collect(payload)["s"] == expected
|
|
588
|
+
|
|
589
|
+
def test_unicode_escape_split_at_every_position(self) -> None:
|
|
590
|
+
r"""\\u4E2D split 0-5 chars from the backslash."""
|
|
591
|
+
full = r'{"s": "\u4E2D"}'
|
|
592
|
+
for split in range(len(full)):
|
|
593
|
+
s = ToolArgStream("c")
|
|
594
|
+
events = s.feed(full[:split]) + s.feed(full[split:])
|
|
595
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
596
|
+
assert text == "\u4e2d", f"wrong at split={split}"
|
|
597
|
+
|
|
598
|
+
def test_surrogate_pair_split_at_every_position(self) -> None:
|
|
599
|
+
r"""\\uD83D\\uDE00 (😀) — chunk boundary at every byte."""
|
|
600
|
+
full = r'{"s": "\uD83D\uDE00"}'
|
|
601
|
+
for split in range(len(full)):
|
|
602
|
+
s = ToolArgStream("c")
|
|
603
|
+
events = s.feed(full[:split]) + s.feed(full[split:])
|
|
604
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
605
|
+
assert text == "😀", f"wrong at split={split}"
|
|
606
|
+
|
|
607
|
+
def test_multiple_surrogate_pairs(self) -> None:
|
|
608
|
+
r"""Three emoji as surrogate pairs in sequence."""
|
|
609
|
+
payload = r'{"s": "\uD83D\uDE00\uD83C\uDF89\uD83D\uDE80"}'
|
|
610
|
+
assert collect(payload)["s"] == "😀🎉🚀"
|
|
611
|
+
|
|
612
|
+
def test_surrogate_pair_chunked_1_byte(self) -> None:
|
|
613
|
+
r"""Byte-by-byte feed for \\uD83D\\uDE00."""
|
|
614
|
+
full = r'{"s": "\uD83D\uDE00"}'
|
|
615
|
+
s = ToolArgStream("c")
|
|
616
|
+
events: list[ToolFieldEvent] = []
|
|
617
|
+
for ch in full:
|
|
618
|
+
events.extend(s.feed(ch))
|
|
619
|
+
text = "".join(e.text for e in events if isinstance(e, ToolFieldDelta))
|
|
620
|
+
assert text == "😀"
|
|
621
|
+
|
|
622
|
+
def test_many_unicode_escapes(self) -> None:
|
|
623
|
+
"""1000 unicode escape sequences — correctness and no quadratic cost."""
|
|
624
|
+
chars = "ABCDEFGHIJ"
|
|
625
|
+
escaped = "".join(f"\\u{ord(c):04X}" for c in chars * 100)
|
|
626
|
+
payload = f'{{"s": "{escaped}"}}'
|
|
627
|
+
assert collect(payload)["s"] == chars * 100
|
|
628
|
+
|
|
629
|
+
def test_escape_adjacent_to_delimiter(self) -> None:
|
|
630
|
+
r"""Escaped quote immediately before closing brace: {"s": "\""} ."""
|
|
631
|
+
payload = r'{"s": "\""}'
|
|
632
|
+
assert collect(payload)["s"] == '"'
|
|
633
|
+
|
|
634
|
+
def test_key_with_escape(self) -> None:
|
|
635
|
+
r"""Key containing \\n and \\t (unusual but valid JSON)."""
|
|
636
|
+
payload = r'{"ke\ny": "v"}'
|
|
637
|
+
events = ToolArgStream("c").feed(payload)
|
|
638
|
+
key = next(e.key for e in events if isinstance(e, ToolFieldStart))
|
|
639
|
+
assert key == "ke\ny"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ── O(1) verification via cProfile call counts ────────────────────────────────
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _step_calls(payload: str) -> int:
|
|
646
|
+
"""Return the number of _step() invocations needed to process payload."""
|
|
647
|
+
pr = cProfile.Profile()
|
|
648
|
+
s = ToolArgStream("c")
|
|
649
|
+
pr.runcall(s.feed, payload)
|
|
650
|
+
for stat in pr.getstats():
|
|
651
|
+
if hasattr(stat.code, "co_name") and stat.code.co_name == "_step":
|
|
652
|
+
return stat.callcount
|
|
653
|
+
return 0 # pragma: no cover
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class TestPerf:
|
|
657
|
+
"""Verify O(1)-per-character behaviour using call counts (machine-independent).
|
|
658
|
+
|
|
659
|
+
cProfile.callcount is deterministic: unaffected by machine speed, load, or
|
|
660
|
+
coverage instrumentation overhead that trips up wall-clock timing tests.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
@pytest.mark.parametrize("n", [1_000, 10_000, 100_000])
|
|
664
|
+
def test_step_calls_linear_string(self, n: int) -> None:
|
|
665
|
+
"""_step() called exactly once per character for plain string values."""
|
|
666
|
+
payload = '{"data": "' + "x" * n + '"}'
|
|
667
|
+
calls = _step_calls(payload)
|
|
668
|
+
# Verify correctness too
|
|
669
|
+
events = ToolArgStream("c").feed(payload)
|
|
670
|
+
assert events[0] == ToolFieldStart(0, "c", "data")
|
|
671
|
+
assert events[-1] == ToolFieldEnd(0, "c", "data")
|
|
672
|
+
assert sum(len(e.text) for e in events if isinstance(e, ToolFieldDelta)) == n
|
|
673
|
+
# No reprocess ever happens for string values → exactly 1 call/char
|
|
674
|
+
assert calls == len(payload), f"expected {len(payload)}, got {calls} for n={n}"
|
|
675
|
+
|
|
676
|
+
@pytest.mark.parametrize("n", [1_000, 10_000, 100_000])
|
|
677
|
+
def test_step_calls_linear_raw(self, n: int) -> None:
|
|
678
|
+
"""_step() called ≤ 2× per character for raw values (reprocess on delimiter)."""
|
|
679
|
+
fields = []
|
|
680
|
+
total = 0
|
|
681
|
+
i = 0
|
|
682
|
+
while total < n:
|
|
683
|
+
entry = f'"f{i}":{i}'
|
|
684
|
+
fields.append(entry)
|
|
685
|
+
total += len(entry) + 1 # +1 for comma
|
|
686
|
+
i += 1
|
|
687
|
+
payload = "{" + ",".join(fields) + "}"
|
|
688
|
+
calls = _step_calls(payload)
|
|
689
|
+
# Each comma/} ending a raw value triggers one reprocess → at most 2×
|
|
690
|
+
assert calls <= 2 * len(payload), f"super-linear: {calls} calls for {len(payload)} chars"
|
|
691
|
+
# In practice close to 1× — ratio must be sane
|
|
692
|
+
assert calls / len(payload) < 1.5, f"too many reprocess calls: {calls / len(payload):.2f}×"
|
|
693
|
+
|
|
694
|
+
def test_step_calls_scale_linearly(self) -> None:
|
|
695
|
+
"""Call count grows proportionally to input length (not O(n²))."""
|
|
696
|
+
sizes = [1_000, 10_000, 100_000]
|
|
697
|
+
counts = [_step_calls('{"d": "' + "x" * n + '"}') for n in sizes]
|
|
698
|
+
# Each size is 10× bigger → calls must also grow ~10× (within 20%)
|
|
699
|
+
for prev, curr, (n0, n1) in zip(counts, counts[1:], zip(sizes, sizes[1:])):
|
|
700
|
+
expected = n1 / n0
|
|
701
|
+
actual = curr / prev
|
|
702
|
+
assert 0.8 * expected <= actual <= 1.2 * expected, (
|
|
703
|
+
f"non-linear: {n0}→{n1} chars, {prev}→{curr} calls (ratio {actual:.2f}×)"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
@pytest.mark.parametrize("chunk_size", [1, 16, 64, 256])
|
|
707
|
+
def test_chunked_same_call_count(self, chunk_size: int) -> None:
|
|
708
|
+
"""Chunked feeding must not add extra _step() calls vs batch."""
|
|
709
|
+
n = 10_000
|
|
710
|
+
payload = '{"content": "' + "a" * n + '"}'
|
|
711
|
+
batch_calls = _step_calls(payload)
|
|
712
|
+
|
|
713
|
+
pr = cProfile.Profile()
|
|
714
|
+
s = ToolArgStream("c")
|
|
715
|
+
pr.enable()
|
|
716
|
+
for i in range(0, len(payload), chunk_size):
|
|
717
|
+
s.feed(payload[i : i + chunk_size])
|
|
718
|
+
pr.disable()
|
|
719
|
+
chunked_calls = next(
|
|
720
|
+
(
|
|
721
|
+
stat.callcount
|
|
722
|
+
for stat in pr.getstats()
|
|
723
|
+
if hasattr(stat.code, "co_name") and stat.code.co_name == "_step"
|
|
724
|
+
),
|
|
725
|
+
0,
|
|
726
|
+
)
|
|
727
|
+
assert chunked_calls == batch_calls, f"chunk_size={chunk_size}: chunked={chunked_calls} != batch={batch_calls}"
|
|
728
|
+
|
|
729
|
+
def test_many_short_fields(self) -> None:
|
|
730
|
+
"""100 fields — correctness + call count stays linear."""
|
|
731
|
+
obj = {f"field_{i:03d}": f"value number {i}" for i in range(100)}
|
|
732
|
+
payload = json.dumps(obj)
|
|
733
|
+
got = collect(payload)
|
|
734
|
+
assert len(got) == 100
|
|
735
|
+
assert all(got[f"field_{i:03d}"] == f"value number {i}" for i in range(100))
|
|
736
|
+
assert _step_calls(payload) <= 2 * len(payload)
|
|
737
|
+
|
|
738
|
+
def test_throughput_report(self) -> None:
|
|
739
|
+
"""Print ns/char and calls/char for manual inspection (never fails)."""
|
|
740
|
+
results = []
|
|
741
|
+
for label, payload in [
|
|
742
|
+
("string 500k", '{"content": "' + "a" * 500_000 + '"}'),
|
|
743
|
+
("raw 500k", "{" + ",".join(f'"f{i}":{i}' for i in range(25_000)) + "}"),
|
|
744
|
+
("100 fields", json.dumps({f"k{i}": "x" * 100 for i in range(100)})),
|
|
745
|
+
]:
|
|
746
|
+
start = time.perf_counter()
|
|
747
|
+
ToolArgStream("c").feed(payload)
|
|
748
|
+
elapsed = time.perf_counter() - start
|
|
749
|
+
ns = elapsed / len(payload) * 1e9
|
|
750
|
+
cpc = _step_calls(payload) / len(payload)
|
|
751
|
+
results.append(f"{label}: {ns:.0f} ns/char {cpc:.2f} calls/char")
|
|
752
|
+
|
|
753
|
+
print("\n " + "\n ".join(results))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|