From e5a373cfff47ae13d1fee3469a972dc27c3845e1 Mon Sep 17 00:00:00 2001 From: Andrew Steinborn Date: Wed, 22 Oct 2025 02:10:07 -0400 Subject: [PATCH] Correct invalid serialization of `date`/`datetime`/`time`/`timedelta` by pulling downcast checks up --- .../type_serializers/datetime_etc.rs | 16 ++--- src/serializers/type_serializers/timedelta.rs | 16 ++--- tests/serializers/test_datetime.py | 72 ++++++++++++++++++- tests/serializers/test_timedelta.py | 29 +++++++- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 9d267cb39..84f6377ad 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -119,15 +119,15 @@ macro_rules! build_temporal_serializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> PyResult> { - match extra.mode { - SerMode::Json => match $downcast(value) { - Ok(py_value) => Ok(self.temporal_mode.$to_json(value.py(), py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) - } + match $downcast(value) { + Ok(py_value) => match extra.mode { + SerMode::Json => Ok(self.temporal_mode.$to_json(value.py(), py_value)?), + _ => Ok(value.clone().unbind()), }, - _ => infer_to_python(value, include, exclude, extra), + _ => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } } } diff --git a/src/serializers/type_serializers/timedelta.rs b/src/serializers/type_serializers/timedelta.rs index f740bd49c..83d4df862 100644 --- a/src/serializers/type_serializers/timedelta.rs +++ b/src/serializers/type_serializers/timedelta.rs @@ -50,15 +50,15 @@ impl TypeSerializer for TimeDeltaSerializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> PyResult> { - match extra.mode { - SerMode::Json => match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => Ok(self.temporal_mode.timedelta_to_json(value.py(), either_timedelta)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) - } + match EitherTimedelta::try_from(value) { + Ok(either_timedelta) => match extra.mode { + SerMode::Json => Ok(self.temporal_mode.timedelta_to_json(value.py(), either_timedelta)?), + _ => Ok(value.clone().unbind()), }, - _ => infer_to_python(value, include, exclude, extra), + _ => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } } } diff --git a/tests/serializers/test_datetime.py b/tests/serializers/test_datetime.py index 88edb205a..7e042322a 100644 --- a/tests/serializers/test_datetime.py +++ b/tests/serializers/test_datetime.py @@ -168,7 +168,14 @@ def test_config_datetime( assert s.to_python(dt, mode='json') == expected_to_python assert s.to_json(dt) == expected_to_json - assert s.to_python({dt: 'foo'}) == {dt: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `datetime` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.datetime\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({dt: 'foo'}) == {dt: 'foo'} with pytest.warns( UserWarning, match=( @@ -224,7 +231,14 @@ def test_config_date( assert s.to_python(dt, mode='json') == expected_to_python assert s.to_json(dt) == expected_to_json - assert s.to_python({dt: 'foo'}) == {dt: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `date` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.date\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({dt: 'foo'}) == {dt: 'foo'} with pytest.warns( UserWarning, match=( @@ -280,7 +294,14 @@ def test_config_time( assert s.to_python(t, mode='json') == expected_to_python assert s.to_json(t) == expected_to_json - assert s.to_python({t: 'foo'}) == {t: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `time` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.time\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({t: 'foo'}) == {t: 'foo'} with pytest.warns( UserWarning, match=( @@ -297,3 +318,48 @@ def test_config_time( ), ): assert s.to_json({t: 'foo'}) == expected_to_json_dict + + +def test_union_datetime_downcasts_correctly(): + serialization_schema = core_schema.plain_serializer_function_ser_schema(lambda v: None) + json_validation_schema = core_schema.no_info_plain_validator_function( + function=lambda v: v, serialization=serialization_schema + ) + + test_custom_ser_schema = core_schema.json_schema( + schema=json_validation_schema, + serialization=serialization_schema, + ) + + s = SchemaSerializer(core_schema.union_schema(choices=[core_schema.datetime_schema(), test_custom_ser_schema])) + assert s.to_python('foo') is None + + +def test_union_date_respects_downcasts_correctly(): + serialization_schema = core_schema.plain_serializer_function_ser_schema(lambda v: None) + json_validation_schema = core_schema.no_info_plain_validator_function( + function=lambda v: v, serialization=serialization_schema + ) + + test_custom_ser_schema = core_schema.json_schema( + schema=json_validation_schema, + serialization=serialization_schema, + ) + + s = SchemaSerializer(core_schema.union_schema(choices=[core_schema.date_schema(), test_custom_ser_schema])) + assert s.to_python('foo') is None + + +def test_union_time_respects_downcasts_correctly(): + serialization_schema = core_schema.plain_serializer_function_ser_schema(lambda v: None) + json_validation_schema = core_schema.no_info_plain_validator_function( + function=lambda v: v, serialization=serialization_schema + ) + + test_custom_ser_schema = core_schema.json_schema( + schema=json_validation_schema, + serialization=serialization_schema, + ) + + s = SchemaSerializer(core_schema.union_schema(choices=[core_schema.time_schema(), test_custom_ser_schema])) + assert s.to_python('foo') is None diff --git a/tests/serializers/test_timedelta.py b/tests/serializers/test_timedelta.py index 820c478dc..32ab9e045 100644 --- a/tests/serializers/test_timedelta.py +++ b/tests/serializers/test_timedelta.py @@ -183,7 +183,9 @@ def test_config_timedelta( assert s.to_python(td) == td assert s.to_python(td, mode='json') == expected_to_python assert s.to_json(td) == expected_to_json - assert s.to_python({td: 'foo'}) == {td: 'foo'} + + with pytest.warns(UserWarning): + assert s.to_python({td: 'foo'}) == {td: 'foo'} with pytest.warns(UserWarning): assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict with pytest.warns( @@ -330,8 +332,14 @@ def test_config_timedelta_timedelta_ser_flag_prioritised( ) assert s.to_python(td) == td assert s.to_python(td, mode='json') == expected_to_python - assert s.to_python({td: 'foo'}) == {td: 'foo'} - + with pytest.warns( + UserWarning, + match=( + r'Expected `timedelta` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.timedelta\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({td: 'foo'}) == {td: 'foo'} with pytest.warns( UserWarning, match=( @@ -348,3 +356,18 @@ def test_config_timedelta_timedelta_ser_flag_prioritised( ), ): assert s.to_json({td: 'foo'}) == expected_to_json_dict + + +def test_union_timedelta_respects_instanceof_check(): + serialization_schema = core_schema.plain_serializer_function_ser_schema(lambda v: None) + json_validation_schema = core_schema.no_info_plain_validator_function( + function=lambda v: v, serialization=serialization_schema + ) + + test_custom_ser_schema = core_schema.json_schema( + schema=json_validation_schema, + serialization=serialization_schema, + ) + + s = SchemaSerializer(core_schema.union_schema(choices=[core_schema.timedelta_schema(), test_custom_ser_schema])) + assert s.to_python('foo') is None