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.
Files changed (43) hide show
  1. {axio-0.3.4 → axio-0.3.5}/PKG-INFO +1 -1
  2. {axio-0.3.4 → axio-0.3.5}/pyproject.toml +1 -1
  3. {axio-0.3.4 → axio-0.3.5}/src/axio/events.py +33 -1
  4. axio-0.3.5/src/axio/tool_args.py +243 -0
  5. axio-0.3.5/tests/test_tool_args.py +753 -0
  6. {axio-0.3.4 → axio-0.3.5}/.github/workflows/publish.yml +0 -0
  7. {axio-0.3.4 → axio-0.3.5}/.github/workflows/tests.yml +0 -0
  8. {axio-0.3.4 → axio-0.3.5}/.gitignore +0 -0
  9. {axio-0.3.4 → axio-0.3.5}/LICENSE +0 -0
  10. {axio-0.3.4 → axio-0.3.5}/Makefile +0 -0
  11. {axio-0.3.4 → axio-0.3.5}/README.md +0 -0
  12. {axio-0.3.4 → axio-0.3.5}/src/axio/__init__.py +0 -0
  13. {axio-0.3.4 → axio-0.3.5}/src/axio/agent.py +0 -0
  14. {axio-0.3.4 → axio-0.3.5}/src/axio/blocks.py +0 -0
  15. {axio-0.3.4 → axio-0.3.5}/src/axio/context.py +0 -0
  16. {axio-0.3.4 → axio-0.3.5}/src/axio/exceptions.py +0 -0
  17. {axio-0.3.4 → axio-0.3.5}/src/axio/messages.py +0 -0
  18. {axio-0.3.4 → axio-0.3.5}/src/axio/models.py +0 -0
  19. {axio-0.3.4 → axio-0.3.5}/src/axio/permission.py +0 -0
  20. {axio-0.3.4 → axio-0.3.5}/src/axio/py.typed +0 -0
  21. {axio-0.3.4 → axio-0.3.5}/src/axio/selector.py +0 -0
  22. {axio-0.3.4 → axio-0.3.5}/src/axio/stream.py +0 -0
  23. {axio-0.3.4 → axio-0.3.5}/src/axio/testing.py +0 -0
  24. {axio-0.3.4 → axio-0.3.5}/src/axio/tool.py +0 -0
  25. {axio-0.3.4 → axio-0.3.5}/src/axio/transport.py +0 -0
  26. {axio-0.3.4 → axio-0.3.5}/src/axio/types.py +0 -0
  27. {axio-0.3.4 → axio-0.3.5}/tests/conftest.py +0 -0
  28. {axio-0.3.4 → axio-0.3.5}/tests/test_agent_branch.py +0 -0
  29. {axio-0.3.4 → axio-0.3.5}/tests/test_agent_permission.py +0 -0
  30. {axio-0.3.4 → axio-0.3.5}/tests/test_agent_run.py +0 -0
  31. {axio-0.3.4 → axio-0.3.5}/tests/test_agent_stream.py +0 -0
  32. {axio-0.3.4 → axio-0.3.5}/tests/test_agent_tools.py +0 -0
  33. {axio-0.3.4 → axio-0.3.5}/tests/test_blocks.py +0 -0
  34. {axio-0.3.4 → axio-0.3.5}/tests/test_context.py +0 -0
  35. {axio-0.3.4 → axio-0.3.5}/tests/test_events.py +0 -0
  36. {axio-0.3.4 → axio-0.3.5}/tests/test_exceptions.py +0 -0
  37. {axio-0.3.4 → axio-0.3.5}/tests/test_permission.py +0 -0
  38. {axio-0.3.4 → axio-0.3.5}/tests/test_selector.py +0 -0
  39. {axio-0.3.4 → axio-0.3.5}/tests/test_stream.py +0 -0
  40. {axio-0.3.4 → axio-0.3.5}/tests/test_tool.py +0 -0
  41. {axio-0.3.4 → axio-0.3.5}/tests/test_transport.py +0 -0
  42. {axio-0.3.4 → axio-0.3.5}/tests/test_types.py +0 -0
  43. {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.4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio"
3
- version = "0.3.4"
3
+ version = "0.3.5"
4
4
  description = "Minimal, streaming-first, protocol-driven foundation for LLM-powered agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 | TextDelta | ToolUseStart | ToolInputDelta | ToolResult | IterationEnd | Error | SessionEndEvent
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