json-stream 2.3.4__tar.gz → 2.4.0__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 (42) hide show
  1. {json_stream-2.3.4/src/json_stream.egg-info → json_stream-2.4.0}/PKG-INFO +62 -3
  2. {json_stream-2.3.4 → json_stream-2.4.0}/README.md +61 -2
  3. {json_stream-2.3.4 → json_stream-2.4.0}/pyproject.toml +1 -1
  4. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/__init__.py +2 -2
  5. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/httpx/__init__.py +8 -0
  6. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/httpx/tests/test_httpx.py +42 -6
  7. json_stream-2.4.0/src/json_stream/loader.py +19 -0
  8. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/requests/__init__.py +8 -0
  9. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/requests/tests/test_requests.py +42 -6
  10. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/select_tokenizer.py +1 -1
  11. json_stream-2.4.0/src/json_stream/tests/test_load_many.py +102 -0
  12. json_stream-2.4.0/src/json_stream/tests/test_visit_many.py +42 -0
  13. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_writer.py +2 -2
  14. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/visitor.py +13 -7
  15. {json_stream-2.3.4 → json_stream-2.4.0/src/json_stream.egg-info}/PKG-INFO +62 -3
  16. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream.egg-info/SOURCES.txt +2 -0
  17. json_stream-2.3.4/src/json_stream/loader.py +0 -12
  18. {json_stream-2.3.4 → json_stream-2.4.0}/LICENSE.txt +0 -0
  19. {json_stream-2.3.4 → json_stream-2.4.0}/setup.cfg +0 -0
  20. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/base.py +0 -0
  21. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/dump/__init__.py +0 -0
  22. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/dump/tests/__init__.py +0 -0
  23. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/dump/tests/test_dump.py +0 -0
  24. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/dump/threading.py +0 -0
  25. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/httpx/tests/__init__.py +0 -0
  26. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/iterators.py +0 -0
  27. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/requests/tests/__init__.py +0 -0
  28. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/__init__.py +0 -0
  29. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_buffering.py +0 -0
  30. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_collection_abcs.py +0 -0
  31. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_iterators.py +0 -0
  32. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_loader.py +0 -0
  33. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_tokenizer.py +0 -0
  34. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_tokenizer_integration.py +0 -0
  35. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_util.py +0 -0
  36. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tests/test_visitor.py +0 -0
  37. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/tokenizer.py +0 -0
  38. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/util.py +0 -0
  39. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream/writer.py +0 -0
  40. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream.egg-info/dependency_links.txt +0 -0
  41. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream.egg-info/requires.txt +0 -0
  42. {json_stream-2.3.4 → json_stream-2.4.0}/src/json_stream.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-stream
3
- Version: 2.3.4
3
+ Version: 2.4.0
4
4
  Summary: Streaming JSON encoder and decoder
5
5
  Author-email: Jamie Cockburn <jamie_cockburn@hotmail.co.uk>
6
6
  License: Copyright (c) 2020 Jamie Cockburn
@@ -61,8 +61,8 @@ Simple streaming JSON parser and encoder.
61
61
 
62
62
  When [reading](#reading) JSON data, `json-stream` can decode JSON data in
63
63
  a streaming manner, providing a pythonic dict/list-like interface, or a
64
- [visitor-based interfeace](#visitor). Can stream from files, [URLs](#urls)
65
- or [iterators](#iterators).
64
+ [visitor-based interface](#visitor). It can stream from files, [URLs](#urls)
65
+ or [iterators](#iterators). It can process [multiple JSON documents](#multiple) in a single stream.
66
66
 
67
67
  When [writing](#writing) JSON data, `json-stream` can stream JSON objects
68
68
  as you generate them.
@@ -278,6 +278,65 @@ z at path ('xxxx', 3)
278
278
  [] at path ('xxxx', 5)
279
279
  ```
280
280
 
281
+ ### <a id="multiple"></a> Multiple JSON documents: `load_many()` and `visit_many()`
282
+
283
+ Sometimes JSON data arrives as a sequence of top‑level JSON texts rather than a single array/object. json-stream supports this pattern with:
284
+ - `json_stream.load_many(...)`: yields each top-level JSON value as it is parsed.
285
+ - `json_stream.visit_many(...)`: visits each top-level JSON value and yields control after each one.
286
+
287
+ These functions are useful for common streaming formats:
288
+ - NDJSON (Newline-Delimited JSON, also known as JSON Lines, content-type often application/x-ndjson): one JSON value per line, separated by a single "\n".
289
+ - Concatenated or sequential JSON documents: complete JSON texts written back-to-back without delimiters.
290
+
291
+ Note about concatenated JSON without explicit delimiters:
292
+
293
+ When multiple top‑level JSON documents are simply concatenated with
294
+ no delimiters (no newlines or no spaces), json-stream can unambiguously
295
+ detect the document boundaries for the following top-level values:
296
+ - objects: `{ ... }`
297
+ - arrays: `[ ... ]`
298
+ - literals: `true`, `false`, `null`
299
+
300
+ However, numbers and strings require a delimiter between consecutive
301
+ documents so the tokenizer can tell where one ends and the next begins.
302
+ A delimiter can be as simple as a single whitespace or a newline.
303
+
304
+ Examples:
305
+ - Valid without delimiters (will parse as 2 docs):
306
+ - `{"a":1}{"b":2}` → `{...}`, `{...}`
307
+ - `[1][2]` → `[1]`, `[2]`
308
+ - `truefalse` → `true`, `false`
309
+ - `null[]` → `null`, `[]`
310
+ - These will error without a delimiter between documents (e.g. a space or newline):
311
+ - `3true`
312
+ - `0"x"`
313
+ - `"hi""there"
314
+
315
+ #### Examples
316
+
317
+ Read many from a file containing NDJSON or concatenated JSON:
318
+ ```python
319
+ import json_stream
320
+
321
+ with open("events.ndjson", "rb") as f: # NDJSON: one JSON object per line
322
+ for item in json_stream.load_many(f):
323
+ # each item is the top-level item in each JSON text
324
+ handle(item)
325
+ ```
326
+
327
+ Visit many values in many documents:
328
+ ```python
329
+ import json_stream
330
+
331
+ def visitor(value, path):
332
+ # called depth-first for each value within the current top-level JSON text
333
+ ...
334
+
335
+ for _ in json_stream.visit_many(open("events.ndjson", "rb"), visitor):
336
+ # loop advances one top-level JSON text at a time
337
+ pass
338
+ ```
339
+
281
340
  ### <a id="urls"></a> Stream a URL
282
341
 
283
342
  `json_stream` knows how to stream directly from a URL using a variety of packages.
@@ -9,8 +9,8 @@ Simple streaming JSON parser and encoder.
9
9
 
10
10
  When [reading](#reading) JSON data, `json-stream` can decode JSON data in
11
11
  a streaming manner, providing a pythonic dict/list-like interface, or a
12
- [visitor-based interfeace](#visitor). Can stream from files, [URLs](#urls)
13
- or [iterators](#iterators).
12
+ [visitor-based interface](#visitor). It can stream from files, [URLs](#urls)
13
+ or [iterators](#iterators). It can process [multiple JSON documents](#multiple) in a single stream.
14
14
 
15
15
  When [writing](#writing) JSON data, `json-stream` can stream JSON objects
16
16
  as you generate them.
@@ -226,6 +226,65 @@ z at path ('xxxx', 3)
226
226
  [] at path ('xxxx', 5)
227
227
  ```
228
228
 
229
+ ### <a id="multiple"></a> Multiple JSON documents: `load_many()` and `visit_many()`
230
+
231
+ Sometimes JSON data arrives as a sequence of top‑level JSON texts rather than a single array/object. json-stream supports this pattern with:
232
+ - `json_stream.load_many(...)`: yields each top-level JSON value as it is parsed.
233
+ - `json_stream.visit_many(...)`: visits each top-level JSON value and yields control after each one.
234
+
235
+ These functions are useful for common streaming formats:
236
+ - NDJSON (Newline-Delimited JSON, also known as JSON Lines, content-type often application/x-ndjson): one JSON value per line, separated by a single "\n".
237
+ - Concatenated or sequential JSON documents: complete JSON texts written back-to-back without delimiters.
238
+
239
+ Note about concatenated JSON without explicit delimiters:
240
+
241
+ When multiple top‑level JSON documents are simply concatenated with
242
+ no delimiters (no newlines or no spaces), json-stream can unambiguously
243
+ detect the document boundaries for the following top-level values:
244
+ - objects: `{ ... }`
245
+ - arrays: `[ ... ]`
246
+ - literals: `true`, `false`, `null`
247
+
248
+ However, numbers and strings require a delimiter between consecutive
249
+ documents so the tokenizer can tell where one ends and the next begins.
250
+ A delimiter can be as simple as a single whitespace or a newline.
251
+
252
+ Examples:
253
+ - Valid without delimiters (will parse as 2 docs):
254
+ - `{"a":1}{"b":2}` → `{...}`, `{...}`
255
+ - `[1][2]` → `[1]`, `[2]`
256
+ - `truefalse` → `true`, `false`
257
+ - `null[]` → `null`, `[]`
258
+ - These will error without a delimiter between documents (e.g. a space or newline):
259
+ - `3true`
260
+ - `0"x"`
261
+ - `"hi""there"
262
+
263
+ #### Examples
264
+
265
+ Read many from a file containing NDJSON or concatenated JSON:
266
+ ```python
267
+ import json_stream
268
+
269
+ with open("events.ndjson", "rb") as f: # NDJSON: one JSON object per line
270
+ for item in json_stream.load_many(f):
271
+ # each item is the top-level item in each JSON text
272
+ handle(item)
273
+ ```
274
+
275
+ Visit many values in many documents:
276
+ ```python
277
+ import json_stream
278
+
279
+ def visitor(value, path):
280
+ # called depth-first for each value within the current top-level JSON text
281
+ ...
282
+
283
+ for _ in json_stream.visit_many(open("events.ndjson", "rb"), visitor):
284
+ # loop advances one top-level JSON text at a time
285
+ pass
286
+ ```
287
+
229
288
  ### <a id="urls"></a> Stream a URL
230
289
 
231
290
  `json_stream` knows how to stream directly from a URL using a variety of packages.
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "json-stream"
8
- version = "2.3.4"
8
+ version = "2.4.0"
9
9
  authors = [
10
10
  {name = "Jamie Cockburn", email="jamie_cockburn@hotmail.co.uk"},
11
11
  ]
@@ -1,4 +1,4 @@
1
- from json_stream.loader import load # noqa: F401
2
- from json_stream.visitor import visit # noqa: F401
1
+ from json_stream.loader import load, load_many # noqa: F401
2
+ from json_stream.visitor import visit, visit_many # noqa: F401
3
3
  from json_stream.writer import streamable_list, streamable_dict # noqa: F401
4
4
  from json_stream.util import to_standard_types
@@ -13,5 +13,13 @@ def load(response, persistent=False, tokenizer=default_tokenizer, chunk_size=CON
13
13
  return json_stream.load(_to_iterable(response, chunk_size), persistent=persistent, tokenizer=tokenizer)
14
14
 
15
15
 
16
+ def load_many(response, persistent=False, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
17
+ return json_stream.load_many(_to_iterable(response, chunk_size), persistent=persistent, tokenizer=tokenizer)
18
+
19
+
16
20
  def visit(response, visitor, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
17
21
  return json_stream.visit(_to_iterable(response, chunk_size), visitor, tokenizer=tokenizer)
22
+
23
+
24
+ def visit_many(response, visitor, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
25
+ return json_stream.visit_many(_to_iterable(response, chunk_size), visitor, tokenizer=tokenizer)
@@ -4,7 +4,8 @@ from itertools import zip_longest
4
4
  from unittest import TestCase
5
5
  from unittest.mock import Mock
6
6
 
7
- from json_stream.httpx import load, visit
7
+ from json_stream import to_standard_types
8
+ from json_stream.httpx import load, visit, load_many, visit_many
8
9
 
9
10
 
10
11
  class TestLoad(TestCase):
@@ -16,13 +17,14 @@ class TestLoad(TestCase):
16
17
  args = [iter(iterable)] * n
17
18
  return zip_longest(*args, fillvalue="")
18
19
 
19
- def _create_mock_response(self):
20
+ def _create_mock_response(self, data=None):
20
21
  # httpx iter_bytes returns an iterable of bytes
21
22
  response = Mock()
22
- data = json.dumps({
23
- "a": "a" * io.DEFAULT_BUFFER_SIZE,
24
- "b": "b",
25
- })
23
+ if data is None:
24
+ data = json.dumps({
25
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
26
+ "b": "b",
27
+ })
26
28
  content = ("".join(chunk).encode() for chunk in self.grouper(data, 1024))
27
29
  response.iter_bytes.return_value = content
28
30
  return response
@@ -69,3 +71,37 @@ class TestLoad(TestCase):
69
71
  ('a' * io.DEFAULT_BUFFER_SIZE, ('a',)),
70
72
  ('b', ('b',)),
71
73
  ], visited)
74
+
75
+ def test_load_many(self):
76
+ expected = [{
77
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
78
+ }, {
79
+ "b": "b" * io.DEFAULT_BUFFER_SIZE,
80
+ }]
81
+ data = "".join(json.dumps(i) for i in expected)
82
+ response = self._create_mock_response(data=data)
83
+ count = 0
84
+ for item, exp in zip(load_many(response), expected):
85
+ self.assertEqual(exp, to_standard_types(item))
86
+ count += 1
87
+ self.assertEqual(count, len(expected))
88
+
89
+ def test_visit_many(self):
90
+ items = [{
91
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
92
+ }, {
93
+ "b": "b" * io.DEFAULT_BUFFER_SIZE,
94
+ }]
95
+ data = "".join(json.dumps(e) for e in items)
96
+ response = self._create_mock_response(data=data)
97
+ current_visit = []
98
+ visits = []
99
+
100
+ def visitor(item, path):
101
+ current_visit.append((item, path))
102
+ for _ in visit_many(response, visitor):
103
+ visits.append(current_visit)
104
+ current_visit = []
105
+ self.assertEqual(len(visits), len(items))
106
+ self.assertEqual(visits[0], [('a' * io.DEFAULT_BUFFER_SIZE, ('a',))])
107
+ self.assertEqual(visits[1], [('b' * io.DEFAULT_BUFFER_SIZE, ('b',))])
@@ -0,0 +1,19 @@
1
+ from json_stream.base import StreamingJSONBase, TokenType
2
+ from json_stream.iterators import ensure_file
3
+ from json_stream.select_tokenizer import default_tokenizer
4
+
5
+
6
+ def load(fp_or_iterable, persistent=False, tokenizer=default_tokenizer):
7
+ return next(load_many(fp_or_iterable, persistent, tokenizer))
8
+
9
+
10
+ def load_many(fp_or_iterable, persistent=False, tokenizer=default_tokenizer):
11
+ fp = ensure_file(fp_or_iterable)
12
+ token_stream = tokenizer(fp)
13
+ for token_type, token in token_stream:
14
+ if token_type == TokenType.OPERATOR:
15
+ data = StreamingJSONBase.factory(token, token_stream, persistent)
16
+ yield data
17
+ data.read_all()
18
+ else:
19
+ yield token
@@ -13,5 +13,13 @@ def load(response, persistent=False, tokenizer=default_tokenizer, chunk_size=CON
13
13
  return json_stream.load(_to_iterable(response, chunk_size), persistent=persistent, tokenizer=tokenizer)
14
14
 
15
15
 
16
+ def load_many(response, persistent=False, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
17
+ return json_stream.load_many(_to_iterable(response, chunk_size), persistent=persistent, tokenizer=tokenizer)
18
+
19
+
16
20
  def visit(response, visitor, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
17
21
  return json_stream.visit(_to_iterable(response, chunk_size), visitor, tokenizer=tokenizer)
22
+
23
+
24
+ def visit_many(response, visitor, tokenizer=default_tokenizer, chunk_size=CONTENT_CHUNK_SIZE):
25
+ return json_stream.visit_many(_to_iterable(response, chunk_size), visitor, tokenizer=tokenizer)
@@ -4,7 +4,8 @@ from itertools import zip_longest
4
4
  from unittest import TestCase
5
5
  from unittest.mock import Mock
6
6
 
7
- from json_stream.requests import load, visit
7
+ from json_stream import to_standard_types
8
+ from json_stream.requests import load, visit, load_many, visit_many
8
9
 
9
10
 
10
11
  class TestLoad(TestCase):
@@ -17,13 +18,14 @@ class TestLoad(TestCase):
17
18
  args = [iter(iterable)] * n
18
19
  return zip_longest(*args, fillvalue="")
19
20
 
20
- def _create_mock_response(self):
21
+ def _create_mock_response(self, data=None):
21
22
  # requests iter_content returns an iterable of bytes
22
23
  response = Mock()
23
- data = json.dumps({
24
- "a": "a" * io.DEFAULT_BUFFER_SIZE,
25
- "b": "b",
26
- })
24
+ if data is None:
25
+ data = json.dumps({
26
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
27
+ "b": "b",
28
+ })
27
29
  content = ("".join(chunk).encode() for chunk in self.grouper(data, 1024))
28
30
  response.iter_content.return_value = content
29
31
  return response
@@ -70,3 +72,37 @@ class TestLoad(TestCase):
70
72
  ('a' * io.DEFAULT_BUFFER_SIZE, ('a',)),
71
73
  ('b', ('b',)),
72
74
  ], visited)
75
+
76
+ def test_load_many(self):
77
+ expected = [{
78
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
79
+ }, {
80
+ "b": "b" * io.DEFAULT_BUFFER_SIZE,
81
+ }]
82
+ data = "".join(json.dumps(i) for i in expected)
83
+ response = self._create_mock_response(data=data)
84
+ count = 0
85
+ for item, exp in zip(load_many(response), expected):
86
+ self.assertEqual(exp, to_standard_types(item))
87
+ count += 1
88
+ self.assertEqual(count, len(expected))
89
+
90
+ def test_visit_many(self):
91
+ items = [{
92
+ "a": "a" * io.DEFAULT_BUFFER_SIZE,
93
+ }, {
94
+ "b": "b" * io.DEFAULT_BUFFER_SIZE,
95
+ }]
96
+ data = "".join(json.dumps(e) for e in items)
97
+ response = self._create_mock_response(data=data)
98
+ current_visit = []
99
+ visits = []
100
+
101
+ def visitor(item, path):
102
+ current_visit.append((item, path))
103
+ for _ in visit_many(response, visitor):
104
+ visits.append(current_visit)
105
+ current_visit = []
106
+ self.assertEqual(len(visits), len(items))
107
+ self.assertEqual(visits[0], [('a' * io.DEFAULT_BUFFER_SIZE, ('a',))])
108
+ self.assertEqual(visits[1], [('b' * io.DEFAULT_BUFFER_SIZE, ('b',))])
@@ -5,7 +5,7 @@ from json_stream_rs_tokenizer import rust_tokenizer_or_raise, ExtensionException
5
5
 
6
6
  try:
7
7
  default_tokenizer = rust_tokenizer_or_raise()
8
- except ExtensionException as e:
8
+ except ExtensionException as e: # pragma: no cover
9
9
  warn(str(e), category=ImportWarning) # ImportWarnings are ignored by default
10
10
  default_tokenizer = tokenize
11
11
 
@@ -0,0 +1,102 @@
1
+ import unittest
2
+ from io import StringIO, BytesIO
3
+ from unittest import TestCase
4
+
5
+ import json_stream
6
+ from json_stream import to_standard_types
7
+
8
+
9
+ class TestLoadMany(TestCase):
10
+ def test_load_many(self):
11
+ # NDJSON-like: one JSON value per line
12
+ payload = "\n".join([
13
+ '{"a": 1}',
14
+ '[1, 2]',
15
+ '3',
16
+ 'true',
17
+ 'null',
18
+ '"x"',
19
+ '{}',
20
+ '[]',
21
+ ])
22
+ stream = StringIO(payload)
23
+ items = [
24
+ to_standard_types(v) for v in json_stream.load_many(stream, persistent=True)
25
+ ]
26
+ self.assertListEqual(
27
+ items,
28
+ [
29
+ {"a": 1},
30
+ [1, 2],
31
+ 3,
32
+ True,
33
+ None,
34
+ "x",
35
+ {},
36
+ [],
37
+ ],
38
+ )
39
+
40
+ def test_load_many_concatenated(self):
41
+ payload = b'{"a": 1}[1,2]truenull"x"{}[]'
42
+ stream = BytesIO(payload)
43
+ items = [
44
+ to_standard_types(v) for v in json_stream.load_many(stream, persistent=False)
45
+ ]
46
+ # Even when persistent=False, materialization should consume correctly per item
47
+ self.assertListEqual(
48
+ items,
49
+ [
50
+ {"a": 1},
51
+ [1, 2],
52
+ True,
53
+ None,
54
+ "x",
55
+ {},
56
+ [],
57
+ ],
58
+ )
59
+
60
+ @unittest.expectedFailure
61
+ def test_load_many_incompatible_concatenations(self):
62
+ # this fails because the tokeniser want's certain primitives to be followed by delimiter
63
+ payloads = [
64
+ [b'{"a": 1}[]', [{"a": 1}, []]], # this one works
65
+ [b'3true', [3, True]],
66
+ [b'3.0true', [3.0, True]],
67
+ [b'1.2e11true', [120000000000.0, True]],
68
+ [b'0true', [0, True]],
69
+ [b'""true', [0, True]],
70
+ ]
71
+ errors = []
72
+ for payload, expected in payloads:
73
+ try:
74
+ stream = BytesIO(payload)
75
+ items = [
76
+ to_standard_types(v) for v in json_stream.load_many(stream, persistent=False)
77
+ ]
78
+ self.assertListEqual(expected, items)
79
+ except ValueError as e:
80
+ errors.append(f"{payload}: {e}")
81
+ self.assertListEqual([], errors)
82
+
83
+ def test_load_many_skips_after_item_partially_consumed(self):
84
+ # Ensure that after yielding an object/array, the generator resumes and continues
85
+ # to the next top-level JSON text correctly.
86
+ payload = '{"first": [1, 2, 3]} {"second": {"x": 1}, "unconsumed": 7} 4'
87
+ stream = StringIO(payload)
88
+
89
+ gen = json_stream.load_many(stream, persistent=True)
90
+
91
+ first = next(gen)
92
+ # consume partially then fully
93
+ self.assertEqual(list(first["first"]), [1, 2, 3])
94
+
95
+ second = next(gen)
96
+ self.assertEqual(dict(second["second"].items()), {"x": 1})
97
+
98
+ third = next(gen)
99
+ self.assertEqual(third, 4)
100
+
101
+ with self.assertRaises(StopIteration):
102
+ next(gen)
@@ -0,0 +1,42 @@
1
+ from io import StringIO
2
+ from unittest import TestCase
3
+
4
+ import json_stream
5
+
6
+
7
+ class TestVisitMany(TestCase):
8
+ def test_visit_many(self):
9
+ payload = "\n".join([
10
+ '{"a": 1}',
11
+ '[1, 2]',
12
+ '3',
13
+ 'true',
14
+ 'null',
15
+ '"x"',
16
+ '{}',
17
+ '[]',
18
+ ])
19
+ stream = StringIO(payload)
20
+
21
+ visited_batches = []
22
+ current = []
23
+
24
+ def visitor(item, path):
25
+ current.append((item, path))
26
+
27
+ for _ in json_stream.visit_many(stream, visitor):
28
+ visited_batches.append(current)
29
+ current = []
30
+
31
+ # We only verify that per-top-level JSON text we received at least one visit
32
+ # and that the first visited path matches the expected top-level structure.
33
+ # Detailed traversal behavior is covered by other visitor tests.
34
+ self.assertEqual(len(visited_batches), 8)
35
+ self.assertEqual(visited_batches[0][0], (1, ('a',))) # {"a": 1}
36
+ self.assertEqual(visited_batches[1][0], (1, (0,))) # [1, 2]
37
+ self.assertEqual(visited_batches[2][0], (3, ())) # 3
38
+ self.assertEqual(visited_batches[3][0], (True, ())) # true
39
+ self.assertEqual(visited_batches[4][0], (None, ())) # null
40
+ self.assertEqual(visited_batches[5][0], ("x", ())) # "x"
41
+ self.assertEqual(visited_batches[6][0], ({}, ())) # {}
42
+ self.assertEqual(visited_batches[7][0], ([], ())) # []
@@ -7,7 +7,7 @@ from json_stream.writer import streamable_dict, streamable_list
7
7
 
8
8
  class BaseTestWriter:
9
9
  def dump(self, o):
10
- raise NotImplementedError()
10
+ raise NotImplementedError() # pragma: no cover
11
11
 
12
12
  def test_writer_wrapper(self):
13
13
  def dict_content(n):
@@ -35,7 +35,7 @@ class BaseTestWriter:
35
35
  @streamable_list
36
36
  def empty_list():
37
37
  for i in range(0): # never yields
38
- yield i
38
+ yield i # pragma: no cover
39
39
 
40
40
  o = empty_list()
41
41
  result = self.dump(o)
@@ -20,12 +20,18 @@ def _visit(obj, visitor, path):
20
20
  visitor(obj, path)
21
21
 
22
22
 
23
- def visit(fp_or_iterator, visitor, tokenizer=default_tokenizer):
23
+ def visit_many(fp_or_iterator, visitor, tokenizer=default_tokenizer):
24
24
  fp = ensure_file(fp_or_iterator)
25
25
  token_stream = tokenizer(fp)
26
- token_type, token = next(token_stream)
27
- if token_type == TokenType.OPERATOR:
28
- obj = StreamingJSONBase.factory(token, token_stream, persistent=False)
29
- else:
30
- obj = token
31
- _visit(obj, visitor, ())
26
+ for token_type, token in token_stream:
27
+ if token_type == TokenType.OPERATOR:
28
+ obj = StreamingJSONBase.factory(token, token_stream, persistent=False)
29
+ _visit(obj, visitor, ())
30
+ obj.read_all()
31
+ else:
32
+ _visit(token, visitor, ())
33
+ yield
34
+
35
+
36
+ def visit(fp_or_iterator, visitor, tokenizer=default_tokenizer):
37
+ next(visit_many(fp_or_iterator, visitor, tokenizer))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-stream
3
- Version: 2.3.4
3
+ Version: 2.4.0
4
4
  Summary: Streaming JSON encoder and decoder
5
5
  Author-email: Jamie Cockburn <jamie_cockburn@hotmail.co.uk>
6
6
  License: Copyright (c) 2020 Jamie Cockburn
@@ -61,8 +61,8 @@ Simple streaming JSON parser and encoder.
61
61
 
62
62
  When [reading](#reading) JSON data, `json-stream` can decode JSON data in
63
63
  a streaming manner, providing a pythonic dict/list-like interface, or a
64
- [visitor-based interfeace](#visitor). Can stream from files, [URLs](#urls)
65
- or [iterators](#iterators).
64
+ [visitor-based interface](#visitor). It can stream from files, [URLs](#urls)
65
+ or [iterators](#iterators). It can process [multiple JSON documents](#multiple) in a single stream.
66
66
 
67
67
  When [writing](#writing) JSON data, `json-stream` can stream JSON objects
68
68
  as you generate them.
@@ -278,6 +278,65 @@ z at path ('xxxx', 3)
278
278
  [] at path ('xxxx', 5)
279
279
  ```
280
280
 
281
+ ### <a id="multiple"></a> Multiple JSON documents: `load_many()` and `visit_many()`
282
+
283
+ Sometimes JSON data arrives as a sequence of top‑level JSON texts rather than a single array/object. json-stream supports this pattern with:
284
+ - `json_stream.load_many(...)`: yields each top-level JSON value as it is parsed.
285
+ - `json_stream.visit_many(...)`: visits each top-level JSON value and yields control after each one.
286
+
287
+ These functions are useful for common streaming formats:
288
+ - NDJSON (Newline-Delimited JSON, also known as JSON Lines, content-type often application/x-ndjson): one JSON value per line, separated by a single "\n".
289
+ - Concatenated or sequential JSON documents: complete JSON texts written back-to-back without delimiters.
290
+
291
+ Note about concatenated JSON without explicit delimiters:
292
+
293
+ When multiple top‑level JSON documents are simply concatenated with
294
+ no delimiters (no newlines or no spaces), json-stream can unambiguously
295
+ detect the document boundaries for the following top-level values:
296
+ - objects: `{ ... }`
297
+ - arrays: `[ ... ]`
298
+ - literals: `true`, `false`, `null`
299
+
300
+ However, numbers and strings require a delimiter between consecutive
301
+ documents so the tokenizer can tell where one ends and the next begins.
302
+ A delimiter can be as simple as a single whitespace or a newline.
303
+
304
+ Examples:
305
+ - Valid without delimiters (will parse as 2 docs):
306
+ - `{"a":1}{"b":2}` → `{...}`, `{...}`
307
+ - `[1][2]` → `[1]`, `[2]`
308
+ - `truefalse` → `true`, `false`
309
+ - `null[]` → `null`, `[]`
310
+ - These will error without a delimiter between documents (e.g. a space or newline):
311
+ - `3true`
312
+ - `0"x"`
313
+ - `"hi""there"
314
+
315
+ #### Examples
316
+
317
+ Read many from a file containing NDJSON or concatenated JSON:
318
+ ```python
319
+ import json_stream
320
+
321
+ with open("events.ndjson", "rb") as f: # NDJSON: one JSON object per line
322
+ for item in json_stream.load_many(f):
323
+ # each item is the top-level item in each JSON text
324
+ handle(item)
325
+ ```
326
+
327
+ Visit many values in many documents:
328
+ ```python
329
+ import json_stream
330
+
331
+ def visitor(value, path):
332
+ # called depth-first for each value within the current top-level JSON text
333
+ ...
334
+
335
+ for _ in json_stream.visit_many(open("events.ndjson", "rb"), visitor):
336
+ # loop advances one top-level JSON text at a time
337
+ pass
338
+ ```
339
+
281
340
  ### <a id="urls"></a> Stream a URL
282
341
 
283
342
  `json_stream` knows how to stream directly from a URL using a variety of packages.
@@ -29,9 +29,11 @@ src/json_stream/tests/__init__.py
29
29
  src/json_stream/tests/test_buffering.py
30
30
  src/json_stream/tests/test_collection_abcs.py
31
31
  src/json_stream/tests/test_iterators.py
32
+ src/json_stream/tests/test_load_many.py
32
33
  src/json_stream/tests/test_loader.py
33
34
  src/json_stream/tests/test_tokenizer.py
34
35
  src/json_stream/tests/test_tokenizer_integration.py
35
36
  src/json_stream/tests/test_util.py
37
+ src/json_stream/tests/test_visit_many.py
36
38
  src/json_stream/tests/test_visitor.py
37
39
  src/json_stream/tests/test_writer.py
@@ -1,12 +0,0 @@
1
- from json_stream.base import StreamingJSONBase, TokenType
2
- from json_stream.iterators import ensure_file
3
- from json_stream.select_tokenizer import default_tokenizer
4
-
5
-
6
- def load(fp_or_iterable, persistent=False, tokenizer=default_tokenizer):
7
- fp = ensure_file(fp_or_iterable)
8
- token_stream = tokenizer(fp)
9
- token_type, token = next(token_stream)
10
- if token_type == TokenType.OPERATOR:
11
- return StreamingJSONBase.factory(token, token_stream, persistent)
12
- return token
File without changes
File without changes