streaming-json-parser 0.1.0__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.
File without changes
@@ -0,0 +1,552 @@
1
+ import json
2
+ import re
3
+ from typing import Any, Optional, Union
4
+
5
+ # State constants for the iterative parser
6
+ S_INIT, S_OBJ_START, S_OBJ_KEY, S_OBJ_COLON, S_OBJ_VALUE, S_OBJ_COMMA, \
7
+ S_ARR_START, S_ARR_VALUE, S_ARR_COMMA, S_COMPLETE, S_ERROR = range(11)
8
+
9
+ class IterativeStateMachine:
10
+ def __init__(self):
11
+ # Pre-compile regex for finding unquoted keys for efficiency
12
+ # Allows alphanumeric characters, _, -, . as part of the key
13
+ self.__key_pattern = re.compile(r"([a-zA-Z0-9_.-]+)")
14
+
15
+ def parse_iterative_partial(self, s: str) -> tuple[Optional[Union[dict[str, Any], list[Any]]], int]:
16
+ """
17
+ An iterative state-machine based parser for potentially incomplete or
18
+ slightly non-standard JSON (unquoted keys, single quotes).
19
+ Attempts to parse one top-level object or array.
20
+
21
+ Args:
22
+ s: The string buffer containing JSON data (or partial data).
23
+
24
+ Returns:
25
+ A tuple containing:
26
+ - The parsed object (dict) or array (list), or None if parsing fails early.
27
+ - The index in the string 's' up to which parsing consumed input.
28
+ This index might point to the end of a complete structure, or the
29
+ point where parsing stopped due to incomplete data or an error.
30
+ """
31
+ state_stack = [S_INIT]
32
+ # Holds the nested dicts/lists currently being built
33
+ container_stack: list[Union[dict[str, Any], list[Any]]] = []
34
+ # Stores the key for the current object value being parsed
35
+ current_key: Optional[str] = None
36
+ # The root object or list being parsed
37
+ root: Optional[Union[dict[str, Any], list[Any]]] = None
38
+ i = 0 # Current parsing index in string 's'
39
+ len_s = len(s)
40
+
41
+ while i < len_s:
42
+ current_state = state_stack[-1]
43
+ char = s[i]
44
+
45
+ # Skip Whitespace efficiently
46
+ if char.isspace():
47
+ i += 1
48
+ continue
49
+
50
+ try:
51
+ # --- State Machine Logic ---
52
+
53
+ # Initial state: Expect start of object '{' or array '['
54
+ if current_state == S_INIT:
55
+ if char == '{':
56
+ root = {}
57
+ container_stack.append(root)
58
+ state_stack[-1] = S_OBJ_START # Transition to object start state
59
+ i += 1
60
+ elif char == '[':
61
+ root = []
62
+ container_stack.append(root)
63
+ state_stack[-1] = S_ARR_START # Transition to array start state
64
+ i += 1
65
+ else:
66
+ # Error: Input doesn't start with '{' or '['
67
+ state_stack.append(S_ERROR)
68
+ break # Stop parsing
69
+
70
+ # === OBJECT STATES ===
71
+
72
+ # Inside object, after '{' or comma: Expect key or '}'
73
+ elif current_state == S_OBJ_START:
74
+ if char == '}':
75
+ # Empty object or end of object
76
+ if not container_stack:
77
+ # Should be impossible if state logic is correct
78
+ state_stack.append(S_ERROR)
79
+ break
80
+ state_stack.pop() # Pop S_OBJ_START state
81
+ container_stack.pop() # Pop the completed object
82
+ i += 1
83
+ if not state_stack:
84
+ # If stack is now empty, we finished the root object
85
+ state_stack.append(S_COMPLETE)
86
+ break
87
+ # Otherwise, we were in a nested object, continue parsing parent
88
+ else:
89
+ # Expecting an object key next
90
+ state_stack[-1] = S_OBJ_KEY
91
+ # Do not increment 'i' here, S_OBJ_KEY needs to process current char
92
+
93
+ # Expecting an object key (quoted or unquoted)
94
+ elif current_state == S_OBJ_KEY:
95
+ key = None
96
+ start_i = i
97
+ if char == '"':
98
+ key, i = self.__parse_partial_string(s, i)
99
+ elif char == "'": # Non-standard: single quotes
100
+ key, i = self.__parse_single_quoted_string(s, i)
101
+ else: # Non-standard: unquoted key
102
+ key, i = self.__parse_unquoted_key(s, i)
103
+
104
+ # Check if a key was successfully parsed and index advanced
105
+ if key is not None and i > start_i:
106
+ current_key = key
107
+ state_stack[-1] = S_OBJ_COLON # Transition: Expect colon next
108
+ else:
109
+ # Failed to parse a valid key at this position
110
+ state_stack.append(S_ERROR)
111
+ break # Stop parsing
112
+
113
+ # Expecting the colon ':' after an object key
114
+ elif current_state == S_OBJ_COLON:
115
+ if char == ':':
116
+ state_stack[-1] = S_OBJ_VALUE # Transition: Expect value next
117
+ i += 1
118
+ else:
119
+ # Error: Missing colon after key
120
+ state_stack.append(S_ERROR)
121
+ break # Stop parsing
122
+
123
+ # Expecting a value (primitive, object, or array) for the current key
124
+ elif current_state == S_OBJ_VALUE:
125
+ value_parsed = False
126
+ start_i = i
127
+ # Get the object this key-value pair belongs to
128
+ parent_obj = container_stack[-1]
129
+
130
+ # Basic check for state consistency
131
+ if not isinstance(parent_obj, dict) or current_key is None:
132
+ state_stack.append(S_ERROR)
133
+ break # Should not happen in correct state flow
134
+
135
+ # Check for nested object start
136
+ if char == '{':
137
+ new_obj = {}
138
+ parent_obj[current_key] = new_obj
139
+ # Transition parent state to expect comma/close brace
140
+ state_stack[-1] = S_OBJ_COMMA
141
+ # Push new object and its state onto stacks
142
+ container_stack.append(new_obj)
143
+ state_stack.append(S_OBJ_START)
144
+ i += 1
145
+ value_parsed = True
146
+ # Check for nested array start
147
+ elif char == '[':
148
+ new_arr = []
149
+ parent_obj[current_key] = new_arr
150
+ # Transition parent state to expect comma/close brace
151
+ state_stack[-1] = S_OBJ_COMMA
152
+ # Push new array and its state onto stacks
153
+ container_stack.append(new_arr)
154
+ state_stack.append(S_ARR_START)
155
+ i += 1
156
+ value_parsed = True
157
+ else:
158
+ # Attempt to parse a primitive value (string, number, literal)
159
+ value, next_i = self.__parse_primitive_value(s, i)
160
+ if next_i > i: # Check if parsing advanced the index
161
+ parent_obj[current_key] = value
162
+ state_stack[-1] = S_OBJ_COMMA # Expect comma/close brace next
163
+ i = next_i # Update index to after the primitive
164
+ value_parsed = True
165
+ # else: primitive parsing failed, handled below
166
+
167
+ if not value_parsed:
168
+ # Error: Could not parse a valid value after the colon
169
+ state_stack.append(S_ERROR)
170
+ break # Stop parsing
171
+
172
+ # After an object value: Expect comma ',' or closing brace '}'
173
+ elif current_state == S_OBJ_COMMA:
174
+ if char == ',':
175
+ state_stack[-1] = S_OBJ_KEY # Transition: Expect another key
176
+ i += 1
177
+ elif char == '}':
178
+ # End of the current object
179
+ if not container_stack:
180
+ state_stack.append(S_ERROR) # Error: Unbalanced braces
181
+ break
182
+ state_stack.pop() # Pop S_OBJ_COMMA state
183
+ container_stack.pop() # Pop the completed object
184
+ i += 1
185
+ if not state_stack:
186
+ # Finished the root object
187
+ state_stack.append(S_COMPLETE)
188
+ break
189
+ # Else, continue processing parent container
190
+ else:
191
+ # Error: Expected ',' or '}' after value
192
+ state_stack.append(S_ERROR)
193
+ break # Stop parsing
194
+
195
+ # === ARRAY STATES ===
196
+
197
+ # Inside array, after '[' or comma: Expect value or ']'
198
+ elif current_state == S_ARR_START:
199
+ if char == ']':
200
+ # Empty array or end of array
201
+ if not container_stack:
202
+ state_stack.append(S_ERROR) # Error: Unbalanced brackets
203
+ break
204
+ state_stack.pop() # Pop S_ARR_START state
205
+ container_stack.pop() # Pop the completed array
206
+ i += 1
207
+ if not state_stack:
208
+ # Finished the root array
209
+ state_stack.append(S_COMPLETE)
210
+ break
211
+ # Else, continue processing parent container
212
+ else:
213
+ # Expecting an array value next
214
+ state_stack[-1] = S_ARR_VALUE
215
+ # Do not increment 'i', S_ARR_VALUE needs to process current char
216
+
217
+ # Expecting an array value (primitive, object, or array)
218
+ elif current_state == S_ARR_VALUE:
219
+ value_parsed = False
220
+ start_i = i
221
+ # Get the array this value belongs to
222
+ parent_arr = container_stack[-1]
223
+
224
+ # Basic check for state consistency
225
+ if not isinstance(parent_arr, list):
226
+ state_stack.append(S_ERROR)
227
+ break # Should not happen
228
+
229
+ # Check for nested object start
230
+ if char == '{':
231
+ new_obj = {}
232
+ parent_arr.append(new_obj)
233
+ # Transition parent state to expect comma/close bracket
234
+ state_stack[-1] = S_ARR_COMMA
235
+ # Push new object and its state
236
+ container_stack.append(new_obj)
237
+ state_stack.append(S_OBJ_START)
238
+ i += 1
239
+ value_parsed = True
240
+ # Check for nested array start
241
+ elif char == '[':
242
+ new_arr = []
243
+ parent_arr.append(new_arr)
244
+ # Transition parent state to expect comma/close bracket
245
+ state_stack[-1] = S_ARR_COMMA
246
+ # Push new array and its state
247
+ container_stack.append(new_arr)
248
+ state_stack.append(S_ARR_START)
249
+ i += 1
250
+ value_parsed = True
251
+ else:
252
+ # Attempt to parse a primitive value
253
+ value, next_i = self.__parse_primitive_value(s, i)
254
+ if next_i > i: # Check if parsing advanced the index
255
+ parent_arr.append(value)
256
+ state_stack[-1] = S_ARR_COMMA # Expect comma/close bracket next
257
+ i = next_i # Update index
258
+ value_parsed = True
259
+ # else: primitive parsing failed, handled below
260
+
261
+ if not value_parsed:
262
+ # Error: Could not parse a valid value in array
263
+ state_stack.append(S_ERROR)
264
+ break # Stop parsing
265
+
266
+ # After an array value: Expect comma ',' or closing bracket ']'
267
+ elif current_state == S_ARR_COMMA:
268
+ if char == ',':
269
+ state_stack[-1] = S_ARR_VALUE # Transition: Expect another value
270
+ i += 1
271
+ elif char == ']':
272
+ # End of the current array
273
+ if not container_stack:
274
+ state_stack.append(S_ERROR) # Error: Unbalanced brackets
275
+ break
276
+ state_stack.pop() # Pop S_ARR_COMMA state
277
+ container_stack.pop() # Pop the completed array
278
+ i += 1
279
+ if not state_stack:
280
+ # Finished the root array
281
+ state_stack.append(S_COMPLETE)
282
+ break
283
+ # Else, continue processing parent container
284
+ else:
285
+ # Error: Expected ',' or ']' after array element
286
+ state_stack.append(S_ERROR)
287
+ break # Stop parsing
288
+
289
+ # === END/ERROR STATES ===
290
+
291
+ # Parsing completed successfully for the root element
292
+ elif current_state == S_COMPLETE:
293
+ break # Exit main loop
294
+
295
+ # An error occurred during parsing
296
+ elif current_state == S_ERROR:
297
+ break # Exit main loop
298
+
299
+ # Catchall for unknown states (should not happen)
300
+ else:
301
+ print(f"Warning: Encountered unknown parser state {current_state}")
302
+ state_stack.append(S_ERROR)
303
+ break # Stop parsing
304
+
305
+ except IndexError:
306
+ # Catch errors accessing state_stack or container_stack if they become empty unexpectedly
307
+ print(f"Error: Stack underflow at index {i}, state {current_state}. Likely malformed JSON.")
308
+ state_stack.append(S_ERROR)
309
+ break
310
+ except Exception as inner_ex:
311
+ # Catch any other unexpected errors during state processing
312
+ print(f"Error during iterative parse step: {inner_ex} at index {i}, state {current_state}")
313
+ state_stack.append(S_ERROR)
314
+ break # Stop parsing on unexpected exceptions
315
+
316
+ # Determine the final state and return result
317
+ final_state = state_stack[-1]
318
+
319
+ if final_state == S_COMPLETE:
320
+ # Parsed a complete object/array successfully
321
+ return root, i
322
+ elif final_state == S_ERROR:
323
+ # Parsing stopped due to an error. Return whatever was parsed
324
+ # up to the error point (might be None or partial structure)
325
+ # 'i' will be the index where the error occurred.
326
+ return root, i
327
+ else:
328
+ # Loop finished because end of input string 's' was reached,
329
+ # but the JSON structure wasn't 'complete' (e.g., missing closing brace).
330
+ # Return the partially parsed structure and the final index 'i'.
331
+ return root, i
332
+
333
+ def __parse_primitive_value(self, s: str, i: int) -> tuple[Any, int]:
334
+ """
335
+ Attempts to parse any primitive JSON value (string, number, literal)
336
+ starting at s[i]. Also handles non-standard single quotes.
337
+ Returns the parsed value and the index after it, or (None, i) on failure.
338
+ """
339
+ if i >= len(s):
340
+ # Cannot parse if index is out of bounds
341
+ return None, i
342
+
343
+ char = s[i]
344
+
345
+ # Check for string start (double quote)
346
+ if char == '"':
347
+ return self.__parse_partial_string(s, i)
348
+
349
+ # Check for non-standard string start (single quote)
350
+ if char == "'":
351
+ return self.__parse_single_quoted_string(s, i)
352
+
353
+ # Check for number start (digit or minus sign)
354
+ if char in "-0123456789":
355
+ return self.__parse_partial_number(s, i)
356
+
357
+ # Check for literals (true, false, null)
358
+ val, next_i = self.__parse_partial_literal(s, i)
359
+ if next_i > i:
360
+ # A literal was successfully parsed
361
+ return val, next_i
362
+
363
+ # If none of the above matched, it's not a recognizable primitive start
364
+ return None, i # Indicate failure by returning original index
365
+
366
+ def __parse_unquoted_key(self, s: str, i: int) -> tuple[Optional[str], int]:
367
+ """
368
+ Attempts to parse an unquoted object key starting at s[i]. Non-standard.
369
+ Uses the pre-compiled regex `_key_pattern`.
370
+ Returns the key string and the index after it, or (None, i) if no match.
371
+ """
372
+ match = self.__key_pattern.match(s, i)
373
+ if match:
374
+ # Key must not be followed immediately by ":" without whitespace,
375
+ # handle ':' separation in the main loop.
376
+ key = match.group(1)
377
+ return key, match.end()
378
+ else:
379
+ # No match for the unquoted key pattern
380
+ return None, i
381
+
382
+ def __parse_single_quoted_string(self, s: str, i: int) -> tuple[str, int]:
383
+ """
384
+ Parses a single-quoted string starting at s[i]. Non-standard JSON.
385
+ Assumes s[i] == "'". Handles limited escapes (\' and \\).
386
+ Returns the parsed string content and the index after the closing quote,
387
+ or the end of the string if unterminated.
388
+ """
389
+ i += 1 # skip opening '
390
+ result = []
391
+ escape = False
392
+
393
+ while i < len(s):
394
+ ch = s[i]
395
+
396
+ if escape:
397
+ if ch == "'":
398
+ result.append("'")
399
+ elif ch == '\\':
400
+ result.append('\\')
401
+ else:
402
+ # Pass through other characters following a backslash
403
+ result.append('\\')
404
+ result.append(ch)
405
+ escape = False
406
+ elif ch == "\\":
407
+ escape = True
408
+ elif ch == "'":
409
+ # End of string found
410
+ return "".join(result), i + 1
411
+ else:
412
+ result.append(ch)
413
+
414
+ i += 1
415
+
416
+ # Unterminated single-quoted string
417
+ return "".join(result), i
418
+
419
+ def __parse_partial_number(self, s: str, i: int) -> tuple[Any, int]:
420
+ """
421
+ Parses a number (int or float) starting at s[i].
422
+ Uses regex for robust matching of JSON number format.
423
+ Returns the parsed number (int or float) and the index after the number,
424
+ or (None, i) if no valid number start is found.
425
+ """
426
+ # Regex for standard JSON numbers (integer and floating point)
427
+ # Allows leading minus, optional fractional part, optional exponent
428
+ num_match = re.match(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?", s[i:])
429
+
430
+ if num_match:
431
+ num_str = num_match.group(0)
432
+ end_i = i + len(num_str)
433
+ try:
434
+ # Use json.loads on the matched string for reliable conversion
435
+ # This correctly handles int vs float detection.
436
+ return json.loads(num_str), end_i
437
+ except json.JSONDecodeError:
438
+ # This should theoretically not happen if the regex is correct,
439
+ # but acts as a safeguard. Return the raw string maybe?
440
+ # Or indicate failure. Let's return failure indicator.
441
+ return None, i # Indicate parsing failed despite regex match
442
+ else:
443
+ # No valid number pattern matched at the current position
444
+ return None, i
445
+
446
+ def __parse_partial_literal(self, s: str, i: int) -> tuple[Any, int]:
447
+ """
448
+ Parses JSON literals (true, false, null) starting at s[i].
449
+ Checks for word boundaries to avoid partial matches (e.g., 'trueish').
450
+ Returns the literal value (True, False, None) and the index after it,
451
+ or (None, i) if no literal is matched.
452
+ """
453
+ len_s = len(s)
454
+
455
+ # Check for 'true'
456
+ if s.startswith("true", i):
457
+ end_i = i + 4
458
+ # Check if it's the end of the string or followed by a non-alphanumeric char
459
+ if end_i == len_s or not s[end_i].isalnum():
460
+ return True, end_i
461
+
462
+ # Check for 'false'
463
+ if s.startswith("false", i):
464
+ end_i = i + 5
465
+ # Check for word boundary
466
+ if end_i == len_s or not s[end_i].isalnum():
467
+ return False, end_i
468
+
469
+ # Check for 'null'
470
+ if s.startswith("null", i):
471
+ end_i = i + 4
472
+ # Check for word boundary
473
+ if end_i == len_s or not s[end_i].isalnum():
474
+ return None, end_i
475
+
476
+ # No literal matched at this position
477
+ return None, i
478
+
479
+ def __parse_partial_string(self, s: str, i: int) -> tuple[str, int]:
480
+ """
481
+ Parses a double-quoted string starting at s[i].
482
+ Assumes s[i] == '"'. Handles standard JSON escapes.
483
+ Returns the parsed string content and the index after the closing quote,
484
+ or the end of the string if unterminated.
485
+ """
486
+ i += 1 # skip opening "
487
+ result = []
488
+ escape = False
489
+
490
+ while i < len(s):
491
+ ch = s[i]
492
+
493
+ # Handle escape sequences
494
+ if escape:
495
+ if ch == 'b':
496
+ result.append('\b')
497
+ elif ch == 'f':
498
+ result.append('\f')
499
+ elif ch == 'n':
500
+ result.append('\n')
501
+ elif ch == 'r':
502
+ result.append('\r')
503
+ elif ch == 't':
504
+ result.append('\t')
505
+ elif ch == '"':
506
+ result.append('"')
507
+ elif ch == '\\':
508
+ result.append('\\')
509
+ elif ch == '/':
510
+ result.append('/') # Allowed escape, often seen
511
+ elif ch == 'u':
512
+ # Unicode escape (basic handling)
513
+ if i + 4 < len(s):
514
+ try:
515
+ hex_code = s[i + 1 : i + 5]
516
+ result.append(chr(int(hex_code, 16)))
517
+ i += 4 # Skip hex digits
518
+ except ValueError:
519
+ # Invalid hex code, keep the original sequence
520
+ result.append('\\u')
521
+ result.append(s[i + 1 : i + 5])
522
+ i += 4
523
+ else:
524
+ # Incomplete escape sequence at end of buffer
525
+ result.append('\\u')
526
+ # Append remaining characters if any
527
+ if i + 1 < len(s):
528
+ result.append(s[i + 1 :])
529
+ i = len(s) # Move index to end
530
+ break # Exit loop as escape is incomplete
531
+ else:
532
+ # Pass through unknown escape sequences
533
+ result.append('\\')
534
+ result.append(ch)
535
+ escape = False
536
+ elif ch == "\\":
537
+ escape = True
538
+ elif ch == '"':
539
+ # End of string found
540
+ return "".join(result), i + 1
541
+ else:
542
+ # Handle control characters (U+0000 to U+001F)
543
+ # Escape them to \uXXXX format if not already escaped
544
+ if 0x00 <= ord(ch) <= 0x1F:
545
+ result.append(f'\\u{ord(ch):04x}')
546
+ result.append(ch)
547
+
548
+ i += 1
549
+
550
+ # If loop finishes without finding closing quote, string is unterminated.
551
+ # Return the partial result found so far.
552
+ return "".join(result), i
@@ -0,0 +1,173 @@
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+ from src.streaming_json_parser.iterative_state_machine import \
6
+ IterativeStateMachine
7
+
8
+
9
+ class StreamingJsonParser:
10
+ """
11
+ A parser designed to handle potentially incomplete or slightly malformed
12
+ JSON streams, attempting to extract valid JSON objects.
13
+ """
14
+ def __init__(self):
15
+ self.__buffer: str = ""
16
+
17
+ def consume(self, data: str) -> None:
18
+ """
19
+ Adds new data chunks to the internal buffer after escaping invalid chars.
20
+ """
21
+ # Escape control characters before adding to buffer
22
+ # This helps prevent issues if these chars appear outside strings
23
+ if not self.__is_string(data):
24
+ return
25
+
26
+ escaped_data = self.__escape_invalid_control_chars(data)
27
+ if escaped_data:
28
+ self.__buffer += escaped_data
29
+
30
+ def get(self) -> dict[str, Any]:
31
+ """
32
+ Attempts to parse and return the first complete JSON object found
33
+ in the buffer. Removes the parsed object (and preceding non-JSON data)
34
+ from the buffer. Returns an empty dict if no complete object is found.
35
+ """
36
+ # --- Initial buffer cleanup ---
37
+ # Remove potential BOM (Byte Order Mark) and leading whitespace
38
+ self.__buffer = self.__buffer.lstrip("\ufeff").lstrip()
39
+
40
+ # Find the first opening brace '{' which signifies a potential JSON object start
41
+ start_index = self.__buffer.find("{")
42
+ if start_index == -1:
43
+ # No object start found, clear buffer if it contains only whitespace/junk
44
+ if not self.__buffer.strip():
45
+ self.__buffer = ""
46
+ # Otherwise, keep buffer as it might be incomplete start of something else
47
+ return {}
48
+ elif start_index > 0:
49
+ # Discard anything before the first '{'
50
+ self.__buffer = self.__buffer[start_index:]
51
+
52
+ # If buffer became empty after stripping/finding '{', return empty
53
+ if not self.__buffer:
54
+ return {}
55
+
56
+ # --- Attempt 1: Use standard json.raw_decode for well-formed JSON ---
57
+ # This is efficient for standard JSON.
58
+ try:
59
+ decoder = json.JSONDecoder()
60
+ # raw_decode parses one JSON value and returns it and the index where it stopped
61
+ obj, idx = decoder.raw_decode(self.__buffer)
62
+
63
+ # Check if the decoded item is a dictionary (object)
64
+ if isinstance(obj, dict):
65
+ # Successfully parsed a standard JSON object
66
+ self.__buffer = self.__buffer[idx:] # Remove parsed part from buffer
67
+ self.__clean_buffer_after_parse() # Clean up potential leading junk
68
+ return obj
69
+ else:
70
+ # Parsed something, but it wasn't an object (e.g., list, primitive)
71
+ # Discard the parsed part and try again (or let partial parser handle it)
72
+ self.__buffer = self.__buffer[idx:]
73
+ # Fall through to the partial parser attempt
74
+ except json.JSONDecodeError:
75
+ # raw_decode failed, likely due to incomplete or malformed JSON.
76
+ # Proceed to the more lenient iterative partial parser.
77
+ pass
78
+ except Exception:
79
+ # Catch other potential errors during raw_decode
80
+ # print(f"Unexpected error during raw_decode: {e}") # Optional logging
81
+ pass # Fall through to partial parser
82
+
83
+ # --- Attempt 2: Use the Iterative Partial Parser ---
84
+ # This handles incomplete data, unquoted keys, single quotes etc.
85
+ self.__buffer = self.__buffer.lstrip() # Ensure no leading whitespace
86
+
87
+ # Re-check if buffer starts with '{' after potential modification from attempt 1
88
+ if not self.__buffer.startswith('{'):
89
+ start_index = self.__buffer.find("{")
90
+ if start_index == -1:
91
+ # No '{' found at all anymore
92
+ self.__buffer = ""
93
+ return {}
94
+ else:
95
+ # Discard leading content if any before '{'
96
+ self.__buffer = self.__buffer[start_index:]
97
+
98
+ # Re-check if buffer is empty
99
+ if not self.__buffer:
100
+ return {}
101
+
102
+ # Run the iterative state-machine parser
103
+ iterative_state_machine = IterativeStateMachine()
104
+ parsed_obj, consumed_idx = iterative_state_machine.parse_iterative_partial(self.__buffer)
105
+
106
+ # Update buffer based on how much was consumed
107
+ if 0 <= consumed_idx <= len(self.__buffer):
108
+ # Successfully parsed or partially parsed, remove consumed part
109
+ self.__buffer = self.__buffer[consumed_idx:]
110
+ else:
111
+ # Indicates an error or unexpected state, clear buffer to be safe
112
+ self.__buffer = ""
113
+
114
+ # Clean buffer again after partial parse attempt
115
+ self.__clean_buffer_after_parse()
116
+
117
+ # Return the parsed object only if it's a dictionary
118
+ if isinstance(parsed_obj, dict):
119
+ return parsed_obj
120
+ else:
121
+ # The partial parser might return lists or None in some edge cases/errors
122
+ return {}
123
+
124
+ def __escape_invalid_control_chars(self, s: str) -> str:
125
+ """
126
+ Escapes control characters (U+0000 to U+001F) that are invalid in JSON
127
+ unless escaped. Standard escapes like \n, \t are preserved.
128
+ """
129
+ # Mapping for standard JSON escapes within the control character range
130
+ escape_map = {
131
+ "\b": "\\b", # Backspace (U+0008)
132
+ "\f": "\\f", # Form feed (U+000C)
133
+ "\n": "\\n", # Line feed (U+000A)
134
+ "\r": "\\r", # Carriage return (U+000D)
135
+ "\t": "\\t" # Horizontal tab (U+0009)
136
+ }
137
+
138
+ # Replacement function for re.sub
139
+ def replace(match: re.Match[str]) -> str:
140
+ ch = match.group(0)
141
+ # Use standard escape if available, otherwise use \uXXXX format
142
+ return escape_map.get(ch, f"\\u{ord(ch):04x}")
143
+
144
+ # Regex to find all characters from U+0000 to U+001F
145
+ return re.sub(r"[\x00-\x1F]", replace, s)
146
+
147
+ def __clean_buffer_after_parse(self):
148
+ """
149
+ Helper to clean the buffer after a successful parse.
150
+ Removes leading whitespace and searches for the next potential object start.
151
+ If no '{' is found, clears the buffer if it only contains whitespace.
152
+ """
153
+ self.__buffer = self.__buffer.lstrip()
154
+ next_obj_start = self.__buffer.find("{")
155
+
156
+ if next_obj_start > 0:
157
+ # Found another potential object start, discard text before it
158
+ self.__buffer = self.__buffer[next_obj_start:]
159
+ elif next_obj_start == -1:
160
+ # No more '{' found. If buffer is just whitespace, clear it.
161
+ if not self.__buffer.strip():
162
+ self.__buffer = ""
163
+ # Otherwise, keep the buffer content (might be start of next partial object)
164
+
165
+ def __is_string(self, data: Any) -> bool:
166
+ """
167
+ Helper to check if the provided data is a string.
168
+ Args:
169
+ data: The data to check.
170
+ Returns:
171
+ True if data is a string, False otherwise.
172
+ """
173
+ return isinstance(data, str)
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: streaming-json-parser
3
+ Version: 0.1.0
4
+ Summary: A streaming JSON parser that processes JSON data incrementally, handling partial states. Useful for incrementally parsing partial responses from streaming outputs of Large Language Models (LLMs).
5
+ Author-email: Aramis Facchinetti <aramis.facchinetti16@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/aramisfacchinetti/streaming-json-parser
8
+ Project-URL: Repository, https://github.com/aramisfacchinetti/streaming-json-parser
9
+ Keywords: streaming,json,parser,llm,large language model,incremental parsing
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Development Status :: 5 - Production/Stable
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Text Processing
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.3.5; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # Streaming JSON Parser
30
+
31
+ ## Objective
32
+
33
+ This Python module implements a streaming JSON parser designed to process JSON data incrementally. The primary goal is to handle potentially incomplete JSON data streams, such as those produced by Large Language Models (LLMs), and return the current state of the parsed object at any time.
34
+
35
+ ## Requirements Subset
36
+
37
+ The parser is specifically designed for a subset of JSON where:
38
+
39
+ - Values consist solely of **strings** and **objects**.
40
+ - **Escape sequences** in strings are not expected (though the implementation handles them).
41
+ - **Duplicate keys** in objects are not expected (though the implementation may tolerate them, typically keeping the last value).
42
+
43
+ ## Features
44
+
45
+ - **Incremental Parsing:** Consumes JSON data in chunks via the `consume()` method.
46
+ - **Partial State Retrieval:** The `get()` method returns the currently parsed JSON object state, even if the input stream is incomplete.
47
+ - **Partial String Values:** Returns partial string values as they are received (e.g., `{"key": "val` is valid partial state).
48
+ - **Key Handling:** Keys are only included in the returned object once their value type (string or object start) is identified.
49
+ - **Robustness:** Attempts to parse standard JSON efficiently and falls back to a more lenient state-machine parser for incomplete or slightly non-standard input.
50
+ - **Non-Standard JSON:** Tolerates some non-standard features like unquoted keys and single-quoted strings.
51
+ - **Error Handling:** Attempts to recover from invalid characters or find the first valid JSON object within the buffer.
52
+ - **Support for Primitives & Arrays:** Although the requirements focused on strings and objects, the implementation also handles numbers, booleans, null, and arrays as values within objects.
53
+
54
+ ## Implementation Approach
55
+
56
+ 1. **Buffering:** The `consume()` method appends incoming data chunks to an internal string buffer after escaping potentially invalid control characters.
57
+ 2. **Parsing (`get()`):**
58
+ - The buffer is first cleaned by removing leading whitespace and any characters before the first `{`.
59
+ - It attempts parsing using `json.raw_decode` for speed and standard compliance. If a dictionary is successfully decoded, it's returned, and the consumed portion is removed from the buffer.
60
+ - If `raw_decode` fails (due to incomplete data, syntax errors, or non-standard features), it falls back to the `IterativeStateMachine`.
61
+ - The `IterativeStateMachine` parses the buffer character by character, maintaining state to handle nested structures, different value types (including non-standard ones like unquoted keys), and partial inputs.
62
+ - The `get()` method returns the dictionary parsed by either method and updates the buffer, removing the parsed object and any leading garbage before the _next_ potential object. If no complete object can be parsed, an empty dictionary is returned.
63
+
64
+ ## Assumptions and Extensions
65
+
66
+ The implementation makes the following assumptions or extends the requirements:
67
+
68
+ 1. **Handling of Additional Primitive Types:** Supports numbers (int, float), booleans (`true`, `false`), and `null` as values, beyond the specified strings and objects.
69
+ 2. **Handling of Arrays:** Supports JSON arrays (`[...]`) as values within objects and can parse them, although `get()` only returns top-level _objects_ (`dict`).
70
+ 3. **Non-Standard JSON Support:** Tolerates and parses:
71
+ - Unquoted object keys (e.g., `{key: "value"}`).
72
+ - Single-quoted strings (e.g., `{'key': 'value'}`).
73
+ 4. **Escape Sequence Handling:** Actively handles standard JSON escape sequences (e.g., `\n`, `\"`) and Unicode escapes (`\uXXXX`) within strings, although they were "not expected".
74
+ 5. **Control Character Handling:** Escapes invalid JSON control characters (U+0000 to U+001F) found _outside_ of strings in the input buffer using `\uXXXX` format during `consume`.
75
+ 6. **Error Recovery/Robustness:** Discards leading non-JSON data before the first `{` and attempts to parse the first valid object found. Handles multiple objects in the buffer sequentially across `get()` calls.
76
+ 7. **Duplicate Keys:** Does not explicitly prevent duplicate keys; standard Python dictionary behavior (last key wins) likely applies.
77
+ 8. **Efficiency Strategy:** Uses `json.raw_decode` first, falling back to a custom parser only when necessary.
78
+ 9. **Input Type:** `consume` expects string input; other types are ignored.
79
+
80
+ ## Algorithmic Complexity
81
+
82
+ The efficiency of the `StreamingJsonParser` depends on the method being called and the nature of the input data stream.
83
+
84
+ - **`consume(buffer: str)`:**
85
+
86
+ - **Time Complexity:** Primarily involves appending the new `buffer` (length `k`) to the internal buffer and performing basic character escaping. This is typically **O(k)**. String concatenation in Python can sometimes be O(N+k) where N is the current buffer size, but often optimized closer to O(k) amortized.
87
+ - **Space Complexity:** Increases the internal buffer size by O(k).
88
+
89
+ - **`get()`:**
90
+
91
+ - **Time Complexity:**
92
+ - **Fast Path (`json.raw_decode`):** If the buffer starts with a complete, standard JSON object of size `P`, Python's built-in decoder is used. This is generally efficient, expected to be around **O(P)**.
93
+ - **Fallback Path (`IterativeStateMachine`):** If `raw_decode` fails (due to incomplete data or non-standard syntax), the custom state machine parses the buffer character by character. In the worst case, it might need to scan a significant portion of the buffer (size `B'`). The complexity is dominated by this scan and subsequent buffer slicing, making it roughly **O(B')**.
94
+ - **Overall:** The complexity varies. It's close to O(P) when complete objects are readily available and standard, and approaches O(B') when parsing incomplete or non-standard streams requires the iterative fallback.
95
+ - **Space Complexity:** Does not inherently allocate significant additional space beyond the internal representation of the parsed object being returned. The main space usage comes from the internal buffer managed by `consume`.
96
+
97
+ - **Overall Space Complexity:** The primary factor is the internal buffer. In the worst case (e.g., a very large stream is consumed without any complete objects being parsed and removed by `get()`), the space complexity can be **O(T)**, where T is the total size of the streamed data received so far. In typical usage where `get()` successfully parses and removes objects, the buffer size stays manageable.
98
+
99
+ ## Usage
100
+
101
+ ```python
102
+ # Import the class
103
+ from streaming_json_parser import StreamingJsonParser
104
+
105
+ # Initialize the parser
106
+ parser = StreamingJsonParser()
107
+
108
+ # Consume JSON data chunks
109
+ parser.consume('{"name": "Example", "data": {"val') # Partial object value
110
+ parser.consume('ue": "stream"}') # Complete the object
111
+
112
+ # Get the current state of the parsed object
113
+ # This will return the first complete object found.
114
+ current_object = parser.get()
115
+ print(current_object)
116
+ # Output: {'name': 'Example', 'data': {'value': 'stream'}}
117
+
118
+ # The buffer is cleared/updated after get(), ready for the next object
119
+ parser.consume('{"next": "object"}')
120
+ next_object = parser.get()
121
+ print(next_object)
122
+ # Output: {'next': 'object'}
123
+
124
+ # Example with partial string value
125
+ parser = StreamingJsonParser()
126
+ parser.consume('{"key": "partial string')
127
+ partial_state = parser.get()
128
+ print(partial_state)
129
+ # Output: {'key': 'partial string'}
130
+
131
+ parser.consume(' complete"}')
132
+ complete_state = parser.get()
133
+ print(complete_state)
134
+ # Output: {'key': 'partial string complete'}
135
+ ```
136
+
137
+ ## Setup
138
+
139
+ To use this parser and run the tests, you need to install the dependencies:
140
+
141
+ ```bash
142
+ pip install -r requirements.txt
143
+ ```
144
+
145
+ The `requirements.txt` file includes:
146
+
147
+ - `pytest`
148
+ - `pytest-cov`
149
+
150
+ ## Testing
151
+
152
+ Unit tests are provided in `test_streaming_json_parser.py`. You can run them using `pytest`:
153
+
154
+ ```bash
155
+ pytest
156
+ ```
@@ -0,0 +1,8 @@
1
+ streaming_json_parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ streaming_json_parser/iterative_state_machine.py,sha256=LHM2tMSYwQ26snwXWiLItj5zp0ds9c5gxY2_TVt5EMQ,24555
3
+ streaming_json_parser/streaming_json_parser.py,sha256=N2QJUeRU6V2mQklL8hwS5rOuE9TT3QwzwNxR1SpDY4M,7220
4
+ streaming_json_parser-0.1.0.dist-info/licenses/LICENSE,sha256=LCzjgEJx8tM9dXg1qVkOuj9AdZgqNYdh2q0GGWqW9s0,1075
5
+ streaming_json_parser-0.1.0.dist-info/METADATA,sha256=Kc7ePMzoMmMI3kb8XCkG1vd39COE1ZbuSoVZPa6g0Ic,9208
6
+ streaming_json_parser-0.1.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
7
+ streaming_json_parser-0.1.0.dist-info/top_level.txt,sha256=esGYTSNfbg07ZazVHscWNod09hjodlQ8TNYP4sAKYh4,22
8
+ streaming_json_parser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aramis Facchinetti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ streaming_json_parser