From ced66ade2f7ebf9959585ea0efc74a7af55f6cbe Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 19 Mar 2026 21:10:27 -0300 Subject: [PATCH 01/20] array_hook for JSON decoder Add array_hook parameter, to both Python and C implementations --- Lib/json/__init__.py | 30 +++++++++++++++++++++++++----- Lib/json/decoder.py | 16 ++++++++++++++-- Lib/json/scanner.py | 3 ++- Lib/test/test_json/test_decode.py | 15 +++++++++++++++ Modules/_json.c | 13 +++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/Lib/json/__init__.py b/Lib/json/__init__.py index 89396b25a2cbb3..34130b43b415c5 100644 --- a/Lib/json/__init__.py +++ b/Lib/json/__init__.py @@ -241,7 +241,7 @@ def dumps(obj, *, skipkeys=False, ensure_ascii=True, check_circular=True, **kw).encode(obj) -_default_decoder = JSONDecoder(object_hook=None, object_pairs_hook=None) +_default_decoder = JSONDecoder(object_hook=None, object_pairs_hook=None, array_hook=None) def detect_encoding(b): @@ -275,7 +275,8 @@ def detect_encoding(b): def load(fp, *, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, object_pairs_hook=None, **kw): + parse_int=None, parse_constant=None, object_pairs_hook=None, + array_hook=None, **kw): """Deserialize ``fp`` (a ``.read()``-supporting file-like object containing a JSON document) to a Python object. @@ -291,17 +292,26 @@ def load(fp, *, cls=None, object_hook=None, parse_float=None, ``object_hook`` is also defined, the ``object_pairs_hook`` takes priority. + ``array_hook`` is an optional function that will be called with the result + of any literal array decode (a ``list``). The return value of this function will + be used instead of the ``list``. This feature can be used along + ``object_pairs_hook`` to customize the resulting data structure - for example, + by setting that to ``frozendict`` and ``array_hook`` to ``tuple``, one can get + a deep immutable data structute from any JSON data. + To use a custom ``JSONDecoder`` subclass, specify it with the ``cls`` kwarg; otherwise ``JSONDecoder`` is used. """ return loads(fp.read(), cls=cls, object_hook=object_hook, parse_float=parse_float, parse_int=parse_int, - parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw) + parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, + array_hook=None, **kw) def loads(s, *, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, object_pairs_hook=None, **kw): + parse_int=None, parse_constant=None, object_pairs_hook=None, + array_hook=None, **kw): """Deserialize ``s`` (a ``str``, ``bytes`` or ``bytearray`` instance containing a JSON document) to a Python object. @@ -317,6 +327,13 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None, ``object_hook`` is also defined, the ``object_pairs_hook`` takes priority. + ``array_hook`` is an optional function that will be called with the result + of any literal array decode (a ``list``). The return value of this function will + be used instead of the ``list``. This feature can be used along + ``object_pairs_hook`` to customize the resulting data structure - for example, + by setting that to ``frozendict`` and ``array_hook`` to ``tuple``, one can get + a deep immutable data structute from any JSON data. + ``parse_float``, if specified, will be called with the string of every JSON float to be decoded. By default this is equivalent to float(num_str). This can be used to use another datatype or parser @@ -347,7 +364,8 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None, if (cls is None and object_hook is None and parse_int is None and parse_float is None and - parse_constant is None and object_pairs_hook is None and not kw): + parse_constant is None and object_pairs_hook is None + and array_hook is None and not kw): return _default_decoder.decode(s) if cls is None: cls = JSONDecoder @@ -355,6 +373,8 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None, kw['object_hook'] = object_hook if object_pairs_hook is not None: kw['object_pairs_hook'] = object_pairs_hook + if array_hook is not None: + kw['array_hook'] = array_hook if parse_float is not None: kw['parse_float'] = parse_float if parse_int is not None: diff --git a/Lib/json/decoder.py b/Lib/json/decoder.py index 4cd6f8367a1349..e1a28b4146042f 100644 --- a/Lib/json/decoder.py +++ b/Lib/json/decoder.py @@ -218,7 +218,7 @@ def JSONObject(s_and_end, strict, scan_once, object_hook, object_pairs_hook, pairs = object_hook(pairs) return pairs, end -def JSONArray(s_and_end, scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR): +def JSONArray(s_and_end, scan_once, array_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR): s, end = s_and_end values = [] nextchar = s[end:end + 1] @@ -227,6 +227,8 @@ def JSONArray(s_and_end, scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR): nextchar = s[end:end + 1] # Look-ahead for trivial empty array if nextchar == ']': + if array_hook is not None: + values = array_hook(values) return values, end + 1 _append = values.append while True: @@ -256,6 +258,8 @@ def JSONArray(s_and_end, scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR): if nextchar == ']': raise JSONDecodeError("Illegal trailing comma before end of array", s, comma_idx) + if array_hook is not None: + return array_hook(values), end return values, end @@ -291,7 +295,7 @@ class JSONDecoder(object): def __init__(self, *, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, strict=True, - object_pairs_hook=None): + object_pairs_hook=None, array_hook=None): """``object_hook``, if specified, will be called with the result of every JSON object decoded and its return value will be used in place of the given ``dict``. This can be used to provide custom @@ -304,6 +308,13 @@ def __init__(self, *, object_hook=None, parse_float=None, If ``object_hook`` is also defined, the ``object_pairs_hook`` takes priority. + ``array_hook`` is an optional function that will be called with the result + of any literal array decode (a ``list``). The return value of this function will + be used instead of the ``list``. This feature can be used along + ``object_pairs_hook`` to customize the resulting data structure - for example, + by setting that to ``frozendict`` and ``array_hook`` to ``tuple``, one can get + a deep immutable data structute from any JSON data. + ``parse_float``, if specified, will be called with the string of every JSON float to be decoded. By default this is equivalent to float(num_str). This can be used to use another datatype or parser @@ -330,6 +341,7 @@ def __init__(self, *, object_hook=None, parse_float=None, self.parse_constant = parse_constant or _CONSTANTS.__getitem__ self.strict = strict self.object_pairs_hook = object_pairs_hook + self.array_hook = array_hook self.parse_object = JSONObject self.parse_array = JSONArray self.parse_string = scanstring diff --git a/Lib/json/scanner.py b/Lib/json/scanner.py index 090897515fe2f3..b484e00be0fd2a 100644 --- a/Lib/json/scanner.py +++ b/Lib/json/scanner.py @@ -23,6 +23,7 @@ def py_make_scanner(context): parse_constant = context.parse_constant object_hook = context.object_hook object_pairs_hook = context.object_pairs_hook + array_hook = context.array_hook memo = context.memo def _scan_once(string, idx): @@ -37,7 +38,7 @@ def _scan_once(string, idx): return parse_object((string, idx + 1), strict, _scan_once, object_hook, object_pairs_hook, memo) elif nextchar == '[': - return parse_array((string, idx + 1), _scan_once) + return parse_array((string, idx + 1), _scan_once, array_hook) elif nextchar == 'n' and string[idx:idx + 4] == 'null': return None, idx + 4 elif nextchar == 't' and string[idx:idx + 4] == 'true': diff --git a/Lib/test/test_json/test_decode.py b/Lib/test/test_json/test_decode.py index 2250af964c022b..59667d3ea76d53 100644 --- a/Lib/test/test_json/test_decode.py +++ b/Lib/test/test_json/test_decode.py @@ -69,6 +69,21 @@ def test_object_pairs_hook(self): object_pairs_hook=OrderedDict), OrderedDict([('empty', OrderedDict())])) + def test_array_hook(self): + s = '[1, 2, 3]' + + t = self.loads(s, array_hook=tuple) + self.assertEqual(t, (1, 2, 3)) + self.assertEqual(type(t), tuple) + # Array in inner structure + s = '{"xkd": [1, 2, 3]}' + p = {"xkd": (1, 2, 3)} + data = self.loads(s, array_hook=tuple) + self.assertEqual(data, p) + self.assertEqual(type(data["xkd"]), tuple) + + self.assertEqual(self.loads('[]', array_hook=tuple), ()) + def test_decoder_optimizations(self): # Several optimizations were made that skip over calls to # the whitespace regex, so this test is designed to try and diff --git a/Modules/_json.c b/Modules/_json.c index f9c4f06bac7b43..a67ef5f33e7d28 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -30,6 +30,7 @@ typedef struct _PyScannerObject { signed char strict; PyObject *object_hook; PyObject *object_pairs_hook; + PyObject *array_hook; PyObject *parse_float; PyObject *parse_int; PyObject *parse_constant; @@ -41,6 +42,7 @@ static PyMemberDef scanner_members[] = { {"strict", Py_T_BOOL, offsetof(PyScannerObject, strict), Py_READONLY, "strict"}, {"object_hook", _Py_T_OBJECT, offsetof(PyScannerObject, object_hook), Py_READONLY, "object_hook"}, {"object_pairs_hook", _Py_T_OBJECT, offsetof(PyScannerObject, object_pairs_hook), Py_READONLY}, + {"array_hook", _Py_T_OBJECT, offsetof(PyScannerObject, array_hook), Py_READONLY}, {"parse_float", _Py_T_OBJECT, offsetof(PyScannerObject, parse_float), Py_READONLY, "parse_float"}, {"parse_int", _Py_T_OBJECT, offsetof(PyScannerObject, parse_int), Py_READONLY, "parse_int"}, {"parse_constant", _Py_T_OBJECT, offsetof(PyScannerObject, parse_constant), Py_READONLY, "parse_constant"}, @@ -720,6 +722,7 @@ scanner_traverse(PyObject *op, visitproc visit, void *arg) Py_VISIT(Py_TYPE(self)); Py_VISIT(self->object_hook); Py_VISIT(self->object_pairs_hook); + Py_VISIT(self->array_hook); Py_VISIT(self->parse_float); Py_VISIT(self->parse_int); Py_VISIT(self->parse_constant); @@ -732,6 +735,7 @@ scanner_clear(PyObject *op) PyScannerObject *self = PyScannerObject_CAST(op); Py_CLEAR(self->object_hook); Py_CLEAR(self->object_pairs_hook); + Py_CLEAR(self->array_hook); Py_CLEAR(self->parse_float); Py_CLEAR(self->parse_int); Py_CLEAR(self->parse_constant); @@ -942,6 +946,12 @@ _parse_array_unicode(PyScannerObject *s, PyObject *memo, PyObject *pystr, Py_ssi goto bail; } *next_idx_ptr = idx + 1; + /* if array_hook is not None: rval = array_hook(rval) */ + if (s->array_hook != Py_None) { + val = PyObject_CallOneArg(s->array_hook, rval); + Py_DECREF(rval); + return val; + } return rval; bail: Py_XDECREF(val); @@ -1259,6 +1269,9 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) s->object_pairs_hook = PyObject_GetAttrString(ctx, "object_pairs_hook"); if (s->object_pairs_hook == NULL) goto bail; + s->array_hook = PyObject_GetAttrString(ctx, "array_hook"); + if (s->array_hook == NULL) + goto bail; s->parse_float = PyObject_GetAttrString(ctx, "parse_float"); if (s->parse_float == NULL) goto bail; From cdb6dcfa8f38009d0061f53613ee42636da013a7 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Mon, 23 Mar 2026 21:38:32 -0300 Subject: [PATCH 02/20] Update documentation for array_hook parameter --- Doc/library/json.rst | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 4a26419e65bee4..2d3187e15ba174 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -264,7 +264,7 @@ Basic Usage .. function:: load(fp, *, cls=None, object_hook=None, parse_float=None, \ parse_int=None, parse_constant=None, \ - object_pairs_hook=None, **kw) + object_pairs_hook=None, array_hook=None, **kw) Deserialize *fp* to a Python object using the :ref:`JSON-to-Python conversion table `. @@ -301,6 +301,15 @@ Basic Usage Default ``None``. :type object_pairs_hook: :term:`callable` | None + :param array_hook: + If set, a function that is called with the result of + any JSON array literal decoded with as a Python list. + The return value of this function will be used + instead of the :class:`list`. + This feature can be used to implement custom decoders. + Default ``None``. + :type array_hook: :term:`callable` | None + :param parse_float: If set, a function that is called with the string of every JSON float to be decoded. @@ -349,7 +358,10 @@ Basic Usage conversion length limitation ` to help avoid denial of service attacks. -.. function:: loads(s, *, cls=None, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, object_pairs_hook=None, **kw) + .. versionchanged:: 3.15 + * Added the optional *array_hook* parameter. + +.. function:: loads(s, *, cls=None, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, object_pairs_hook=None, array_hook=None, **kw) Identical to :func:`load`, but instead of a file-like object, deserialize *s* (a :class:`str`, :class:`bytes` or :class:`bytearray` @@ -412,6 +424,14 @@ Encoders and Decoders .. versionchanged:: 3.1 Added support for *object_pairs_hook*. + *array_hook* is an optional function that will be called with the + result of every JSON array decoded as a list. The return value of + *array_hook* will be used instead of the :class:`list`. This feature can be + used to implement custom decoders. + + .. versionchanged:: 3.15 + Added support for *array_hook*. + *parse_float* is an optional function that will be called with the string of every JSON float to be decoded. By default, this is equivalent to ``float(num_str)``. This can be used to use another datatype or parser for From c7b77eeacc05aa330343f6737d4408bc658d7524 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:06:53 +0000 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst new file mode 100644 index 00000000000000..8d863dc361c1e8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -0,0 +1,8 @@ +json +----- + +* Add the *array_hook* parameter to :func:`~load` and :func:`~loads` functions: + Allow a callback for JSON literal array types to customize Python lists in the + resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* + param and :class:`tuple` to *array_hook` will yield a deeply nested imitable + Python structure representing the JSON data. From be6cb376238961b22d2c90d8e153d90915337268 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 00:24:48 -0300 Subject: [PATCH 04/20] Fix typo in news blurb --- .../next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index 8d863dc361c1e8..f9adf4ff43508d 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -4,5 +4,5 @@ json * Add the *array_hook* parameter to :func:`~load` and :func:`~loads` functions: Allow a callback for JSON literal array types to customize Python lists in the resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* - param and :class:`tuple` to *array_hook` will yield a deeply nested imitable + param and :class:`tuple` to `array_hook` will yield a deeply nested imitable Python structure representing the JSON data. From 282050340e42615fc2de6850332879d853957f96 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 01:33:21 -0300 Subject: [PATCH 05/20] Fix doc formatting --- .../next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index f9adf4ff43508d..85bd604491b1c6 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -4,5 +4,5 @@ json * Add the *array_hook* parameter to :func:`~load` and :func:`~loads` functions: Allow a callback for JSON literal array types to customize Python lists in the resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* - param and :class:`tuple` to `array_hook` will yield a deeply nested imitable + param and :class:`tuple` to ``array_hook`` will yield a deeply nested imitable Python structure representing the JSON data. From b07938427cb74e73e9328a1c7405e5abaac95203 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 01:34:44 -0300 Subject: [PATCH 06/20] Doc typo --- .../next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index 85bd604491b1c6..cb2b0b27cb573e 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -4,5 +4,5 @@ json * Add the *array_hook* parameter to :func:`~load` and :func:`~loads` functions: Allow a callback for JSON literal array types to customize Python lists in the resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* - param and :class:`tuple` to ``array_hook`` will yield a deeply nested imitable + param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable Python structure representing the JSON data. From 7ff9a7388d8e1e277931920d240068cd012d5260 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 14:55:44 -0300 Subject: [PATCH 07/20] 'What's New' entry --- Doc/whatsnew/3.15.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0973c387a1e595..c879b7f4088cc2 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -788,6 +788,17 @@ inspect for :func:`~inspect.getdoc`. (Contributed by Serhiy Storchaka in :gh:`132686`.) +json +---- + +* Add the *array_hook* parameter to :func:`~json.load` and + :func:`~json.loads` functions: + Allow a callback for JSON literal array types to customize Python lists in + the resulting decoded object. Passing combined :class:`frozendict` to + *object_pairs_hook* param and :class:`tuple` to ``array_hook`` will yield a + deeply nested immutable Python structure representing the JSON data. + (Contributed by Joao S. O. Bueno in :gh:`146440`) + locale ------ From 47072c3caf31002a617594068306d9db21944f2d Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 15:17:31 -0300 Subject: [PATCH 08/20] Remove redundant default parameters --- Lib/json/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/json/__init__.py b/Lib/json/__init__.py index 34130b43b415c5..9e2840fbc6d338 100644 --- a/Lib/json/__init__.py +++ b/Lib/json/__init__.py @@ -241,7 +241,7 @@ def dumps(obj, *, skipkeys=False, ensure_ascii=True, check_circular=True, **kw).encode(obj) -_default_decoder = JSONDecoder(object_hook=None, object_pairs_hook=None, array_hook=None) +_default_decoder = JSONDecoder() def detect_encoding(b): From 9cd60a5194bd4d00e0912d1accdd1098356b9613 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 15:18:33 -0300 Subject: [PATCH 09/20] Fix blurb content + formatting --- .../2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index cb2b0b27cb573e..ff0d6ecea89ce1 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -1,8 +1,5 @@ -json ------ - -* Add the *array_hook* parameter to :func:`~load` and :func:`~loads` functions: - Allow a callback for JSON literal array types to customize Python lists in the - resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* - param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable - Python structure representing the JSON data. +:mod:`json`: Add the *array_hook* parameter to :func:`~json.load` and :func:`~json.loads` functions: +Allow a callback for JSON literal array types to customize Python lists in the +resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* +param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable +Python structure representing the JSON data. From 6e482971694de2ff6678cb26558cae31d3b7bdaa Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 15:19:31 -0300 Subject: [PATCH 10/20] Formatting and missing parameter to JSONDecoder --- Doc/library/json.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 2d3187e15ba174..6f962d4aa431cb 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -359,7 +359,7 @@ Basic Usage of service attacks. .. versionchanged:: 3.15 - * Added the optional *array_hook* parameter. + Added the optional *array_hook* parameter. .. function:: loads(s, *, cls=None, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, object_pairs_hook=None, array_hook=None, **kw) @@ -379,7 +379,7 @@ Basic Usage Encoders and Decoders --------------------- -.. class:: JSONDecoder(*, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, strict=True, object_pairs_hook=None) +.. class:: JSONDecoder(*, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, strict=True, object_pairs_hook=None, array_hook=None) Simple JSON decoder. @@ -429,7 +429,7 @@ Encoders and Decoders *array_hook* will be used instead of the :class:`list`. This feature can be used to implement custom decoders. - .. versionchanged:: 3.15 + .. versionchanged:: next Added support for *array_hook*. *parse_float* is an optional function that will be called with the string of From 0eed2fb3e3ebf23eed89b238f07a21f960cd2205 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 15:24:27 -0300 Subject: [PATCH 11/20] Reformat blurb --- .../2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index ff0d6ecea89ce1..58a0fe0f61155f 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -1,5 +1,6 @@ -:mod:`json`: Add the *array_hook* parameter to :func:`~json.load` and :func:`~json.loads` functions: +:mod:`json`: Add the *array_hook* parameter to :func:`~json.load` and +:func:`~json.loads` functions: Allow a callback for JSON literal array types to customize Python lists in the -resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* -param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable -Python structure representing the JSON data. +resulting decoded object. Passing combined :class:`frozendict` to +*object_pairs_hook* param and :class:`tuple` to ``array_hook`` will yield a +deeply nested immutable Python structure representing the JSON data. From 2942826ed89d8191de22133f514c7ac62b0cb04a Mon Sep 17 00:00:00 2001 From: "Joao S. O. Bueno" Date: Thu, 26 Mar 2026 18:49:27 -0300 Subject: [PATCH 12/20] Update Doc/whatsnew/3.15.rst Co-authored-by: Victor Stinner --- Doc/whatsnew/3.15.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 408719f949d53e..b27e820f4a2da8 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -806,7 +806,7 @@ json * Add the *array_hook* parameter to :func:`~json.load` and :func:`~json.loads` functions: - Allow a callback for JSON literal array types to customize Python lists in + allow a callback for JSON literal array types to customize Python lists in the resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable Python structure representing the JSON data. From 227b52d241e8faad103777748c54d64ad47b430c Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:13:29 -0300 Subject: [PATCH 13/20] Linting fixes --- Lib/json/decoder.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Lib/json/decoder.py b/Lib/json/decoder.py index e1a28b4146042f..364e44d40cc307 100644 --- a/Lib/json/decoder.py +++ b/Lib/json/decoder.py @@ -259,7 +259,7 @@ def JSONArray(s_and_end, scan_once, array_hook, _w=WHITESPACE.match, _ws=WHITESP raise JSONDecodeError("Illegal trailing comma before end of array", s, comma_idx) if array_hook is not None: - return array_hook(values), end + values = array_hook(values) return values, end @@ -308,12 +308,13 @@ def __init__(self, *, object_hook=None, parse_float=None, If ``object_hook`` is also defined, the ``object_pairs_hook`` takes priority. - ``array_hook`` is an optional function that will be called with the result - of any literal array decode (a ``list``). The return value of this function will - be used instead of the ``list``. This feature can be used along - ``object_pairs_hook`` to customize the resulting data structure - for example, - by setting that to ``frozendict`` and ``array_hook`` to ``tuple``, one can get - a deep immutable data structute from any JSON data. + ``array_hook`` is an optional function that will be called with the + result of any literal array decode (a ``list``). The return value of + this function will be used instead of the ``list``. This feature can + be used along ``object_pairs_hook`` to customize the resulting data + structure - for example, by setting that to ``frozendict`` and + ``array_hook`` to ``tuple``, one can get a deep immutable data + structute from any JSON data. ``parse_float``, if specified, will be called with the string of every JSON float to be decoded. By default this is equivalent to From 58d8897d92e191c75b280a8f757389e84e9c37e0 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:15:38 -0300 Subject: [PATCH 14/20] Capitalization typo --- .../next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst index 58a0fe0f61155f..231c56fa063e72 100644 --- a/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst +++ b/Misc/NEWS.d/next/Library/2026-03-26-02-06-52.gh-issue-146440.HXjhQO.rst @@ -1,6 +1,6 @@ :mod:`json`: Add the *array_hook* parameter to :func:`~json.load` and :func:`~json.loads` functions: -Allow a callback for JSON literal array types to customize Python lists in the +allow a callback for JSON literal array types to customize Python lists in the resulting decoded object. Passing combined :class:`frozendict` to *object_pairs_hook* param and :class:`tuple` to ``array_hook`` will yield a deeply nested immutable Python structure representing the JSON data. From 2a9cdd52ad63e9e8052cba6af49aa9ac137f025e Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:23:36 -0300 Subject: [PATCH 15/20] PEP 7 + Py_IsNone macro in functions touched --- Modules/_json.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index a67ef5f33e7d28..621b1c8aabb898 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -946,8 +946,8 @@ _parse_array_unicode(PyScannerObject *s, PyObject *memo, PyObject *pystr, Py_ssi goto bail; } *next_idx_ptr = idx + 1; - /* if array_hook is not None: rval = array_hook(rval) */ - if (s->array_hook != Py_None) { + /* if array_hook is not None: return array_hook(rval) */ + if (!Py_IsNone(s->array_hook)) { val = PyObject_CallOneArg(s->array_hook, rval); Py_DECREF(rval); return val; @@ -1247,8 +1247,9 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) PyObject *strict; static char *kwlist[] = {"context", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O:make_scanner", kwlist, &ctx)) + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O:make_scanner", kwlist, &ctx)) { return NULL; + } s = (PyScannerObject *)type->tp_alloc(type, 0); if (s == NULL) { @@ -1257,30 +1258,38 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) /* All of these will fail "gracefully" so we don't need to verify them */ strict = PyObject_GetAttrString(ctx, "strict"); - if (strict == NULL) + if (strict == NULL) { goto bail; + } s->strict = PyObject_IsTrue(strict); Py_DECREF(strict); - if (s->strict < 0) + if (s->strict < 0) { goto bail; + } s->object_hook = PyObject_GetAttrString(ctx, "object_hook"); - if (s->object_hook == NULL) + if (s->object_hook == NULL) { goto bail; + } s->object_pairs_hook = PyObject_GetAttrString(ctx, "object_pairs_hook"); - if (s->object_pairs_hook == NULL) + if (s->object_pairs_hook == NULL) { goto bail; + } s->array_hook = PyObject_GetAttrString(ctx, "array_hook"); - if (s->array_hook == NULL) + if (s->array_hook == NULL) { goto bail; + } s->parse_float = PyObject_GetAttrString(ctx, "parse_float"); - if (s->parse_float == NULL) + if (s->parse_float == NULL) { goto bail; + } s->parse_int = PyObject_GetAttrString(ctx, "parse_int"); - if (s->parse_int == NULL) + if (s->parse_int == NULL) { goto bail; + } s->parse_constant = PyObject_GetAttrString(ctx, "parse_constant"); - if (s->parse_constant == NULL) + if (s->parse_constant == NULL) { goto bail; + } return (PyObject *)s; From 9dc12582f0b26fa0edc6ff3a04826db75f10c0ab Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:24:27 -0300 Subject: [PATCH 16/20] Revert "PEP 7 + Py_IsNone macro in functions touched" This reverts commit 2a9cdd52ad63e9e8052cba6af49aa9ac137f025e. --- Modules/_json.c | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index 621b1c8aabb898..a67ef5f33e7d28 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -946,8 +946,8 @@ _parse_array_unicode(PyScannerObject *s, PyObject *memo, PyObject *pystr, Py_ssi goto bail; } *next_idx_ptr = idx + 1; - /* if array_hook is not None: return array_hook(rval) */ - if (!Py_IsNone(s->array_hook)) { + /* if array_hook is not None: rval = array_hook(rval) */ + if (s->array_hook != Py_None) { val = PyObject_CallOneArg(s->array_hook, rval); Py_DECREF(rval); return val; @@ -1247,9 +1247,8 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) PyObject *strict; static char *kwlist[] = {"context", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O:make_scanner", kwlist, &ctx)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O:make_scanner", kwlist, &ctx)) return NULL; - } s = (PyScannerObject *)type->tp_alloc(type, 0); if (s == NULL) { @@ -1258,38 +1257,30 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) /* All of these will fail "gracefully" so we don't need to verify them */ strict = PyObject_GetAttrString(ctx, "strict"); - if (strict == NULL) { + if (strict == NULL) goto bail; - } s->strict = PyObject_IsTrue(strict); Py_DECREF(strict); - if (s->strict < 0) { + if (s->strict < 0) goto bail; - } s->object_hook = PyObject_GetAttrString(ctx, "object_hook"); - if (s->object_hook == NULL) { + if (s->object_hook == NULL) goto bail; - } s->object_pairs_hook = PyObject_GetAttrString(ctx, "object_pairs_hook"); - if (s->object_pairs_hook == NULL) { + if (s->object_pairs_hook == NULL) goto bail; - } s->array_hook = PyObject_GetAttrString(ctx, "array_hook"); - if (s->array_hook == NULL) { + if (s->array_hook == NULL) goto bail; - } s->parse_float = PyObject_GetAttrString(ctx, "parse_float"); - if (s->parse_float == NULL) { + if (s->parse_float == NULL) goto bail; - } s->parse_int = PyObject_GetAttrString(ctx, "parse_int"); - if (s->parse_int == NULL) { + if (s->parse_int == NULL) goto bail; - } s->parse_constant = PyObject_GetAttrString(ctx, "parse_constant"); - if (s->parse_constant == NULL) { + if (s->parse_constant == NULL) goto bail; - } return (PyObject *)s; From fc821174f6079b25a02fbc366fe8baed8593f8a4 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:30:42 -0300 Subject: [PATCH 17/20] Change version to 'next' --- Doc/library/json.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 6f962d4aa431cb..72632a8ef53d5b 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -358,7 +358,7 @@ Basic Usage conversion length limitation ` to help avoid denial of service attacks. - .. versionchanged:: 3.15 + .. versionchanged:: next Added the optional *array_hook* parameter. .. function:: loads(s, *, cls=None, object_hook=None, parse_float=None, parse_int=None, parse_constant=None, object_pairs_hook=None, array_hook=None, **kw) From c4d6b370a97774ba14fddfd0b901409e64e84c46 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Thu, 26 Mar 2026 21:31:16 -0300 Subject: [PATCH 18/20] Nested list + frozendict testcase --- Lib/test/test_json/test_decode.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_json/test_decode.py b/Lib/test/test_json/test_decode.py index 59667d3ea76d53..e0385ff77492ff 100644 --- a/Lib/test/test_json/test_decode.py +++ b/Lib/test/test_json/test_decode.py @@ -71,17 +71,18 @@ def test_object_pairs_hook(self): def test_array_hook(self): s = '[1, 2, 3]' - t = self.loads(s, array_hook=tuple) self.assertEqual(t, (1, 2, 3)) self.assertEqual(type(t), tuple) - # Array in inner structure - s = '{"xkd": [1, 2, 3]}' - p = {"xkd": (1, 2, 3)} - data = self.loads(s, array_hook=tuple) + # Nested array in inner structure with object_hook + s = '{"xkd": [[1], [2], [3]]}' + p = frozendict(xkd=((1,), (2,), (3,))) + data = self.loads(s, object_hook=frozendict, array_hook=tuple) self.assertEqual(data, p) + self.assertEqual(type(data), frozendict) self.assertEqual(type(data["xkd"]), tuple) - + for item in data["xkd"]: + self.assertEqual(type(item), tuple) self.assertEqual(self.loads('[]', array_hook=tuple), ()) def test_decoder_optimizations(self): From 8ea13ffea8550ec527417e1c8095ef103d49dfb4 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 27 Mar 2026 11:36:47 +0100 Subject: [PATCH 19/20] Apply suggestions from code review Co-authored-by: Victor Stinner --- Lib/json/__init__.py | 8 ++++---- Lib/test/test_json/test_decode.py | 2 ++ Modules/_json.c | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/json/__init__.py b/Lib/json/__init__.py index 9e2840fbc6d338..251025efac14b8 100644 --- a/Lib/json/__init__.py +++ b/Lib/json/__init__.py @@ -275,8 +275,8 @@ def detect_encoding(b): def load(fp, *, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, object_pairs_hook=None, - array_hook=None, **kw): + parse_int=None, parse_constant=None, object_pairs_hook=None, + array_hook=None, **kw): """Deserialize ``fp`` (a ``.read()``-supporting file-like object containing a JSON document) to a Python object. @@ -310,8 +310,8 @@ def load(fp, *, cls=None, object_hook=None, parse_float=None, def loads(s, *, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, object_pairs_hook=None, - array_hook=None, **kw): + parse_int=None, parse_constant=None, object_pairs_hook=None, + array_hook=None, **kw): """Deserialize ``s`` (a ``str``, ``bytes`` or ``bytearray`` instance containing a JSON document) to a Python object. diff --git a/Lib/test/test_json/test_decode.py b/Lib/test/test_json/test_decode.py index e0385ff77492ff..d846c8af7ec434 100644 --- a/Lib/test/test_json/test_decode.py +++ b/Lib/test/test_json/test_decode.py @@ -74,6 +74,7 @@ def test_array_hook(self): t = self.loads(s, array_hook=tuple) self.assertEqual(t, (1, 2, 3)) self.assertEqual(type(t), tuple) + # Nested array in inner structure with object_hook s = '{"xkd": [[1], [2], [3]]}' p = frozendict(xkd=((1,), (2,), (3,))) @@ -83,6 +84,7 @@ def test_array_hook(self): self.assertEqual(type(data["xkd"]), tuple) for item in data["xkd"]: self.assertEqual(type(item), tuple) + self.assertEqual(self.loads('[]', array_hook=tuple), ()) def test_decoder_optimizations(self): diff --git a/Modules/_json.c b/Modules/_json.c index a67ef5f33e7d28..cd96f4dbe145ff 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1270,8 +1270,9 @@ scanner_new(PyTypeObject *type, PyObject *args, PyObject *kwds) if (s->object_pairs_hook == NULL) goto bail; s->array_hook = PyObject_GetAttrString(ctx, "array_hook"); - if (s->array_hook == NULL) + if (s->array_hook == NULL) { goto bail; + } s->parse_float = PyObject_GetAttrString(ctx, "parse_float"); if (s->parse_float == NULL) goto bail; From d1e2077688c3c33b65627667f3e4c5fa25e64dad Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 27 Mar 2026 11:42:17 +0100 Subject: [PATCH 20/20] Apply suggestions from code review Co-authored-by: Victor Stinner --- Modules/_json.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index cd96f4dbe145ff..f70c36125081d1 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -946,8 +946,8 @@ _parse_array_unicode(PyScannerObject *s, PyObject *memo, PyObject *pystr, Py_ssi goto bail; } *next_idx_ptr = idx + 1; - /* if array_hook is not None: rval = array_hook(rval) */ - if (s->array_hook != Py_None) { + /* if array_hook is not None: return array_hook(rval) */ + if (!Py_IsNone(s->array_hook)) { val = PyObject_CallOneArg(s->array_hook, rval); Py_DECREF(rval); return val;