Skip to content

Commit 7eb7a4d

Browse files
committed
gh-129711: Add a streaming C implementation of the JSON encoder
JSONEncoder.iterencode() (and thus json.dump()) now uses a streaming C iterator instead of the pure-Python generator when the _json accelerator is available. Includes no-escape fast paths for ASCII and unicode strings shared with the one-shot encoder.
1 parent 1736526 commit 7eb7a4d

3 files changed

Lines changed: 1029 additions & 94 deletions

File tree

Lib/json/encoder.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,16 +250,20 @@ def floatstr(o, allow_nan=self.allow_nan,
250250
indent = self.indent
251251
else:
252252
indent = ' ' * self.indent
253-
if _one_shot and c_make_encoder is not None:
254-
_iterencode = c_make_encoder(
253+
if c_make_encoder is not None:
254+
_encoder = c_make_encoder(
255255
markers, self.default, _encoder, indent,
256256
self.key_separator, self.item_separator, self.sort_keys,
257257
self.skipkeys, self.allow_nan)
258-
else:
259-
_iterencode = _make_iterencode(
260-
markers, self.default, _encoder, indent, floatstr,
261-
self.key_separator, self.item_separator, self.sort_keys,
262-
self.skipkeys, _one_shot)
258+
if _one_shot:
259+
return _encoder(o, 0)
260+
# Streaming C implementation: yield the encoding as str chunks.
261+
return _encoder._iterencode(o, 0)
262+
263+
_iterencode = _make_iterencode(
264+
markers, self.default, _encoder, indent, floatstr,
265+
self.key_separator, self.item_separator, self.sort_keys,
266+
self.skipkeys, _one_shot)
263267
return _iterencode(o, 0)
264268

265269
def _make_iterencode(markers, _default, _encoder, _indent, _floatstr,

Lib/test/test_json/test_dump.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,114 @@ def __str__(self):
130130
self.assertEqual(self.dumps({'key': obj}),
131131
'{"key": "nonascii:\\u00e9"}')
132132

133+
# The tests below exercise JSONEncoder.iterencode() -- the streaming
134+
# encoder. dumps()/encode() use a separate one-shot code path, so these
135+
# behaviours are not covered by the dumps()-based tests above.
136+
137+
def test_iterencode_streams_in_chunks(self):
138+
# A non-trivial structure is yielded as several chunks, not buffered
139+
# into a single string.
140+
obj = {"key": list(range(10))}
141+
chunks = list(self.json.JSONEncoder().iterencode(obj))
142+
self.assertGreater(len(chunks), 1)
143+
self.assertEqual("".join(chunks), self.dumps(obj))
144+
145+
def test_iterencode_matches_encode(self):
146+
# The streaming iterator must produce exactly the same output as the
147+
# one-shot encoder for representative inputs and options. This cross
148+
# checks the streaming path without duplicating the encoder tests.
149+
cases = [
150+
None, True, False, 0, -1, 2.5, "txt", "esc\"\n\t\\",
151+
[], {}, [1, [2, [3, []]]], {"a": {"b": {"c": 1}}},
152+
{"nums": [1, 2.0, 3], "nested": {"x": [True, None]}},
153+
list(range(50)), {str(i): i for i in range(20)},
154+
]
155+
for kw in ({}, {"indent": 2}, {"sort_keys": True},
156+
{"separators": (",", ":")}):
157+
enc = self.json.JSONEncoder(**kw)
158+
for obj in cases:
159+
with self.subTest(obj=obj, options=kw):
160+
streamed = "".join(enc.iterencode(obj))
161+
self.assertEqual(streamed, enc.encode(obj))
162+
163+
def test_iterencode_default_streams_container(self):
164+
# A container returned by default() is streamed chunk-by-chunk, not
165+
# buffered into a single chunk.
166+
class Wrapped:
167+
def __init__(self, data):
168+
self.data = data
169+
def default(o):
170+
if isinstance(o, Wrapped):
171+
return o.data
172+
raise TypeError
173+
obj = Wrapped({"a": list(range(10)), "b": Wrapped([1, 2, 3])})
174+
enc = self.json.JSONEncoder(default=default)
175+
chunks = list(enc.iterencode(obj))
176+
self.assertGreater(len(chunks), 1)
177+
self.assertEqual("".join(chunks), enc.encode(obj))
178+
179+
def test_iterencode_circular_via_default(self):
180+
# A default() result that refers back to the object passed to
181+
# default() must be reported as a circular reference.
182+
class Wrapped:
183+
pass
184+
w = Wrapped()
185+
def default(o):
186+
return [w]
187+
enc = self.json.JSONEncoder(default=default)
188+
with self.assertRaisesRegex(ValueError, "Circular reference"):
189+
list(enc.iterencode(w))
190+
191+
def test_iterencode_dict_mutated_during_streaming(self):
192+
# Mutating a dict mid-stream must not crash the interpreter. The C
193+
# iterator snapshots the dict's items; the Python iterator raises
194+
# RuntimeError. Either outcome is acceptable.
195+
d = {"k%d" % i: i for i in range(10)}
196+
it = self.json.JSONEncoder().iterencode(d)
197+
head = next(it)
198+
d.clear()
199+
d["late"] = 1
200+
try:
201+
result = head + "".join(it)
202+
except RuntimeError:
203+
return # Python backend: dict changed size during iteration
204+
# C backend: encodes the snapshot taken before the mutation.
205+
self.assertTrue(result.startswith("{") and result.endswith("}"))
206+
207+
def test_iterencode_mapping_items_mutated_during_streaming(self):
208+
# gh-142831: a dict subclass whose items() returns a list the mapping
209+
# retains -- shrunk mid-stream by a default() callback -- must not
210+
# crash. The encoder must snapshot into a list it owns exclusively.
211+
sentinel = object()
212+
213+
class Evil(dict):
214+
backing = None
215+
def items(self):
216+
Evil.backing = list(dict.items(self))
217+
return Evil.backing
218+
219+
def default(o):
220+
if o is sentinel:
221+
Evil.backing.clear() # invalidate the items list mid-stream
222+
return None
223+
raise TypeError
224+
225+
d = Evil()
226+
d["bad"] = sentinel # first item, so default() fires before the rest
227+
for i in range(30):
228+
d["k%d" % i] = i
229+
result = "".join(self.json.JSONEncoder(default=default).iterencode(d))
230+
self.assertTrue(result.startswith("{") and result.endswith("}"))
231+
232+
def test_iterencode_mapping_non_2_tuple_items(self):
233+
# A mapping whose items() does not yield 2-tuples must raise rather
234+
# than crash.
235+
class Weird(dict):
236+
def items(self):
237+
return [(1, 2, 3)]
238+
with self.assertRaises((ValueError, TypeError)):
239+
"".join(self.json.JSONEncoder().iterencode(Weird({"a": 1})))
240+
133241

134242
class TestPyDump(TestDump, PyTest): pass
135243

0 commit comments

Comments
 (0)