diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index b86b7aad0..f6165816f 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -355,7 +355,7 @@ def to_json( round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', bytes_mode: Literal['utf8', 'base64'] = 'utf8', - inf_nan_mode: Literal['null', 'constants'] = 'constants', + inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, @@ -376,7 +376,7 @@ def to_json( round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'` or `'base64'`. - inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'` or `'constants'`. + inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. serialize_unknown: Attempt to serialize unknown types, `str(value)` will be used, if that fails `""` will be used. fallback: A function to call when an unknown value is encountered, @@ -430,7 +430,7 @@ def to_jsonable_python( round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', bytes_mode: Literal['utf8', 'base64'] = 'utf8', - inf_nan_mode: Literal['null', 'constants'] = 'constants', + inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, @@ -451,7 +451,7 @@ def to_jsonable_python( round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'` or `'base64'`. - inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'` or `'constants'`. + inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. serialize_unknown: Attempt to serialize unknown types, `str(value)` will be used, if that fails `""` will be used. fallback: A function to call when an unknown value is encountered, diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 2cb875b23..72aa757db 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -106,7 +106,7 @@ class CoreConfig(TypedDict, total=False): # the config options are used to customise serialization to JSON ser_json_timedelta: Literal['iso8601', 'float'] # default: 'iso8601' ser_json_bytes: Literal['utf8', 'base64', 'hex'] # default: 'utf8' - ser_json_inf_nan: Literal['null', 'constants'] # default: 'null' + ser_json_inf_nan: Literal['null', 'constants', 'strings'] # default: 'null' # used to hide input data from ValidationError repr hide_input_in_errors: bool validation_error_cause: bool # default: False diff --git a/src/serializers/config.rs b/src/serializers/config.rs index 2863d68d9..0aca33de5 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -104,6 +104,7 @@ serialization_mode! { "ser_json_inf_nan", Null => "null", Constants => "constants", + Strings => "strings", } impl TimedeltaMode { diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index 2384e8691..c4cb8e9d9 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -9,6 +9,7 @@ use pyo3::types::{PyByteArray, PyBytes, PyDict, PyFrozenSet, PyIterator, PyList, use serde::ser::{Error, Serialize, SerializeMap, SerializeSeq, Serializer}; use crate::input::{EitherTimedelta, Int}; +use crate::serializers::type_serializers; use crate::tools::{extract_i64, py_err, safe_repr}; use crate::url::{PyMultiHostUrl, PyUrl}; @@ -403,11 +404,7 @@ pub(crate) fn infer_serialize_known( ObType::Bool => serialize!(bool), ObType::Float | ObType::FloatSubclass => { let v = value.extract::().map_err(py_err_se_err)?; - if (v.is_nan() || v.is_infinite()) && extra.config.inf_nan_mode == InfNanMode::Null { - serializer.serialize_none() - } else { - serializer.serialize_f64(v) - } + type_serializers::float::serialize_f64(v, serializer, extra.config.inf_nan_mode.clone()) } ObType::Decimal => value.to_string().serialize(serializer), ObType::Str | ObType::StrSubclass => { diff --git a/src/serializers/type_serializers/float.rs b/src/serializers/type_serializers/float.rs index f3cb81380..84eae4ba0 100644 --- a/src/serializers/type_serializers/float.rs +++ b/src/serializers/type_serializers/float.rs @@ -30,6 +30,24 @@ impl FloatSerializer { } } +pub fn serialize_f64(v: f64, serializer: S, inf_nan_mode: InfNanMode) -> Result { + if v.is_nan() || v.is_infinite() { + match inf_nan_mode { + InfNanMode::Null => serializer.serialize_none(), + InfNanMode::Constants => serializer.serialize_f64(v), + InfNanMode::Strings => { + if v.is_nan() { + serializer.serialize_str("NaN") + } else { + serializer.serialize_str(if v.is_sign_positive() { "Infinity" } else { "-Infinity" }) + } + } + } + } else { + serializer.serialize_f64(v) + } +} + impl BuildSerializer for FloatSerializer { const EXPECTED_TYPE: &'static str = "float"; @@ -85,16 +103,11 @@ impl TypeSerializer for FloatSerializer { serializer: S, include: Option<&Bound<'_, PyAny>>, exclude: Option<&Bound<'_, PyAny>>, + // TODO: Merge extra.config into self.inf_nan_mode? extra: &Extra, ) -> Result { match value.extract::() { - Ok(v) => { - if (v.is_nan() || v.is_infinite()) && self.inf_nan_mode == InfNanMode::Null { - serializer.serialize_none() - } else { - serializer.serialize_f64(v) - } - } + Ok(v) => serialize_f64(v, serializer, self.inf_nan_mode.clone()), Err(_) => { extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; infer_serialize(value, serializer, include, exclude, extra) diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index b6eedafe0..4c3cd9eff 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -623,6 +623,14 @@ def test_ser_json_inf_nan_with_any() -> None: assert s.to_python(nan, mode='json') is None assert s.to_json(nan) == b'null' + s = SchemaSerializer(core_schema.any_schema(), core_schema.CoreConfig(ser_json_inf_nan='strings')) + assert isinf(s.to_python(inf)) + assert isinf(s.to_python(inf, mode='json')) + assert s.to_json(inf) == b'"Infinity"' + assert isnan(s.to_python(nan)) + assert isnan(s.to_python(nan, mode='json')) + assert s.to_json(nan) == b'"NaN"' + def test_ser_json_inf_nan_with_list_of_any() -> None: s = SchemaSerializer( diff --git a/tests/serializers/test_simple.py b/tests/serializers/test_simple.py index b63208c07..b0fe7b836 100644 --- a/tests/serializers/test_simple.py +++ b/tests/serializers/test_simple.py @@ -152,6 +152,9 @@ def test_numpy(): (float('inf'), 'Infinity', {'ser_json_inf_nan': 'constants'}), (float('-inf'), '-Infinity', {'ser_json_inf_nan': 'constants'}), (float('nan'), 'NaN', {'ser_json_inf_nan': 'constants'}), + (float('inf'), '"Infinity"', {'ser_json_inf_nan': 'strings'}), + (float('-inf'), '"-Infinity"', {'ser_json_inf_nan': 'strings'}), + (float('nan'), '"NaN"', {'ser_json_inf_nan': 'strings'}), ], ) def test_float_inf_and_nan_serializers(value, expected_json, config): diff --git a/tests/validators/test_float.py b/tests/validators/test_float.py index 88966261a..c397f453c 100644 --- a/tests/validators/test_float.py +++ b/tests/validators/test_float.py @@ -387,6 +387,10 @@ def test_allow_inf_nan_true_json() -> None: assert v.validate_json('Infinity') == float('inf') assert v.validate_json('-Infinity') == float('-inf') + assert v.validate_json('"NaN"') == IsFloatNan() + assert v.validate_json('"Infinity"') == float('inf') + assert v.validate_json('"-Infinity"') == float('-inf') + def test_allow_inf_nan_false_json() -> None: v = SchemaValidator(core_schema.float_schema(), core_schema.CoreConfig(allow_inf_nan=False))