diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 42cb1f850fe9c5..cc2013e5d7cefd 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -172,9 +172,14 @@ Basic Usage If *allow_nan* is false (default: ``True``), then it will be a :exc:`ValueError` to serialize out of range :class:`float` values (``nan``, - ``inf``, ``-inf``) in strict compliance of the JSON specification. - If *allow_nan* is true, their JavaScript equivalents (``NaN``, - ``Infinity``, ``-Infinity``) will be used. + ``inf``, ``-inf``) in strict compliance with the JSON specification. If + *allow_nan* is the string ``'as_null'``, NaNs and infinities will be + converted to a JSON ``null``, matching the behavior of JavaScript's + ``JSON.stringify``. If *allow_nan* is true but not equal to ``'as_null'`` + then NaNs and infinities are converted to non-quote-delimited strings + ``NaN``, ``Infinity`` and ``-Infinity`` in the JSON output. Note that this + represents an extension of the JSON specification, and that the generated + output may not be accepted as valid JSON by third-party JSON parsers. If *indent* is a non-negative integer or string, then JSON array elements and object members will be pretty-printed with that indent level. An indent level @@ -209,6 +214,11 @@ Basic Usage .. versionchanged:: 3.6 All optional parameters are now :ref:`keyword-only `. + .. versionchanged:: 3.14 + Added support for ``allow_nan='as_null'``. Passing any string value + other than ``'as_null'`` for *allow_nan* now triggers a + :exc:`DeprecationWarning`. + .. note:: Unlike :mod:`pickle` and :mod:`marshal`, JSON is not a framed protocol, @@ -450,11 +460,16 @@ Encoders and Decoders prevent an infinite recursion (which would cause a :exc:`RecursionError`). Otherwise, no such check takes place. - If *allow_nan* is true (the default), then ``NaN``, ``Infinity``, and - ``-Infinity`` will be encoded as such. This behavior is not JSON - specification compliant, but is consistent with most JavaScript based - encoders and decoders. Otherwise, it will be a :exc:`ValueError` to encode - such floats. + If *allow_nan* is false (default: ``True``), then it will be a + :exc:`ValueError` to serialize out of range :class:`float` values (``nan``, + ``inf``, ``-inf``) in strict compliance with the JSON specification. If + *allow_nan* is the string ``'as_null'``, NaNs and infinities will be + converted to a JSON ``null``, matching the behavior of JavaScript's + ``JSON.stringify``. If *allow_nan* is true but not equal to ``'as_null'`` + then NaNs and infinities are converted to non-quote-delimited strings + ``NaN``, ``Infinity`` and ``-Infinity`` in the JSON output. Note that this + represents an extension of the JSON specification, and that the generated + output may not be accepted as valid JSON by third-party JSON parsers. If *sort_keys* is true (default: ``False``), then the output of dictionaries will be sorted by key; this is useful for regression tests to ensure that @@ -486,6 +501,10 @@ Encoders and Decoders .. versionchanged:: 3.6 All parameters are now :ref:`keyword-only `. + .. versionchanged:: 3.14 + Added support for ``allow_nan='as_null'``. Passing any string value + other than ``'as_null'`` for *allow_nan* now triggers a + :exc:`DeprecationWarning`. .. method:: default(o) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index bcb1098f43d5a3..4fda5b31c81435 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -86,6 +86,14 @@ New Modules Improved Modules ================ +json +---- + +* Add support for ``allow_nan='as_null'`` when encoding to JSON. This converts + floating-point infinities and NaNs to a JSON ``null``, for alignment + with ECMAScript's ``JSON.stringify``. + (Contributed by Mark Dickinson in :gh:`115246`.) + Optimizations ============= diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index ca7355b2b61aa7..fde34ea21cdcc1 100644 --- a/Include/internal/pycore_global_objects_fini_generated.h +++ b/Include/internal/pycore_global_objects_fini_generated.h @@ -800,6 +800,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(arguments)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(argv)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(as_integer_ratio)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(as_null)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(asend)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(ast)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(athrow)); diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h index fbb25285f0f282..35f97323c53ad6 100644 --- a/Include/internal/pycore_global_strings.h +++ b/Include/internal/pycore_global_strings.h @@ -289,6 +289,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(arguments) STRUCT_FOR_ID(argv) STRUCT_FOR_ID(as_integer_ratio) + STRUCT_FOR_ID(as_null) STRUCT_FOR_ID(asend) STRUCT_FOR_ID(ast) STRUCT_FOR_ID(athrow) diff --git a/Include/internal/pycore_runtime_init_generated.h b/Include/internal/pycore_runtime_init_generated.h index 508da40c53422d..4bf645ecac3c29 100644 --- a/Include/internal/pycore_runtime_init_generated.h +++ b/Include/internal/pycore_runtime_init_generated.h @@ -798,6 +798,7 @@ extern "C" { INIT_ID(arguments), \ INIT_ID(argv), \ INIT_ID(as_integer_ratio), \ + INIT_ID(as_null), \ INIT_ID(asend), \ INIT_ID(ast), \ INIT_ID(athrow), \ diff --git a/Include/internal/pycore_unicodeobject_generated.h b/Include/internal/pycore_unicodeobject_generated.h index cc2fc15ac5cabf..a97934ab53404a 100644 --- a/Include/internal/pycore_unicodeobject_generated.h +++ b/Include/internal/pycore_unicodeobject_generated.h @@ -708,6 +708,9 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { string = &_Py_ID(as_integer_ratio); assert(_PyUnicode_CheckConsistency(string, 1)); _PyUnicode_InternInPlace(interp, &string); + string = &_Py_ID(as_null); + assert(_PyUnicode_CheckConsistency(string, 1)); + _PyUnicode_InternInPlace(interp, &string); string = &_Py_ID(asend); assert(_PyUnicode_CheckConsistency(string, 1)); _PyUnicode_InternInPlace(interp, &string); diff --git a/Lib/json/__init__.py b/Lib/json/__init__.py index ed2c74771ea87d..c3409ee7bcf6b2 100644 --- a/Lib/json/__init__.py +++ b/Lib/json/__init__.py @@ -225,7 +225,7 @@ def dumps(obj, *, skipkeys=False, ensure_ascii=True, check_circular=True, """ # cached encoder if (not skipkeys and ensure_ascii and - check_circular and allow_nan and + check_circular and allow_nan is True and cls is None and indent is None and separators is None and default is None and not sort_keys and not kw): return _default_encoder.encode(obj) diff --git a/Lib/json/encoder.py b/Lib/json/encoder.py index 323332f064edf8..382fba8aa97512 100644 --- a/Lib/json/encoder.py +++ b/Lib/json/encoder.py @@ -1,6 +1,7 @@ """Implementation of JSONEncoder """ import re +import warnings try: from _json import encode_basestring_ascii as c_encode_basestring_ascii @@ -148,6 +149,13 @@ def __init__(self, *, skipkeys=False, ensure_ascii=True, self.skipkeys = skipkeys self.ensure_ascii = ensure_ascii self.check_circular = check_circular + if isinstance(allow_nan, str) and allow_nan != 'as_null': + warnings.warn( + "in the future, allow_nan will no longer accept strings " + "other than 'as_null'. Use a boolean instead.", + DeprecationWarning, + stacklevel=3, + ) self.allow_nan = allow_nan self.sort_keys = sort_keys self.indent = indent @@ -236,7 +244,9 @@ def floatstr(o, allow_nan=self.allow_nan, else: return _repr(o) - if not allow_nan: + if allow_nan == 'as_null': + return 'null' + elif not allow_nan: raise ValueError( "Out of range float values are not JSON compliant: " + repr(o)) diff --git a/Lib/test/test_json/test_float.py b/Lib/test/test_json/test_float.py index 61540a3a02c2c6..5279bd6d54dc44 100644 --- a/Lib/test/test_json/test_float.py +++ b/Lib/test/test_json/test_float.py @@ -2,6 +2,11 @@ from test.test_json import PyTest, CTest +class NotUsableAsABoolean: + def __bool__(self): + raise TypeError("I refuse to be interpreted as a boolean") + + class TestFloat: def test_floats(self): for num in [1617161771.7650001, math.pi, math.pi**100, math.pi**-100, 3.1]: @@ -29,6 +34,36 @@ def test_allow_nan(self): msg = f'Out of range float values are not JSON compliant: {val}' self.assertRaisesRegex(ValueError, msg, self.dumps, [val], allow_nan=False) + def test_allow_nan_null(self): + # when allow_nan is 'as_null', infinities and NaNs convert to 'null' + for val in [float('inf'), float('-inf'), float('nan')]: + with self.subTest(val=val): + out = self.dumps([val], allow_nan='as_null') + res = self.loads(out) + self.assertEqual(res, [None]) + + # and finite values are treated as normal + for val in [1.25, -23, -0.0, 0.0]: + with self.subTest(val=val): + out = self.dumps([val], allow_nan='as_null') + res = self.loads(out) + self.assertEqual(res, [val]) + + # testing a mixture + vals = [-1.3, 1e100, -math.inf, 1234, -0.0, math.nan] + out = self.dumps(vals, allow_nan='as_null') + res = self.loads(out) + self.assertEqual(res, [-1.3, 1e100, None, 1234, -0.0, None]) + + def test_allow_nan_string_deprecation(self): + with self.assertWarns(DeprecationWarning): + self.dumps(2.3, allow_nan='true') + + def test_allow_nan_non_boolean(self): + # check that exception gets propagated as expected + with self.assertRaises(TypeError): + self.dumps(math.inf, allow_nan=NotUsableAsABoolean()) + class TestPyFloat(TestFloat, PyTest): pass class TestCFloat(TestFloat, CTest): pass diff --git a/Misc/NEWS.d/next/Library/2024-05-12-09-27-16.gh-issue-98306.dSlaEP.rst b/Misc/NEWS.d/next/Library/2024-05-12-09-27-16.gh-issue-98306.dSlaEP.rst new file mode 100644 index 00000000000000..9bbee184cc8b8f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-05-12-09-27-16.gh-issue-98306.dSlaEP.rst @@ -0,0 +1,2 @@ +Add support for ``allow_nan='as_null'`` when encoding an object to a JSON +string. This converts floating-point infinities and NaNs to a JSON ``null``. diff --git a/Modules/_json.c b/Modules/_json.c index e33ef1f5eea92f..7e0359912c47a9 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1209,13 +1209,13 @@ encoder_new(PyTypeObject *type, PyObject *args, PyObject *kwds) PyEncoderObject *s; PyObject *markers, *defaultfn, *encoder, *indent, *key_separator; - PyObject *item_separator; + PyObject *item_separator, *allow_nan_obj; int sort_keys, skipkeys, allow_nan; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOOOUUppp:make_encoder", kwlist, + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOOOUUppO:make_encoder", kwlist, &markers, &defaultfn, &encoder, &indent, &key_separator, &item_separator, - &sort_keys, &skipkeys, &allow_nan)) + &sort_keys, &skipkeys, &allow_nan_obj)) return NULL; if (markers != Py_None && !PyDict_Check(markers)) { @@ -1225,6 +1225,20 @@ encoder_new(PyTypeObject *type, PyObject *args, PyObject *kwds) return NULL; } + // allow_nan = + // 0 to disallow nans and infinities + // 1 to convert nans and infinities into corresponding JSON strings + // 2 to convert nans and infinities to a JSON null + if (PyUnicode_Check(allow_nan_obj) && + _PyUnicode_Equal(allow_nan_obj, &_Py_ID(as_null))) { + allow_nan = 2; + } else { + allow_nan = PyObject_IsTrue(allow_nan_obj); + if (allow_nan < 0) { + return NULL; + } + } + s = (PyEncoderObject *)type->tp_alloc(type, 0); if (s == NULL) return NULL; @@ -1335,7 +1349,10 @@ encoder_encode_float(PyEncoderObject *s, PyObject *obj) ); return NULL; } - if (i > 0) { + else if (s->allow_nan == 2) { + return PyUnicode_FromString("null"); + } + else if (i > 0) { return PyUnicode_FromString("Infinity"); } else if (i < 0) {