From b3a853057fb4916bcd95d18c17fe4998196f4e38 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Mon, 16 Jun 2025 14:58:32 +0100 Subject: [PATCH 01/16] chore(deps): bump speedate to 0.16.0, make nessassary changes to codebase --- Cargo.lock | 13 +++++++------ Cargo.toml | 6 +++--- src/input/datetime.rs | 22 +++++++++++++--------- src/validators/datetime.rs | 3 ++- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d17efc8a..9d9689c9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,10 +608,11 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" [[package]] name = "speedate" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5e7adf4e07e7de39a64d77962ca14a09165e592d42d0c9f9acadb679f4f937" +checksum = "bc823f2eb39713ef0d286058af264ce0d8ddcf62b8a78a0aec02cd0105576d87" dependencies = [ + "lexical-parse-float", "strum", "strum_macros", ] @@ -630,18 +631,18 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index d5de50234..7901bfd64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,12 +29,12 @@ rust-version = "1.75" # but needs a bit of work to make sure it's not used in the codebase pyo3 = { version = "0.25", features = ["generate-import-lib", "num-bigint", "py-clone"] } regex = "1.11.1" -strum = { version = "0.26.3", features = ["derive"] } -strum_macros = "0.26.4" +strum = { version = "0.27", features = ["derive"] } +strum_macros = "0.27" serde_json = { version = "1.0.140", features = ["arbitrary_precision"] } enum_dispatch = "0.3.13" serde = { version = "1.0.219", features = ["derive"] } -speedate = "0.15.0" +speedate = "0.16.0" smallvec = "1.15.0" ahash = "0.8.12" url = "2.5.4" diff --git a/src/input/datetime.rs b/src/input/datetime.rs index 8e859ae76..79b6c5297 100644 --- a/src/input/datetime.rs +++ b/src/input/datetime.rs @@ -6,7 +6,7 @@ use pyo3::pyclass::CompareOp; use pyo3::types::PyTuple; use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDeltaAccess, PyDict, PyTime, PyTzInfo}; use pyo3::IntoPyObjectExt; -use speedate::MicrosecondsPrecisionOverflowBehavior; +use speedate::{DateTimeConfig, MicrosecondsPrecisionOverflowBehavior}; use speedate::{Date, DateTime, Duration, ParseError, Time, TimeConfig}; use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; @@ -366,10 +366,12 @@ pub fn bytes_as_datetime<'py>( ) -> ValResult> { match DateTime::parse_bytes_with_config( bytes, - &TimeConfig { - microseconds_precision_overflow_behavior: microseconds_overflow_behavior, - unix_timestamp_offset: Some(0), - }, + &DateTimeConfig::builder().time_config( + TimeConfig { + microseconds_precision_overflow_behavior: microseconds_overflow_behavior, + unix_timestamp_offset: Some(0), + }, + ).build() ) { Ok(dt) => Ok(dt.into()), Err(err) => Err(ValError::new( @@ -390,10 +392,12 @@ pub fn int_as_datetime<'py>( match DateTime::from_timestamp_with_config( timestamp, timestamp_microseconds, - &TimeConfig { - unix_timestamp_offset: Some(0), - ..Default::default() - }, + &DateTimeConfig::builder().time_config( + TimeConfig { + unix_timestamp_offset: Some(0), + ..Default::default() + }, + ).build() ) { Ok(dt) => Ok(dt.into()), Err(err) => Err(ValError::new( diff --git a/src/validators/datetime.rs b/src/validators/datetime.rs index d82a3dd6c..ee9215f8a 100644 --- a/src/validators/datetime.rs +++ b/src/validators/datetime.rs @@ -5,6 +5,7 @@ use pyo3::sync::GILOnceCell; use pyo3::types::{PyDict, PyString}; use speedate::{DateTime, MicrosecondsPrecisionOverflowBehavior, Time}; use std::cmp::Ordering; +use std::str::FromStr; use strum::EnumMessage; use crate::build_tools::{is_strict, py_schema_error_type}; @@ -32,7 +33,7 @@ pub(crate) fn extract_microseconds_precision( schema_or_config_same(schema, config, intern!(schema.py(), "microseconds_precision"))? .map_or( Ok(speedate::MicrosecondsPrecisionOverflowBehavior::Truncate), - |v: Bound<'_, PyString>| speedate::MicrosecondsPrecisionOverflowBehavior::try_from(v.to_str().unwrap()), + |v: Bound<'_, PyString>| speedate::MicrosecondsPrecisionOverflowBehavior::from_str(v.to_str().unwrap()), ) .map_err(|_| { py_schema_error_type!("Invalid `microseconds_precision`, must be one of \"truncate\" or \"error\"") From 86748f7b7a419245f44d939faa9b2bfa1ac2d0fe Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Mon, 16 Jun 2025 15:00:17 +0100 Subject: [PATCH 02/16] style: formatting --- src/input/datetime.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/input/datetime.rs b/src/input/datetime.rs index 79b6c5297..58b9b0934 100644 --- a/src/input/datetime.rs +++ b/src/input/datetime.rs @@ -6,8 +6,8 @@ use pyo3::pyclass::CompareOp; use pyo3::types::PyTuple; use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDeltaAccess, PyDict, PyTime, PyTzInfo}; use pyo3::IntoPyObjectExt; -use speedate::{DateTimeConfig, MicrosecondsPrecisionOverflowBehavior}; use speedate::{Date, DateTime, Duration, ParseError, Time, TimeConfig}; +use speedate::{DateTimeConfig, MicrosecondsPrecisionOverflowBehavior}; use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::fmt::Write; @@ -366,12 +366,12 @@ pub fn bytes_as_datetime<'py>( ) -> ValResult> { match DateTime::parse_bytes_with_config( bytes, - &DateTimeConfig::builder().time_config( - TimeConfig { + &DateTimeConfig::builder() + .time_config(TimeConfig { microseconds_precision_overflow_behavior: microseconds_overflow_behavior, unix_timestamp_offset: Some(0), - }, - ).build() + }) + .build(), ) { Ok(dt) => Ok(dt.into()), Err(err) => Err(ValError::new( @@ -392,12 +392,12 @@ pub fn int_as_datetime<'py>( match DateTime::from_timestamp_with_config( timestamp, timestamp_microseconds, - &DateTimeConfig::builder().time_config( - TimeConfig { + &DateTimeConfig::builder() + .time_config(TimeConfig { unix_timestamp_offset: Some(0), ..Default::default() - }, - ).build() + }) + .build(), ) { Ok(dt) => Ok(dt.into()), Err(err) => Err(ValError::new( From 25e6ca6e9f23d42d4509e570e5f3488e3d0be90f Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Mon, 16 Jun 2025 21:42:38 +0100 Subject: [PATCH 03/16] fix: begin implementing ser_json_temporal --- python/pydantic_core/_pydantic_core.pyi | 1 + python/pydantic_core/core_schema.py | 1 + src/errors/validation_exception.rs | 2 +- src/serializers/config.rs | 64 ++++++++++++- src/serializers/extra.rs | 4 +- src/serializers/infer.rs | 17 ++-- src/serializers/mod.rs | 17 ++-- .../type_serializers/datetime_etc.rs | 92 +++++++++++++++++-- tests/serializers/test_any.py | 41 +++++++++ 9 files changed, 207 insertions(+), 32 deletions(-) diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 0e29a6b41..2aff05cc8 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -402,6 +402,7 @@ def to_json( exclude_none: bool = False, round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', + termporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 6d265d48e..c14e1c33b 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -102,6 +102,7 @@ class CoreConfig(TypedDict, total=False): allow_inf_nan: bool # default: True # the config options are used to customise serialization to JSON ser_json_timedelta: Literal['iso8601', 'float'] # default: 'iso8601' + ser_json_temporal: Literal['iso8601', 'seconds', 'milliseconds'] # default: 'iso8601' ser_json_bytes: Literal['utf8', 'base64', 'hex'] # default: 'utf8' ser_json_inf_nan: Literal['null', 'constants', 'strings'] # default: 'null' val_json_bytes: Literal['utf8', 'base64', 'hex'] # default: 'utf8' diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index 5d36851c2..d4e40ebd9 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -340,7 +340,7 @@ impl ValidationError { include_context: bool, include_input: bool, ) -> PyResult> { - let state = SerializationState::new("iso8601", "utf8", "constants")?; + let state = SerializationState::new("iso8601", "iso8601", "utf8", "constants")?; let extra = state.extra(py, &SerMode::Json, None, false, false, true, None, false, None); let serializer = ValidationErrorSerializer { py, diff --git a/src/serializers/config.rs b/src/serializers/config.rs index e2cf85a53..955a4a514 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -3,13 +3,14 @@ use std::str::{from_utf8, FromStr, Utf8Error}; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyDelta, PyDict, PyString}; +use pyo3::types::{PyDateTime, PyDelta, PyDict, PyString}; use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; use crate::build_tools::py_schema_err; -use crate::input::EitherTimedelta; +use crate::input::{pydatetime_as_datetime, EitherTimedelta}; +use crate::serializers::type_serializers::datetime_etc::{datetime_to_milliseconds, datetime_to_seconds, datetime_to_string}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -18,6 +19,7 @@ use super::errors::py_err_se_err; #[allow(clippy::struct_field_names)] pub(crate) struct SerializationConfig { pub timedelta_mode: TimedeltaMode, + pub datetime_mode: TemporalMode, pub bytes_mode: BytesMode, pub inf_nan_mode: InfNanMode, } @@ -25,18 +27,26 @@ pub(crate) struct SerializationConfig { impl SerializationConfig { pub fn from_config(config: Option<&Bound<'_, PyDict>>) -> PyResult { let timedelta_mode = TimedeltaMode::from_config(config)?; + let datetime_mode = TemporalMode::from_config(config)?; let bytes_mode = BytesMode::from_config(config)?; let inf_nan_mode = InfNanMode::from_config(config)?; Ok(Self { timedelta_mode, + datetime_mode, bytes_mode, inf_nan_mode, }) } - pub fn from_args(timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { + pub fn from_args( + timedelta_mode: &str, + datetime_mode: &str, + bytes_mode: &str, + inf_nan_mode: &str, + ) -> PyResult { Ok(Self { timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, + datetime_mode: TemporalMode::from_str(datetime_mode)?, bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, }) @@ -91,6 +101,14 @@ serialization_mode! { Float => "float", } +serialization_mode! { + TemporalMode, + "ser_json_temporal", + Iso8601 => "iso8601", + SecondsInt => "seconds", + MillisecondsInt => "milliseconds" +} + serialization_mode! { BytesMode, "ser_json_bytes", @@ -163,6 +181,46 @@ impl TimedeltaMode { } } +impl TemporalMode { + + pub fn datetime_to_json(self, py: Python, datetime: &Bound<'_, PyDateTime>) -> PyResult { + match self { + Self::Iso8601 => Ok(datetime_to_string(datetime)?.into_py_any(py)?), + Self::SecondsInt => Ok(datetime_to_seconds(datetime)?.into_py_any(py)?), + Self::MillisecondsInt => Ok(datetime_to_milliseconds(datetime)?.into_py_any(py)?), + } + } + + pub fn json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { + match self { + Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), + Self::SecondsInt => Ok(datetime_to_seconds(datetime)?.to_string().into()), + Self::MillisecondsInt => Ok(datetime_to_milliseconds(datetime)?.to_string().into()), + } + } + + pub fn datetime_serialize( + self, + datetime: &Bound<'_, PyDateTime>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = datetime_to_string(datetime).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::SecondsInt => { + let s = datetime_to_seconds(datetime).map_err(py_err_se_err)?; + serializer.serialize_i64(s) + } + Self::MillisecondsInt => { + let s = datetime_to_milliseconds(datetime).map_err(py_err_se_err)?; + serializer.serialize_i64(s) + } + } + } +} + impl BytesMode { pub fn bytes_to_string<'a>(self, py: Python, bytes: &'a [u8]) -> PyResult> { match self { diff --git a/src/serializers/extra.rs b/src/serializers/extra.rs index e919beb8c..0b1036bd5 100644 --- a/src/serializers/extra.rs +++ b/src/serializers/extra.rs @@ -28,10 +28,10 @@ pub(crate) struct SerializationState { } impl SerializationState { - pub fn new(timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { + pub fn new(timedelta_mode: &str, temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { let warnings = CollectWarnings::new(WarningsMode::None); let rec_guard = SerRecursionState::default(); - let config = SerializationConfig::from_args(timedelta_mode, bytes_mode, inf_nan_mode)?; + let config = SerializationConfig::from_args(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; Ok(Self { warnings, rec_guard, diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index eb067b81d..f4cb26def 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -176,8 +176,11 @@ pub(crate) fn infer_to_python_known( })? } ObType::Datetime => { - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(value.downcast()?)?; - iso_dt.into_py_any(py)? + let datetime = extra + .config + .datetime_mode + .datetime_to_json(value.py(), value.downcast()?)?; + datetime.into_py_any(py)? } ObType::Date => { let iso_date = super::type_serializers::datetime_etc::date_to_string(value.downcast()?)?; @@ -458,9 +461,8 @@ pub(crate) fn infer_serialize_known( ObType::Set => serialize_seq!(PySet), ObType::Frozenset => serialize_seq!(PyFrozenSet), ObType::Datetime => { - let py_dt = value.downcast().map_err(py_err_se_err)?; - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(py_dt).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_dt) + let py_datetime = value.downcast().map_err(py_err_se_err)?; + extra.config.datetime_mode.datetime_serialize(py_datetime, serializer) } ObType::Date => { let py_date = value.downcast().map_err(py_err_se_err)?; @@ -635,10 +637,7 @@ pub(crate) fn infer_json_key_known<'a>( }) .map(|cow| Cow::Owned(cow.into_owned())) } - ObType::Datetime => { - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_dt)) - } + ObType::Datetime => extra.config.datetime_mode.json_key(key.downcast()?), ObType::Date => { let iso_date = super::type_serializers::datetime_etc::date_to_string(key.downcast()?)?; Ok(Cow::Owned(iso_date)) diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index f9b51496f..22395d3a4 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -16,6 +16,7 @@ use extra::{CollectWarnings, SerRecursionState, WarningsMode}; pub(crate) use extra::{Extra, SerMode, SerializationState}; use shared::to_json_bytes; pub use shared::CombinedSerializer; +use crate::serializers::config::TemporalMode; mod computed_fields; mod config; @@ -241,9 +242,9 @@ impl SchemaSerializer { #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, indent = None, ensure_ascii = false, include = None, exclude = None, by_alias = true, - exclude_none = false, round_trip = false, timedelta_mode = "iso8601", bytes_mode = "utf8", - inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false, - context = None))] + exclude_none = false, round_trip = false, timedelta_mode = "iso8601", temporal_mode = "iso8601", + bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, + serialize_as_any = false, context = None))] pub fn to_json( py: Python, value: &Bound<'_, PyAny>, @@ -255,6 +256,7 @@ pub fn to_json( exclude_none: bool, round_trip: bool, timedelta_mode: &str, + temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str, serialize_unknown: bool, @@ -262,7 +264,7 @@ pub fn to_json( serialize_as_any: bool, context: Option<&Bound<'_, PyAny>>, ) -> PyResult { - let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?; + let state = SerializationState::new(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; let extra = state.extra( py, &SerMode::Json, @@ -292,8 +294,8 @@ pub fn to_json( #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, include = None, exclude = None, by_alias = true, exclude_none = false, round_trip = false, - timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, - serialize_as_any = false, context = None))] + timedelta_mode = "iso8601", temporal_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", + serialize_unknown = false, fallback = None, serialize_as_any = false, context = None))] pub fn to_jsonable_python( py: Python, value: &Bound<'_, PyAny>, @@ -303,6 +305,7 @@ pub fn to_jsonable_python( exclude_none: bool, round_trip: bool, timedelta_mode: &str, + temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str, serialize_unknown: bool, @@ -310,7 +313,7 @@ pub fn to_jsonable_python( serialize_as_any: bool, context: Option<&Bound<'_, PyAny>>, ) -> PyResult { - let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?; + let state = SerializationState::new(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; let extra = state.extra( py, &SerMode::Json, diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 0b601745a..4f469559c 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -4,19 +4,27 @@ use pyo3::prelude::*; use pyo3::types::{PyDate, PyDateTime, PyDict, PyTime}; use pyo3::IntoPyObjectExt; -use crate::definitions::DefinitionsBuilder; -use crate::input::{pydate_as_date, pydatetime_as_datetime, pytime_as_time}; -use crate::PydanticSerializationUnexpectedValue; - use super::{ infer_json_key, infer_serialize, infer_to_python, py_err_se_err, BuildSerializer, CombinedSerializer, Extra, SerMode, TypeSerializer, }; +use crate::definitions::DefinitionsBuilder; +use crate::input::{pydate_as_date, pydatetime_as_datetime, pytime_as_time}; +use crate::serializers::config::{TemporalMode, FromConfig}; +use crate::PydanticSerializationUnexpectedValue; pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult { pydatetime_as_datetime(py_dt).map(|dt| dt.to_string()) } +pub(crate) fn datetime_to_seconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp()) +} + +pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp_ms()) +} + pub(crate) fn date_to_string(py_date: &Bound<'_, PyDate>) -> PyResult { pydate_as_date(py_date).map(|dt| dt.to_string()) } @@ -119,11 +127,75 @@ macro_rules! build_serializer { }; } -build_serializer!( - DatetimeSerializer, - "datetime", - PyAnyMethods::downcast::, - datetime_to_string -); +#[derive(Debug)] +pub struct DatetimeSerializer { + temporal_mode: TemporalMode, +} + +impl BuildSerializer for DatetimeSerializer { + const EXPECTED_TYPE: &'static str = "datetime"; + + fn build( + _schema: &Bound<'_, PyDict>, + config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + let datetime_mode = TemporalMode::from_config(config)?; + Ok(Self { temporal_mode: datetime_mode }.into()) + } +} +impl_py_gc_traverse!(DatetimeSerializer {}); + +impl TypeSerializer for DatetimeSerializer { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> PyResult { + match extra.mode { + SerMode::Json => match PyAnyMethods::downcast::(value) { + Ok(py_value) => Ok(self.temporal_mode.datetime_to_json(value.py(), py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } + }, + _ => infer_to_python(value, include, exclude, extra), + } + } + + fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { + match PyAnyMethods::downcast::(key) { + Ok(py_value) => Ok(self.temporal_mode.json_key(py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), key, extra)?; + infer_json_key(key, extra) + } + } + } + + fn serde_serialize( + &self, + value: &Bound<'_, PyAny>, + serializer: S, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> Result { + match PyAnyMethods::downcast::(value) { + Ok(py_value) => self.temporal_mode.datetime_serialize(py_value, serializer), + Err(_) => { + extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; + infer_serialize(value, serializer, include, exclude, extra) + } + } + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} build_serializer!(DateSerializer, "date", downcast_date_reject_datetime, date_to_string); build_serializer!(TimeSerializer, "time", PyAnyMethods::downcast::, time_to_string); diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index 09d492177..f7a885ac0 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -722,3 +722,44 @@ class MyEnum(Enum): assert v.to_json({MyEnum.A: 'x'}) == b'{"1":"x"}' assert v.to_python(1) == 1 assert v.to_json(1) == b'1' + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + datetime(2024, 1, 1, 0, 0, 0), + '2024-01-01T00:00:00', + b'"2024-01-01T00:00:00"', + {'2024-01-01T00:00:00': 'foo'}, + b'{"2024-01-01T00:00:00":"foo"}', + 'iso8601', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200, + b'1704067200', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200000, + b'1704067200000', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ], +) +def test_any_config_datetime( + dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + 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'} + assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict + assert s.to_json({dt: 'foo'}) == expected_to_json_dict \ No newline at end of file From 461f1e0f4b1a44ca96b259b39719ecdaaf632fdc Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Tue, 17 Jun 2025 09:58:08 +0100 Subject: [PATCH 04/16] fix: support date ser with new temporal mode --- src/serializers/config.rs | 53 +++++++++-- src/serializers/infer.rs | 18 ++-- src/serializers/mod.rs | 2 +- .../type_serializers/datetime_etc.rs | 91 ++++++++++++++++++- tests/serializers/test_any.py | 41 +++++++++ 5 files changed, 181 insertions(+), 24 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index 955a4a514..b5415237d 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -3,14 +3,17 @@ use std::str::{from_utf8, FromStr, Utf8Error}; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyDateTime, PyDelta, PyDict, PyString}; +use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDict, PyString}; use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; use crate::build_tools::py_schema_err; use crate::input::{pydatetime_as_datetime, EitherTimedelta}; -use crate::serializers::type_serializers::datetime_etc::{datetime_to_milliseconds, datetime_to_seconds, datetime_to_string}; +use crate::serializers::type_serializers::datetime_etc::{ + date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, + datetime_to_string, +}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -19,7 +22,7 @@ use super::errors::py_err_se_err; #[allow(clippy::struct_field_names)] pub(crate) struct SerializationConfig { pub timedelta_mode: TimedeltaMode, - pub datetime_mode: TemporalMode, + pub temporal_mode: TemporalMode, pub bytes_mode: BytesMode, pub inf_nan_mode: InfNanMode, } @@ -32,7 +35,7 @@ impl SerializationConfig { let inf_nan_mode = InfNanMode::from_config(config)?; Ok(Self { timedelta_mode, - datetime_mode, + temporal_mode: datetime_mode, bytes_mode, inf_nan_mode, }) @@ -46,7 +49,7 @@ impl SerializationConfig { ) -> PyResult { Ok(Self { timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, - datetime_mode: TemporalMode::from_str(datetime_mode)?, + temporal_mode: TemporalMode::from_str(datetime_mode)?, bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, }) @@ -182,7 +185,6 @@ impl TimedeltaMode { } impl TemporalMode { - pub fn datetime_to_json(self, py: Python, datetime: &Bound<'_, PyDateTime>) -> PyResult { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.into_py_any(py)?), @@ -191,7 +193,15 @@ impl TemporalMode { } } - pub fn json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { + pub fn date_to_json(self, py: Python, date: &Bound<'_, PyDate>) -> PyResult { + match self { + Self::Iso8601 => Ok(date_to_string(date)?.into_py_any(py)?), + Self::SecondsInt => Ok(date_to_seconds(date)?.into_py_any(py)?), + Self::MillisecondsInt => Ok(date_to_milliseconds(date)?.into_py_any(py)?), + } + } + + pub fn datetime_json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), Self::SecondsInt => Ok(datetime_to_seconds(datetime)?.to_string().into()), @@ -199,6 +209,14 @@ impl TemporalMode { } } + pub fn date_json_key<'py>(self, date: &Bound<'_, PyDate>) -> PyResult> { + match self { + Self::Iso8601 => Ok(date_to_string(date)?.to_string().into()), + Self::SecondsInt => Ok(date_to_seconds(date)?.to_string().into()), + Self::MillisecondsInt => Ok(date_to_milliseconds(date)?.to_string().into()), + } + } + pub fn datetime_serialize( self, datetime: &Bound<'_, PyDateTime>, @@ -219,6 +237,27 @@ impl TemporalMode { } } } + + pub fn date_serialize( + self, + date: &Bound<'_, PyDate>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = date_to_string(date).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::SecondsInt => { + let s = date_to_seconds(date).map_err(py_err_se_err)?; + serializer.serialize_i64(s) + } + Self::MillisecondsInt => { + let s = date_to_milliseconds(date).map_err(py_err_se_err)?; + serializer.serialize_i64(s) + } + } + } } impl BytesMode { diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index f4cb26def..02cfb28a9 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -178,13 +178,13 @@ pub(crate) fn infer_to_python_known( ObType::Datetime => { let datetime = extra .config - .datetime_mode + .temporal_mode .datetime_to_json(value.py(), value.downcast()?)?; datetime.into_py_any(py)? } ObType::Date => { - let iso_date = super::type_serializers::datetime_etc::date_to_string(value.downcast()?)?; - iso_date.into_py_any(py)? + let date = extra.config.temporal_mode.date_to_json(value.py(), value.downcast()?)?; + date.into_py_any(py)? } ObType::Time => { let iso_time = super::type_serializers::datetime_etc::time_to_string(value.downcast()?)?; @@ -462,12 +462,11 @@ pub(crate) fn infer_serialize_known( ObType::Frozenset => serialize_seq!(PyFrozenSet), ObType::Datetime => { let py_datetime = value.downcast().map_err(py_err_se_err)?; - extra.config.datetime_mode.datetime_serialize(py_datetime, serializer) + extra.config.temporal_mode.datetime_serialize(py_datetime, serializer) } ObType::Date => { let py_date = value.downcast().map_err(py_err_se_err)?; - let iso_date = super::type_serializers::datetime_etc::date_to_string(py_date).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_date) + extra.config.temporal_mode.date_serialize(py_date, serializer) } ObType::Time => { let py_time = value.downcast().map_err(py_err_se_err)?; @@ -637,11 +636,8 @@ pub(crate) fn infer_json_key_known<'a>( }) .map(|cow| Cow::Owned(cow.into_owned())) } - ObType::Datetime => extra.config.datetime_mode.json_key(key.downcast()?), - ObType::Date => { - let iso_date = super::type_serializers::datetime_etc::date_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_date)) - } + ObType::Datetime => extra.config.temporal_mode.datetime_json_key(key.downcast()?), + ObType::Date => extra.config.temporal_mode.date_json_key(key.downcast()?), ObType::Time => { let iso_time = super::type_serializers::datetime_etc::time_to_string(key.downcast()?)?; Ok(Cow::Owned(iso_time)) diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index 22395d3a4..34d0080c2 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -9,6 +9,7 @@ use type_serializers::any::AnySerializer; use crate::definitions::{Definitions, DefinitionsBuilder}; use crate::py_gc::PyGcTraverse; +use crate::serializers::config::TemporalMode; pub(crate) use config::BytesMode; use config::SerializationConfig; pub use errors::{PydanticSerializationError, PydanticSerializationUnexpectedValue}; @@ -16,7 +17,6 @@ use extra::{CollectWarnings, SerRecursionState, WarningsMode}; pub(crate) use extra::{Extra, SerMode, SerializationState}; use shared::to_json_bytes; pub use shared::CombinedSerializer; -use crate::serializers::config::TemporalMode; mod computed_fields; mod config; diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 4f469559c..5d18595b0 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -10,7 +10,7 @@ use super::{ }; use crate::definitions::DefinitionsBuilder; use crate::input::{pydate_as_date, pydatetime_as_datetime, pytime_as_time}; -use crate::serializers::config::{TemporalMode, FromConfig}; +use crate::serializers::config::{FromConfig, TemporalMode}; use crate::PydanticSerializationUnexpectedValue; pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult { @@ -25,6 +25,13 @@ pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResul pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp_ms()) } +pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp()) +} +pub(crate) fn date_to_milliseconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp_ms()) +} + pub(crate) fn date_to_string(py_date: &Bound<'_, PyDate>) -> PyResult { pydate_as_date(py_date).map(|dt| dt.to_string()) } @@ -140,8 +147,8 @@ impl BuildSerializer for DatetimeSerializer { config: Option<&Bound<'_, PyDict>>, _definitions: &mut DefinitionsBuilder, ) -> PyResult { - let datetime_mode = TemporalMode::from_config(config)?; - Ok(Self { temporal_mode: datetime_mode }.into()) + let temporal_mode = TemporalMode::from_config(config)?; + Ok(Self { temporal_mode }.into()) } } impl_py_gc_traverse!(DatetimeSerializer {}); @@ -168,7 +175,7 @@ impl TypeSerializer for DatetimeSerializer { fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match PyAnyMethods::downcast::(key) { - Ok(py_value) => Ok(self.temporal_mode.json_key(py_value)?), + Ok(py_value) => Ok(self.temporal_mode.datetime_json_key(py_value)?), Err(_) => { extra.warnings.on_fallback_py(self.get_name(), key, extra)?; infer_json_key(key, extra) @@ -197,5 +204,79 @@ impl TypeSerializer for DatetimeSerializer { Self::EXPECTED_TYPE } } -build_serializer!(DateSerializer, "date", downcast_date_reject_datetime, date_to_string); + +#[derive(Debug)] +pub struct DateSerializer { + temporal_mode: TemporalMode, +} + +impl BuildSerializer for DateSerializer { + const EXPECTED_TYPE: &'static str = "date"; + + fn build( + _schema: &Bound<'_, PyDict>, + config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + let temporal_mode = TemporalMode::from_config(config)?; + Ok(Self { + temporal_mode: temporal_mode, + } + .into()) + } +} +impl_py_gc_traverse!(DateSerializer {}); + +impl TypeSerializer for DateSerializer { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> PyResult { + match extra.mode { + SerMode::Json => match downcast_date_reject_datetime(value) { + Ok(py_value) => Ok(self.temporal_mode.date_to_json(value.py(), py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } + }, + _ => infer_to_python(value, include, exclude, extra), + } + } + + fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { + match downcast_date_reject_datetime(key) { + Ok(py_value) => Ok(self.temporal_mode.date_json_key(py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), key, extra)?; + infer_json_key(key, extra) + } + } + } + + fn serde_serialize( + &self, + value: &Bound<'_, PyAny>, + serializer: S, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> Result { + match downcast_date_reject_datetime(value) { + Ok(py_value) => self.temporal_mode.date_serialize(py_value, serializer), + Err(_) => { + extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; + infer_serialize(value, serializer, include, exclude, extra) + } + } + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} + build_serializer!(TimeSerializer, "time", PyAnyMethods::downcast::, time_to_string); diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index f7a885ac0..d05f6d6c4 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -760,6 +760,47 @@ def test_any_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'} + assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict + assert s.to_json({dt: 'foo'}) == expected_to_json_dict + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + date(2024, 1, 1), + '2024-01-01', + b'"2024-01-01"', + {'2024-01-01': 'foo'}, + b'{"2024-01-01":"foo"}', + 'iso8601', + ), + ( + date(2024, 1, 1), + 1704067200, + b'1704067200', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + date(2024, 1, 1), + 1704067200000, + b'1704067200000', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ], +) +def test_any_config_date( + dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + 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'} assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict assert s.to_json({dt: 'foo'}) == expected_to_json_dict \ No newline at end of file From dce1bc58f27a154a5655dfc1ccfd63759397c882 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Tue, 17 Jun 2025 10:20:24 +0100 Subject: [PATCH 05/16] feat: implement time logic for ser_json_temporal --- src/serializers/config.rs | 41 +++++++++- src/serializers/infer.rs | 12 +-- .../type_serializers/datetime_etc.rs | 79 ++++++++++++++++++- tests/serializers/test_any.py | 43 +++++++++- 4 files changed, 163 insertions(+), 12 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index b5415237d..d264b6728 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -3,7 +3,7 @@ use std::str::{from_utf8, FromStr, Utf8Error}; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDict, PyString}; +use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDict, PyString, PyTime}; use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; @@ -12,7 +12,7 @@ use crate::build_tools::py_schema_err; use crate::input::{pydatetime_as_datetime, EitherTimedelta}; use crate::serializers::type_serializers::datetime_etc::{ date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, - datetime_to_string, + datetime_to_string, time_to_milliseconds, time_to_seconds, time_to_string, }; use crate::tools::SchemaDict; @@ -201,6 +201,14 @@ impl TemporalMode { } } + pub fn time_to_json(self, py: Python, time: &Bound<'_, PyTime>) -> PyResult { + match self { + Self::Iso8601 => Ok(time_to_string(time)?.into_py_any(py)?), + Self::SecondsInt => Ok(time_to_seconds(time)?.into_py_any(py)?), + Self::MillisecondsInt => Ok(time_to_milliseconds(time)?.into_py_any(py)?), + } + } + pub fn datetime_json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), @@ -217,6 +225,14 @@ impl TemporalMode { } } + pub fn time_json_key<'py>(self, time: &Bound<'_, PyTime>) -> PyResult> { + match self { + Self::Iso8601 => Ok(time_to_string(time)?.to_string().into()), + Self::SecondsInt => Ok(time_to_seconds(time)?.to_string().into()), + Self::MillisecondsInt => Ok(time_to_milliseconds(time)?.to_string().into()), + } + } + pub fn datetime_serialize( self, datetime: &Bound<'_, PyDateTime>, @@ -258,6 +274,27 @@ impl TemporalMode { } } } + + pub fn time_serialize( + self, + time: &Bound<'_, PyTime>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = time_to_string(time).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::SecondsInt => { + let s = time_to_seconds(time).map_err(py_err_se_err)?; + serializer.serialize_u32(s) + } + Self::MillisecondsInt => { + let s = time_to_milliseconds(time).map_err(py_err_se_err)?; + serializer.serialize_u32(s) + } + } + } } impl BytesMode { diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index 02cfb28a9..67401decb 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -187,8 +187,8 @@ pub(crate) fn infer_to_python_known( date.into_py_any(py)? } ObType::Time => { - let iso_time = super::type_serializers::datetime_etc::time_to_string(value.downcast()?)?; - iso_time.into_py_any(py)? + let time = extra.config.temporal_mode.time_to_json(value.py(), value.downcast()?)?; + time.into_py_any(py)? } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value)?; @@ -470,8 +470,7 @@ pub(crate) fn infer_serialize_known( } ObType::Time => { let py_time = value.downcast().map_err(py_err_se_err)?; - let iso_time = super::type_serializers::datetime_etc::time_to_string(py_time).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_time) + extra.config.temporal_mode.time_serialize(py_time, serializer) } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value).map_err(py_err_se_err)?; @@ -638,10 +637,7 @@ pub(crate) fn infer_json_key_known<'a>( } ObType::Datetime => extra.config.temporal_mode.datetime_json_key(key.downcast()?), ObType::Date => extra.config.temporal_mode.date_json_key(key.downcast()?), - ObType::Time => { - let iso_time = super::type_serializers::datetime_etc::time_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_time)) - } + ObType::Time => extra.config.temporal_mode.time_json_key(key.downcast()?), ObType::Uuid => { let uuid = super::type_serializers::uuid::uuid_to_string(key)?; Ok(Cow::Owned(uuid)) diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 5d18595b0..8a2b5e2f6 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -40,6 +40,14 @@ pub(crate) fn time_to_string(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|dt| dt.to_string()) } +pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| t.total_seconds()) +} + +pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| t.total_ms()) +} + fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyResult<&'a Bound<'py, PyDate>> { if let Ok(py_date) = py_date.downcast::() { // because `datetime` is a subclass of `date` we have to check that the value is not a @@ -279,4 +287,73 @@ impl TypeSerializer for DateSerializer { } } -build_serializer!(TimeSerializer, "time", PyAnyMethods::downcast::, time_to_string); +#[derive(Debug)] +pub struct TimeSerializer { + temporal_mode: TemporalMode, +} + +impl BuildSerializer for TimeSerializer { + const EXPECTED_TYPE: &'static str = "time"; + + fn build( + _schema: &Bound<'_, PyDict>, + config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + let temporal_mode = TemporalMode::from_config(config)?; + Ok(Self { temporal_mode }.into()) + } +} +impl_py_gc_traverse!(TimeSerializer {}); + +impl TypeSerializer for TimeSerializer { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> PyResult { + match extra.mode { + SerMode::Json => match PyAnyMethods::downcast::(value) { + Ok(py_value) => Ok(self.temporal_mode.time_to_json(value.py(), py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } + }, + _ => infer_to_python(value, include, exclude, extra), + } + } + + fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { + match PyAnyMethods::downcast::(key) { + Ok(py_value) => Ok(self.temporal_mode.time_json_key(py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), key, extra)?; + infer_json_key(key, extra) + } + } + } + + fn serde_serialize( + &self, + value: &Bound<'_, PyAny>, + serializer: S, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> Result { + match PyAnyMethods::downcast::(value) { + Ok(py_value) => self.temporal_mode.time_serialize(py_value, serializer), + Err(_) => { + extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; + infer_serialize(value, serializer, include, exclude, extra) + } + } + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index d05f6d6c4..6a7edb345 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -803,4 +803,45 @@ def test_any_config_date( assert s.to_python({dt: 'foo'}) == {dt: 'foo'} assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({dt: 'foo'}) == expected_to_json_dict \ No newline at end of file + assert s.to_json({dt: 'foo'}) == expected_to_json_dict + +@pytest.mark.parametrize( + 't,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + time(3, 14, 1, 59263), + '03:14:01.059263', + b'"03:14:01.059263"', + {'03:14:01.059263': 'foo'}, + b'{"03:14:01.059263":"foo"}', + 'iso8601', + ), + ( + time(3, 14, 1, 59263), + 11641, + b'11641', + {'11641': 'foo'}, + b'{"11641":"foo"}', + 'seconds', + ), + ( + time(3, 14, 1, 59263), + 11641059, + b'11641059', + {'11641059': 'foo'}, + b'{"11641059":"foo"}', + 'milliseconds', + ), + ], +) +def test_any_config_time( + t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(t) == t + 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'} + assert s.to_python({t: 'foo'}, mode='json') == expected_to_python_dict + assert s.to_json({t: 'foo'}) == expected_to_json_dict \ No newline at end of file From 8194eb1ea8fea49030e854eb53c65a63dac100e4 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Tue, 17 Jun 2025 11:36:50 +0100 Subject: [PATCH 06/16] fix: formatting --- python/pydantic_core/_pydantic_core.pyi | 7 +- src/serializers/config.rs | 2 +- src/serializers/mod.rs | 1 - .../type_serializers/datetime_etc.rs | 92 +------------- src/validators/datetime.rs | 1 - tests/serializers/test_any.py | 119 +++++++++--------- 6 files changed, 71 insertions(+), 151 deletions(-) diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 2aff05cc8..97839c938 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -402,7 +402,7 @@ def to_json( exclude_none: bool = False, round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', - termporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', + temporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, @@ -426,6 +426,8 @@ def to_json( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. + temporal_mode: How to serialize datetime like objects, either + `'iso8601'` or `'seconds'` or `'milliseconds'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. 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 @@ -484,6 +486,7 @@ def to_jsonable_python( exclude_none: bool = False, round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', + temporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, @@ -505,6 +508,8 @@ def to_jsonable_python( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. + temporal_mode: How to serialize datetime like objects, either + `'iso8601'` or `'seconds'` or `'milliseconds'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. 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 diff --git a/src/serializers/config.rs b/src/serializers/config.rs index d264b6728..b7ad4b15c 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -9,7 +9,7 @@ use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; use crate::build_tools::py_schema_err; -use crate::input::{pydatetime_as_datetime, EitherTimedelta}; +use crate::input::EitherTimedelta; use crate::serializers::type_serializers::datetime_etc::{ date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, datetime_to_string, time_to_milliseconds, time_to_seconds, time_to_string, diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index 34d0080c2..acecaf749 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -9,7 +9,6 @@ use type_serializers::any::AnySerializer; use crate::definitions::{Definitions, DefinitionsBuilder}; use crate::py_gc::PyGcTraverse; -use crate::serializers::config::TemporalMode; pub(crate) use config::BytesMode; use config::SerializationConfig; pub use errors::{PydanticSerializationError, PydanticSerializationUnexpectedValue}; diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 8a2b5e2f6..215f0b01f 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -2,11 +2,10 @@ use std::borrow::Cow; use pyo3::prelude::*; use pyo3::types::{PyDate, PyDateTime, PyDict, PyTime}; -use pyo3::IntoPyObjectExt; use super::{ - infer_json_key, infer_serialize, infer_to_python, py_err_se_err, BuildSerializer, CombinedSerializer, Extra, - SerMode, TypeSerializer, + infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, SerMode, + TypeSerializer, }; use crate::definitions::DefinitionsBuilder; use crate::input::{pydate_as_date, pydatetime_as_datetime, pytime_as_time}; @@ -60,88 +59,6 @@ fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyR Err(PydanticSerializationUnexpectedValue::new_from_msg(None).to_py_err()) } -macro_rules! build_serializer { - ($struct_name:ident, $expected_type:literal, $downcast:path, $convert_func:ident $(, $json_check_func:ident)?) => { - #[derive(Debug)] - pub struct $struct_name; - - impl BuildSerializer for $struct_name { - const EXPECTED_TYPE: &'static str = $expected_type; - - fn build( - _schema: &Bound<'_, PyDict>, - _config: Option<&Bound<'_, PyDict>>, - _definitions: &mut DefinitionsBuilder, - ) -> PyResult { - Ok(Self {}.into()) - } - } - - impl_py_gc_traverse!($struct_name {}); - - impl TypeSerializer for $struct_name { - fn to_python( - &self, - value: &Bound<'_, PyAny>, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> PyResult { - let py = value.py(); - match $downcast(value) { - Ok(py_value) => match extra.mode { - SerMode::Json => { - let s = $convert_func(py_value)?; - s.into_py_any(py) - } - _ => Ok(value.clone().unbind()), - }, - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) - } - } - } - - fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { - match $downcast(key) { - Ok(py_value) => Ok(Cow::Owned($convert_func(py_value)?)), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), key, extra)?; - infer_json_key(key, extra) - } - } - } - - fn serde_serialize( - &self, - value: &Bound<'_, PyAny>, - serializer: S, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> Result { - match $downcast(value) { - Ok(py_value) => { - let s = $convert_func(py_value).map_err(py_err_se_err)?; - serializer.serialize_str(&s) - } - Err(_) => { - extra - .warnings - .on_fallback_ser::(self.get_name(), value, extra)?; - infer_serialize(value, serializer, include, exclude, extra) - } - } - } - - fn get_name(&self) -> &str { - Self::EXPECTED_TYPE - } - } - }; -} - #[derive(Debug)] pub struct DatetimeSerializer { temporal_mode: TemporalMode, @@ -227,10 +144,7 @@ impl BuildSerializer for DateSerializer { _definitions: &mut DefinitionsBuilder, ) -> PyResult { let temporal_mode = TemporalMode::from_config(config)?; - Ok(Self { - temporal_mode: temporal_mode, - } - .into()) + Ok(Self { temporal_mode }.into()) } } impl_py_gc_traverse!(DateSerializer {}); diff --git a/src/validators/datetime.rs b/src/validators/datetime.rs index 5b553a2aa..f6384cd2c 100644 --- a/src/validators/datetime.rs +++ b/src/validators/datetime.rs @@ -5,7 +5,6 @@ use pyo3::sync::GILOnceCell; use pyo3::types::{PyDict, PyString}; use speedate::{DateTime, MicrosecondsPrecisionOverflowBehavior, Time}; use std::cmp::Ordering; -use std::str::FromStr; use strum::EnumMessage; use crate::build_tools::{is_strict, py_schema_error_type}; diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index 6a7edb345..e4801a0aa 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -723,37 +723,38 @@ class MyEnum(Enum): assert v.to_python(1) == 1 assert v.to_json(1) == b'1' + @pytest.mark.parametrize( 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', [ ( - datetime(2024, 1, 1, 0, 0, 0), - '2024-01-01T00:00:00', - b'"2024-01-01T00:00:00"', - {'2024-01-01T00:00:00': 'foo'}, - b'{"2024-01-01T00:00:00":"foo"}', - 'iso8601', + datetime(2024, 1, 1, 0, 0, 0), + '2024-01-01T00:00:00', + b'"2024-01-01T00:00:00"', + {'2024-01-01T00:00:00': 'foo'}, + b'{"2024-01-01T00:00:00":"foo"}', + 'iso8601', ), ( - datetime(2024, 1, 1, 0, 0, 0), - 1704067200, - b'1704067200', - {'1704067200': 'foo'}, - b'{"1704067200":"foo"}', - 'seconds', + datetime(2024, 1, 1, 0, 0, 0), + 1704067200, + b'1704067200', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', ), ( - datetime(2024, 1, 1, 0, 0, 0), - 1704067200000, - b'1704067200000', - {'1704067200000': 'foo'}, - b'{"1704067200000":"foo"}', - 'milliseconds', + datetime(2024, 1, 1, 0, 0, 0), + 1704067200000, + b'1704067200000', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', ), ], ) def test_any_config_datetime( - dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode + dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode ): s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) assert s.to_python(dt) == dt @@ -764,37 +765,38 @@ def test_any_config_datetime( assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict assert s.to_json({dt: 'foo'}) == expected_to_json_dict + @pytest.mark.parametrize( 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', [ ( - date(2024, 1, 1), - '2024-01-01', - b'"2024-01-01"', - {'2024-01-01': 'foo'}, - b'{"2024-01-01":"foo"}', - 'iso8601', + date(2024, 1, 1), + '2024-01-01', + b'"2024-01-01"', + {'2024-01-01': 'foo'}, + b'{"2024-01-01":"foo"}', + 'iso8601', ), ( - date(2024, 1, 1), - 1704067200, - b'1704067200', - {'1704067200': 'foo'}, - b'{"1704067200":"foo"}', - 'seconds', + date(2024, 1, 1), + 1704067200, + b'1704067200', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', ), ( - date(2024, 1, 1), - 1704067200000, - b'1704067200000', - {'1704067200000': 'foo'}, - b'{"1704067200000":"foo"}', - 'milliseconds', + date(2024, 1, 1), + 1704067200000, + b'1704067200000', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', ), ], ) def test_any_config_date( - dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode + dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode ): s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) assert s.to_python(dt) == dt @@ -805,37 +807,38 @@ def test_any_config_date( assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict assert s.to_json({dt: 'foo'}) == expected_to_json_dict + @pytest.mark.parametrize( 't,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', [ ( - time(3, 14, 1, 59263), - '03:14:01.059263', - b'"03:14:01.059263"', - {'03:14:01.059263': 'foo'}, - b'{"03:14:01.059263":"foo"}', - 'iso8601', + time(3, 14, 1, 59263), + '03:14:01.059263', + b'"03:14:01.059263"', + {'03:14:01.059263': 'foo'}, + b'{"03:14:01.059263":"foo"}', + 'iso8601', ), ( - time(3, 14, 1, 59263), - 11641, - b'11641', - {'11641': 'foo'}, - b'{"11641":"foo"}', - 'seconds', + time(3, 14, 1, 59263), + 11641, + b'11641', + {'11641': 'foo'}, + b'{"11641":"foo"}', + 'seconds', ), ( - time(3, 14, 1, 59263), - 11641059, - b'11641059', - {'11641059': 'foo'}, - b'{"11641059":"foo"}', - 'milliseconds', + time(3, 14, 1, 59263), + 11641059, + b'11641059', + {'11641059': 'foo'}, + b'{"11641059":"foo"}', + 'milliseconds', ), ], ) def test_any_config_time( - t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode + t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode ): s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) assert s.to_python(t) == t @@ -844,4 +847,4 @@ def test_any_config_time( assert s.to_python({t: 'foo'}) == {t: 'foo'} assert s.to_python({t: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({t: 'foo'}) == expected_to_json_dict \ No newline at end of file + assert s.to_json({t: 'foo'}) == expected_to_json_dict From 294b636c24a7144ec105c598be670ee2b407598f Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Tue, 17 Jun 2025 14:29:00 +0100 Subject: [PATCH 07/16] fix: tests --- src/input/datetime.rs | 87 ++++++ src/serializers/config.rs | 74 ++++- src/serializers/infer.rs | 31 ++- src/serializers/type_serializers/timedelta.rs | 61 ++++- tests/serializers/test_any.py | 256 ++++++++++++++++++ 5 files changed, 489 insertions(+), 20 deletions(-) diff --git a/src/input/datetime.rs b/src/input/datetime.rs index 279e79159..590d859b3 100644 --- a/src/input/datetime.rs +++ b/src/input/datetime.rs @@ -131,6 +131,93 @@ impl EitherTimedelta<'_> { Self::PySubclass(py_timedelta) => pytimedelta_subclass_as_duration(py_timedelta), } } + + pub fn total_seconds(&self) -> PyResult { + match self { + Self::Raw(timedelta) => { + let mut days: i64 = i64::from(timedelta.day); + let mut seconds: i64 = i64::from(timedelta.second); + let mut microseconds = i64::from(timedelta.microsecond); + if !timedelta.positive { + days = -days; + seconds = -seconds; + microseconds = -microseconds; + } + + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + microseconds; + Ok(total_microseconds as f64 / 1_000_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + microseconds as f64 / 1_000_000.0; + Ok(total_seconds) + } + } + Self::PyExact(py_timedelta) => { + let days: i64 = py_timedelta.get_days().into(); // -999999999 to 999999999 + let seconds: i64 = py_timedelta.get_seconds().into(); // 0 through 86399 + let microseconds = py_timedelta.get_microseconds(); // 0 through 999999 + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + i64::from(microseconds); + Ok(total_microseconds as f64 / 1_000_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + f64::from(microseconds) / 1_000_000.0; + Ok(total_seconds) + } + } + Self::PySubclass(py_timedelta) => py_timedelta + .call_method0(intern!(py_timedelta.py(), "total_seconds"))? + .extract(), + } + } + + pub fn total_milliseconds(&self) -> PyResult { + match self { + Self::Raw(timedelta) => { + let mut days: i64 = i64::from(timedelta.day); + let mut seconds: i64 = i64::from(timedelta.second); + let mut microseconds = i64::from(timedelta.microsecond); + if !timedelta.positive { + days = -days; + seconds = -seconds; + microseconds = -microseconds; + } + + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + microseconds; + Ok(total_microseconds as f64 / 1_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + microseconds as f64 / 1_000.0; + Ok(total_seconds) + } + } + Self::PyExact(py_timedelta) => { + let days: i64 = py_timedelta.get_days().into(); // -999999999 to 999999999 + let seconds: i64 = py_timedelta.get_seconds().into(); // 0 through 86399 + let microseconds = py_timedelta.get_microseconds(); // 0 through 999999 + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + i64::from(microseconds); + Ok(total_microseconds as f64 / 1_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_milliseconds = days_seconds as f64 * 1_000.0 + f64::from(microseconds) / 1_000.0; + Ok(total_milliseconds) + } + } + Self::PySubclass(py_timedelta) => { + let total_seconds: f64 = py_timedelta + .call_method0(intern!(py_timedelta.py(), "total_seconds"))? + .extract()?; + Ok(total_seconds / 1000.0) + } + } + } } impl<'py> TryFrom<&'_ Bound<'py, PyAny>> for EitherTimedelta<'py> { diff --git a/src/serializers/config.rs b/src/serializers/config.rs index b7ad4b15c..1bad0ef55 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -14,6 +14,7 @@ use crate::serializers::type_serializers::datetime_etc::{ date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, datetime_to_string, time_to_milliseconds, time_to_seconds, time_to_string, }; +use crate::serializers::type_serializers::timedelta::EffectiveDeltaMode; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -23,6 +24,7 @@ use super::errors::py_err_se_err; pub(crate) struct SerializationConfig { pub timedelta_mode: TimedeltaMode, pub temporal_mode: TemporalMode, + prefer_timedelta_mode: bool, pub bytes_mode: BytesMode, pub inf_nan_mode: InfNanMode, } @@ -30,12 +32,16 @@ pub(crate) struct SerializationConfig { impl SerializationConfig { pub fn from_config(config: Option<&Bound<'_, PyDict>>) -> PyResult { let timedelta_mode = TimedeltaMode::from_config(config)?; - let datetime_mode = TemporalMode::from_config(config)?; + let temporal_mode = TemporalMode::from_config(config)?; + let prefer_timedelta_mode = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_timedelta")).ok()) + .unwrap_or(false); let bytes_mode = BytesMode::from_config(config)?; let inf_nan_mode = InfNanMode::from_config(config)?; Ok(Self { timedelta_mode, - temporal_mode: datetime_mode, + temporal_mode, + prefer_timedelta_mode, bytes_mode, inf_nan_mode, }) @@ -50,10 +56,19 @@ impl SerializationConfig { Ok(Self { timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, temporal_mode: TemporalMode::from_str(datetime_mode)?, + prefer_timedelta_mode: true, // This is not settable via args bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, }) } + + pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { + if self.prefer_timedelta_mode { + EffectiveDeltaMode::Timedelta(self.timedelta_mode.clone()) + } else { + EffectiveDeltaMode::Temporal(self.temporal_mode.clone()) + } + } } pub trait FromConfig { @@ -209,6 +224,23 @@ impl TemporalMode { } } + pub fn timedelta_to_json(self, py: Python, either_delta: EitherTimedelta) -> PyResult { + match self { + Self::Iso8601 => { + let d = either_delta.to_duration()?; + Ok(d.to_string().into_py_any(py)?) + } + Self::SecondsInt => { + let seconds: f64 = either_delta.total_seconds()?; + Ok(seconds.into_py_any(py)?) + } + Self::MillisecondsInt => { + let milliseconds: f64 = either_delta.total_milliseconds()?; + Ok(milliseconds.into_py_any(py)?) + } + } + } + pub fn datetime_json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), @@ -233,6 +265,23 @@ impl TemporalMode { } } + pub fn timedelta_json_key<'py>(self, either_delta: &EitherTimedelta) -> PyResult> { + match self { + Self::Iso8601 => { + let d = either_delta.to_duration()?; + Ok(d.to_string().into()) + } + Self::SecondsInt => { + let seconds: f64 = either_delta.total_seconds()?; + Ok(seconds.to_string().into()) + } + Self::MillisecondsInt => { + let milliseconds: f64 = either_delta.total_milliseconds()?; + Ok(milliseconds.to_string().into()) + } + } + } + pub fn datetime_serialize( self, datetime: &Bound<'_, PyDateTime>, @@ -295,6 +344,27 @@ impl TemporalMode { } } } + + pub fn timedelta_serialize( + self, + either_delta: EitherTimedelta, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let d = either_delta.to_duration().map_err(py_err_se_err)?; + serializer.serialize_str(&d.to_string()) + } + Self::SecondsInt => { + let seconds: f64 = either_delta.total_seconds().map_err(py_err_se_err)?; + serializer.serialize_f64(seconds) + } + Self::MillisecondsInt => { + let milliseconds: f64 = either_delta.total_milliseconds().map_err(py_err_se_err)?; + serializer.serialize_f64(milliseconds) + } + } + } } impl BytesMode { diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index 67401decb..0b970788b 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -12,6 +12,7 @@ use serde::ser::{Error, Serialize, SerializeMap, SerializeSeq, Serializer}; use crate::input::{EitherTimedelta, Int}; use crate::serializers::type_serializers; +use crate::serializers::type_serializers::timedelta::EffectiveDeltaMode; use crate::tools::{extract_int, py_err, safe_repr}; use crate::url::{PyMultiHostUrl, PyUrl}; @@ -192,10 +193,15 @@ pub(crate) fn infer_to_python_known( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value)?; - extra - .config - .timedelta_mode - .either_delta_to_json(value.py(), either_delta)? + let x = match extra.config.effective_delta_mode() { + EffectiveDeltaMode::Temporal(temporal_mode) => { + temporal_mode.timedelta_to_json(value.py(), either_delta) + } + EffectiveDeltaMode::Timedelta(timedelta_mode) => { + timedelta_mode.either_delta_to_json(value.py(), either_delta) + } + }?; + x } ObType::Url => { let py_url: PyUrl = value.extract()?; @@ -474,10 +480,14 @@ pub(crate) fn infer_serialize_known( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value).map_err(py_err_se_err)?; - extra - .config - .timedelta_mode - .timedelta_serialize(value.py(), either_delta, serializer) + match extra.config.effective_delta_mode() { + EffectiveDeltaMode::Temporal(temporal_mode) => { + temporal_mode.timedelta_serialize(either_delta, serializer) + } + EffectiveDeltaMode::Timedelta(timedelta_mode) => { + timedelta_mode.timedelta_serialize(value.py(), either_delta, serializer) + } + } } ObType::Url => { let py_url: PyUrl = value.extract().map_err(py_err_se_err)?; @@ -644,7 +654,10 @@ pub(crate) fn infer_json_key_known<'a>( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(key)?; - extra.config.timedelta_mode.json_key(key.py(), either_delta) + match extra.config.effective_delta_mode() { + EffectiveDeltaMode::Temporal(temporal_mode) => temporal_mode.timedelta_json_key(&either_delta), + EffectiveDeltaMode::Timedelta(timedelta_mode) => timedelta_mode.json_key(key.py(), either_delta), + } } ObType::Url => { let py_url: PyUrl = key.extract()?; diff --git a/src/serializers/type_serializers/timedelta.rs b/src/serializers/type_serializers/timedelta.rs index f1fb99da7..1f322c76a 100644 --- a/src/serializers/type_serializers/timedelta.rs +++ b/src/serializers/type_serializers/timedelta.rs @@ -1,11 +1,11 @@ -use std::borrow::Cow; - +use pyo3::intern; use pyo3::prelude::*; use pyo3::types::PyDict; +use std::borrow::Cow; use crate::definitions::DefinitionsBuilder; use crate::input::EitherTimedelta; -use crate::serializers::config::{FromConfig, TimedeltaMode}; +use crate::serializers::config::{FromConfig, TemporalMode, TimedeltaMode}; use super::{ infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, SerMode, @@ -15,6 +15,13 @@ use super::{ #[derive(Debug)] pub struct TimeDeltaSerializer { timedelta_mode: TimedeltaMode, + temporal_mode: TemporalMode, + prefer_timedelta_mode: bool, +} + +pub enum EffectiveDeltaMode { + Timedelta(TimedeltaMode), + Temporal(TemporalMode), } impl BuildSerializer for TimeDeltaSerializer { @@ -26,7 +33,28 @@ impl BuildSerializer for TimeDeltaSerializer { _definitions: &mut DefinitionsBuilder, ) -> PyResult { let timedelta_mode = TimedeltaMode::from_config(config)?; - Ok(Self { timedelta_mode }.into()) + let temporal_mode = TemporalMode::from_config(config)?; + + let prefer_timedelta_mode = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_timedelta")).ok()) + .unwrap_or(false); + + Ok(Self { + timedelta_mode, + temporal_mode, + prefer_timedelta_mode, + } + .into()) + } +} + +impl TimeDeltaSerializer { + pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { + if self.prefer_timedelta_mode { + EffectiveDeltaMode::Timedelta(self.timedelta_mode.clone()) + } else { + EffectiveDeltaMode::Temporal(self.temporal_mode.clone()) + } } } @@ -42,7 +70,14 @@ impl TypeSerializer for TimeDeltaSerializer { ) -> PyResult { match extra.mode { SerMode::Json => match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => self.timedelta_mode.either_delta_to_json(value.py(), either_timedelta), + Ok(either_timedelta) => match self.effective_delta_mode() { + EffectiveDeltaMode::Timedelta(timedelta_mode) => { + Ok(timedelta_mode.either_delta_to_json(value.py(), either_timedelta)?) + } + EffectiveDeltaMode::Temporal(temporal_mode) => { + Ok(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) @@ -54,7 +89,10 @@ impl TypeSerializer for TimeDeltaSerializer { fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match EitherTimedelta::try_from(key) { - Ok(either_timedelta) => self.timedelta_mode.json_key(key.py(), either_timedelta), + Ok(either_timedelta) => match self.effective_delta_mode() { + EffectiveDeltaMode::Timedelta(timedelta_mode) => timedelta_mode.json_key(key.py(), either_timedelta), + EffectiveDeltaMode::Temporal(temporal_mode) => temporal_mode.timedelta_json_key(&either_timedelta), + }, Err(_) => { extra.warnings.on_fallback_py(self.get_name(), key, extra)?; infer_json_key(key, extra) @@ -71,9 +109,14 @@ impl TypeSerializer for TimeDeltaSerializer { extra: &Extra, ) -> Result { match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => self - .timedelta_mode - .timedelta_serialize(value.py(), either_timedelta, serializer), + Ok(either_timedelta) => match self.effective_delta_mode() { + EffectiveDeltaMode::Timedelta(timedelta_mode) => { + timedelta_mode.timedelta_serialize(value.py(), either_timedelta, serializer) + } + EffectiveDeltaMode::Temporal(temporal_mode) => { + temporal_mode.timedelta_serialize(either_timedelta, serializer) + } + }, 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 e4801a0aa..2cce4f157 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -848,3 +848,259 @@ def test_any_config_time( assert s.to_python({t: 'foo'}) == {t: 'foo'} assert s.to_python({t: 'foo'}, mode='json') == expected_to_python_dict assert s.to_json({t: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), + ( + timedelta(hours=-2), + -7200000.0, + b'-7200000.0', + {'-7200000': 'foo'}, + b'{"-7200000":"foo"}', + 'milliseconds', + ), + (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), + (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), + (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), + ( + timedelta(microseconds=-1), + -0.001, + b'-0.001', + {'-0.001': 'foo'}, + b'{"-0.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1), + 86400000.0, + b'86400000.0', + {'86400000': 'foo'}, + b'{"86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1), + -86400000.0, + b'-86400000.0', + {'-86400000': 'foo'}, + b'{"-86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1), + 86401000.0, + b'86401000.0', + {'86401000': 'foo'}, + b'{"86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1), + -86401000.0, + b'-86401000.0', + {'-86401000': 'foo'}, + b'{"-86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=-1), + 86399000.0, + b'86399000.0', + {'86399000': 'foo'}, + b'{"86399000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401000.001, + b'86401000.001', + {'86401000.001': 'foo'}, + b'{"86401000.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401000.001, + b'-86401000.001', + {'-86401000.001': 'foo'}, + b'{"-86401000.001":"foo"}', + 'milliseconds', + ), + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'seconds', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'seconds', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'seconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'seconds', + ), + ], +) +def test_any_config_timedelta( + td: timedelta, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) + 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'} + assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict + assert s.to_json({td: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,timedelta_mode', + [ + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'float'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'float'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'float'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'float'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'float'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'float', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'float'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'float'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'float'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'float', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'float'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'float', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'float', + ), + (timedelta(hours=2), 'PT2H', b'"PT2H"', {'PT2H': 'foo'}, b'{"PT2H":"foo"}', 'iso8601'), + (timedelta(hours=-2), '-PT2H', b'"-PT2H"', {'-PT2H': 'foo'}, b'{"-PT2H":"foo"}', 'iso8601'), + (timedelta(seconds=1.5), 'PT1.5S', b'"PT1.5S"', {'PT1.5S': 'foo'}, b'{"PT1.5S":"foo"}', 'iso8601'), + (timedelta(seconds=-1.5), '-PT1.5S', b'"-PT1.5S"', {'-PT1.5S': 'foo'}, b'{"-PT1.5S":"foo"}', 'iso8601'), + ( + timedelta(microseconds=1), + 'PT0.000001S', + b'"PT0.000001S"', + {'PT0.000001S': 'foo'}, + b'{"PT0.000001S":"foo"}', + 'iso8601', + ), + ( + timedelta(microseconds=-1), + '-PT0.000001S', + b'"-PT0.000001S"', + {'-PT0.000001S': 'foo'}, + b'{"-PT0.000001S":"foo"}', + 'iso8601', + ), + (timedelta(days=1), 'P1D', b'"P1D"', {'P1D': 'foo'}, b'{"P1D":"foo"}', 'iso8601'), + (timedelta(days=-1), '-P1D', b'"-P1D"', {'-P1D': 'foo'}, b'{"-P1D":"foo"}', 'iso8601'), + (timedelta(days=1, seconds=1), 'P1DT1S', b'"P1DT1S"', {'P1DT1S': 'foo'}, b'{"P1DT1S":"foo"}', 'iso8601'), + ( + timedelta(days=-1, seconds=-1), + '-P1DT1S', + b'"-P1DT1S"', + {'-P1DT1S': 'foo'}, + b'{"-P1DT1S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=1, seconds=-1), + 'PT23H59M59S', + b'"PT23H59M59S"', + {'PT23H59M59S': 'foo'}, + b'{"PT23H59M59S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 'P1DT1.000001S', + b'"P1DT1.000001S"', + {'P1DT1.000001S': 'foo'}, + b'{"P1DT1.000001S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + '-P1DT1.000001S', + b'"-P1DT1.000001S"', + {'-P1DT1.000001S': 'foo'}, + b'{"-P1DT1.000001S":"foo"}', + 'iso8601', + ), + ], +) +@pytest.mark.parametrize('temporal_mode', ['iso8601', 'seconds', 'milliseconds']) +def test_any_config_timedelta_timedelta_ser_flag_prioritised( + td: timedelta, + expected_to_python, + expected_to_json, + expected_to_python_dict, + expected_to_json_dict, + temporal_mode, + timedelta_mode, +): + s = SchemaSerializer( + core_schema.any_schema(), config={'ser_json_temporal': temporal_mode, 'ser_json_timedelta': timedelta_mode} + ) + 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'} + assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict + assert s.to_json({td: 'foo'}) == expected_to_json_dict From 1dbf46d3e682049a9a8c1e986e1ba18f59ddd162 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Tue, 17 Jun 2025 14:40:36 +0100 Subject: [PATCH 08/16] fix: final changes --- src/serializers/config.rs | 68 +++++++++---------- .../type_serializers/datetime_etc.rs | 24 +++---- src/serializers/type_serializers/timedelta.rs | 10 +-- tests/serializers/test_any.py | 24 +++---- 4 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index 1bad0ef55..d9db84910 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -64,9 +64,9 @@ impl SerializationConfig { pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { if self.prefer_timedelta_mode { - EffectiveDeltaMode::Timedelta(self.timedelta_mode.clone()) + EffectiveDeltaMode::Timedelta(self.timedelta_mode) } else { - EffectiveDeltaMode::Temporal(self.temporal_mode.clone()) + EffectiveDeltaMode::Temporal(self.temporal_mode) } } } @@ -123,8 +123,8 @@ serialization_mode! { TemporalMode, "ser_json_temporal", Iso8601 => "iso8601", - SecondsInt => "seconds", - MillisecondsInt => "milliseconds" + Seconds => "seconds", + Milliseconds => "milliseconds" } serialization_mode! { @@ -203,24 +203,24 @@ impl TemporalMode { pub fn datetime_to_json(self, py: Python, datetime: &Bound<'_, PyDateTime>) -> PyResult { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.into_py_any(py)?), - Self::SecondsInt => Ok(datetime_to_seconds(datetime)?.into_py_any(py)?), - Self::MillisecondsInt => Ok(datetime_to_milliseconds(datetime)?.into_py_any(py)?), + Self::Seconds => Ok(datetime_to_seconds(datetime)?.into_py_any(py)?), + Self::Milliseconds => Ok(datetime_to_milliseconds(datetime)?.into_py_any(py)?), } } pub fn date_to_json(self, py: Python, date: &Bound<'_, PyDate>) -> PyResult { match self { Self::Iso8601 => Ok(date_to_string(date)?.into_py_any(py)?), - Self::SecondsInt => Ok(date_to_seconds(date)?.into_py_any(py)?), - Self::MillisecondsInt => Ok(date_to_milliseconds(date)?.into_py_any(py)?), + Self::Seconds => Ok(date_to_seconds(date)?.into_py_any(py)?), + Self::Milliseconds => Ok(date_to_milliseconds(date)?.into_py_any(py)?), } } pub fn time_to_json(self, py: Python, time: &Bound<'_, PyTime>) -> PyResult { match self { Self::Iso8601 => Ok(time_to_string(time)?.into_py_any(py)?), - Self::SecondsInt => Ok(time_to_seconds(time)?.into_py_any(py)?), - Self::MillisecondsInt => Ok(time_to_milliseconds(time)?.into_py_any(py)?), + Self::Seconds => Ok(time_to_seconds(time)?.into_py_any(py)?), + Self::Milliseconds => Ok(time_to_milliseconds(time)?.into_py_any(py)?), } } @@ -230,11 +230,11 @@ impl TemporalMode { let d = either_delta.to_duration()?; Ok(d.to_string().into_py_any(py)?) } - Self::SecondsInt => { + Self::Seconds => { let seconds: f64 = either_delta.total_seconds()?; Ok(seconds.into_py_any(py)?) } - Self::MillisecondsInt => { + Self::Milliseconds => { let milliseconds: f64 = either_delta.total_milliseconds()?; Ok(milliseconds.into_py_any(py)?) } @@ -244,24 +244,24 @@ impl TemporalMode { pub fn datetime_json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { match self { Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), - Self::SecondsInt => Ok(datetime_to_seconds(datetime)?.to_string().into()), - Self::MillisecondsInt => Ok(datetime_to_milliseconds(datetime)?.to_string().into()), + Self::Seconds => Ok(datetime_to_seconds(datetime)?.to_string().into()), + Self::Milliseconds => Ok(datetime_to_milliseconds(datetime)?.to_string().into()), } } pub fn date_json_key<'py>(self, date: &Bound<'_, PyDate>) -> PyResult> { match self { Self::Iso8601 => Ok(date_to_string(date)?.to_string().into()), - Self::SecondsInt => Ok(date_to_seconds(date)?.to_string().into()), - Self::MillisecondsInt => Ok(date_to_milliseconds(date)?.to_string().into()), + Self::Seconds => Ok(date_to_seconds(date)?.to_string().into()), + Self::Milliseconds => Ok(date_to_milliseconds(date)?.to_string().into()), } } pub fn time_json_key<'py>(self, time: &Bound<'_, PyTime>) -> PyResult> { match self { Self::Iso8601 => Ok(time_to_string(time)?.to_string().into()), - Self::SecondsInt => Ok(time_to_seconds(time)?.to_string().into()), - Self::MillisecondsInt => Ok(time_to_milliseconds(time)?.to_string().into()), + Self::Seconds => Ok(time_to_seconds(time)?.to_string().into()), + Self::Milliseconds => Ok(time_to_milliseconds(time)?.to_string().into()), } } @@ -271,11 +271,11 @@ impl TemporalMode { let d = either_delta.to_duration()?; Ok(d.to_string().into()) } - Self::SecondsInt => { + Self::Seconds => { let seconds: f64 = either_delta.total_seconds()?; Ok(seconds.to_string().into()) } - Self::MillisecondsInt => { + Self::Milliseconds => { let milliseconds: f64 = either_delta.total_milliseconds()?; Ok(milliseconds.to_string().into()) } @@ -292,13 +292,13 @@ impl TemporalMode { let s = datetime_to_string(datetime).map_err(py_err_se_err)?; serializer.serialize_str(&s) } - Self::SecondsInt => { + Self::Seconds => { let s = datetime_to_seconds(datetime).map_err(py_err_se_err)?; - serializer.serialize_i64(s) + serializer.serialize_f64(s) } - Self::MillisecondsInt => { + Self::Milliseconds => { let s = datetime_to_milliseconds(datetime).map_err(py_err_se_err)?; - serializer.serialize_i64(s) + serializer.serialize_f64(s) } } } @@ -313,13 +313,13 @@ impl TemporalMode { let s = date_to_string(date).map_err(py_err_se_err)?; serializer.serialize_str(&s) } - Self::SecondsInt => { + Self::Seconds => { let s = date_to_seconds(date).map_err(py_err_se_err)?; - serializer.serialize_i64(s) + serializer.serialize_f64(s) } - Self::MillisecondsInt => { + Self::Milliseconds => { let s = date_to_milliseconds(date).map_err(py_err_se_err)?; - serializer.serialize_i64(s) + serializer.serialize_f64(s) } } } @@ -334,13 +334,13 @@ impl TemporalMode { let s = time_to_string(time).map_err(py_err_se_err)?; serializer.serialize_str(&s) } - Self::SecondsInt => { + Self::Seconds => { let s = time_to_seconds(time).map_err(py_err_se_err)?; - serializer.serialize_u32(s) + serializer.serialize_f32(s) } - Self::MillisecondsInt => { + Self::Milliseconds => { let s = time_to_milliseconds(time).map_err(py_err_se_err)?; - serializer.serialize_u32(s) + serializer.serialize_f32(s) } } } @@ -355,11 +355,11 @@ impl TemporalMode { let d = either_delta.to_duration().map_err(py_err_se_err)?; serializer.serialize_str(&d.to_string()) } - Self::SecondsInt => { + Self::Seconds => { let seconds: f64 = either_delta.total_seconds().map_err(py_err_se_err)?; serializer.serialize_f64(seconds) } - Self::MillisecondsInt => { + Self::Milliseconds => { let milliseconds: f64 = either_delta.total_milliseconds().map_err(py_err_se_err)?; serializer.serialize_f64(milliseconds) } diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 215f0b01f..1314949d3 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -16,19 +16,19 @@ pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp()) +pub(crate) fn datetime_to_seconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp() as f64) } -pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp_ms()) +pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp_ms() as f64) } -pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { - pydate_as_date(py_date).map(|dt| dt.timestamp()) +pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp() as f64) } -pub(crate) fn date_to_milliseconds(py_date: &Bound<'_, PyDate>) -> PyResult { - pydate_as_date(py_date).map(|dt| dt.timestamp_ms()) +pub(crate) fn date_to_milliseconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp_ms() as f64) } pub(crate) fn date_to_string(py_date: &Bound<'_, PyDate>) -> PyResult { @@ -39,12 +39,12 @@ pub(crate) fn time_to_string(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|dt| dt.to_string()) } -pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { - pytime_as_time(py_time, None).map(|t| t.total_seconds()) +pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| t.total_seconds() as f32) } -pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { - pytime_as_time(py_time, None).map(|t| t.total_ms()) +pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| t.total_ms() as f32) } fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyResult<&'a Bound<'py, PyDate>> { diff --git a/src/serializers/type_serializers/timedelta.rs b/src/serializers/type_serializers/timedelta.rs index 1f322c76a..d970cf9ff 100644 --- a/src/serializers/type_serializers/timedelta.rs +++ b/src/serializers/type_serializers/timedelta.rs @@ -16,7 +16,7 @@ use super::{ pub struct TimeDeltaSerializer { timedelta_mode: TimedeltaMode, temporal_mode: TemporalMode, - prefer_timedelta_mode: bool, + prefer_timedelta: bool, } pub enum EffectiveDeltaMode { @@ -42,7 +42,7 @@ impl BuildSerializer for TimeDeltaSerializer { Ok(Self { timedelta_mode, temporal_mode, - prefer_timedelta_mode, + prefer_timedelta: prefer_timedelta_mode, } .into()) } @@ -50,10 +50,10 @@ impl BuildSerializer for TimeDeltaSerializer { impl TimeDeltaSerializer { pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { - if self.prefer_timedelta_mode { - EffectiveDeltaMode::Timedelta(self.timedelta_mode.clone()) + if self.prefer_timedelta { + EffectiveDeltaMode::Timedelta(self.timedelta_mode) } else { - EffectiveDeltaMode::Temporal(self.temporal_mode.clone()) + EffectiveDeltaMode::Temporal(self.temporal_mode) } } } diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index 2cce4f157..a2e4db018 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -737,16 +737,16 @@ class MyEnum(Enum): ), ( datetime(2024, 1, 1, 0, 0, 0), - 1704067200, - b'1704067200', + 1704067200.0, + b'1704067200.0', {'1704067200': 'foo'}, b'{"1704067200":"foo"}', 'seconds', ), ( datetime(2024, 1, 1, 0, 0, 0), - 1704067200000, - b'1704067200000', + 1704067200000.0, + b'1704067200000.0', {'1704067200000': 'foo'}, b'{"1704067200000":"foo"}', 'milliseconds', @@ -779,16 +779,16 @@ def test_any_config_datetime( ), ( date(2024, 1, 1), - 1704067200, - b'1704067200', + 1704067200.0, + b'1704067200.0', {'1704067200': 'foo'}, b'{"1704067200":"foo"}', 'seconds', ), ( date(2024, 1, 1), - 1704067200000, - b'1704067200000', + 1704067200000.0, + b'1704067200000.0', {'1704067200000': 'foo'}, b'{"1704067200000":"foo"}', 'milliseconds', @@ -821,16 +821,16 @@ def test_any_config_date( ), ( time(3, 14, 1, 59263), - 11641, - b'11641', + 11641.0, + b'11641.0', {'11641': 'foo'}, b'{"11641":"foo"}', 'seconds', ), ( time(3, 14, 1, 59263), - 11641059, - b'11641059', + 11641059.0, + b'11641059.0', {'11641059': 'foo'}, b'{"11641059":"foo"}', 'milliseconds', From c967da2ec12361a47ccd426900d0804e1be47f35 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Thu, 19 Jun 2025 20:21:34 +0100 Subject: [PATCH 09/16] fix: remove redundant ok --- src/serializers/config.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index d9db84910..3d9df8969 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -202,25 +202,25 @@ impl TimedeltaMode { impl TemporalMode { pub fn datetime_to_json(self, py: Python, datetime: &Bound<'_, PyDateTime>) -> PyResult { match self { - Self::Iso8601 => Ok(datetime_to_string(datetime)?.into_py_any(py)?), - Self::Seconds => Ok(datetime_to_seconds(datetime)?.into_py_any(py)?), - Self::Milliseconds => Ok(datetime_to_milliseconds(datetime)?.into_py_any(py)?), + Self::Iso8601 => datetime_to_string(datetime)?.into_py_any(py), + Self::Seconds => datetime_to_seconds(datetime)?.into_py_any(py), + Self::Milliseconds => datetime_to_milliseconds(datetime)?.into_py_any(py), } } pub fn date_to_json(self, py: Python, date: &Bound<'_, PyDate>) -> PyResult { match self { - Self::Iso8601 => Ok(date_to_string(date)?.into_py_any(py)?), - Self::Seconds => Ok(date_to_seconds(date)?.into_py_any(py)?), - Self::Milliseconds => Ok(date_to_milliseconds(date)?.into_py_any(py)?), + Self::Iso8601 => date_to_string(date)?.into_py_any(py), + Self::Seconds => date_to_seconds(date)?.into_py_any(py), + Self::Milliseconds => date_to_milliseconds(date)?.into_py_any(py), } } pub fn time_to_json(self, py: Python, time: &Bound<'_, PyTime>) -> PyResult { match self { - Self::Iso8601 => Ok(time_to_string(time)?.into_py_any(py)?), - Self::Seconds => Ok(time_to_seconds(time)?.into_py_any(py)?), - Self::Milliseconds => Ok(time_to_milliseconds(time)?.into_py_any(py)?), + Self::Iso8601 => time_to_string(time)?.into_py_any(py), + Self::Seconds => time_to_seconds(time)?.into_py_any(py), + Self::Milliseconds => time_to_milliseconds(time)?.into_py_any(py), } } @@ -228,15 +228,15 @@ impl TemporalMode { match self { Self::Iso8601 => { let d = either_delta.to_duration()?; - Ok(d.to_string().into_py_any(py)?) + d.to_string().into_py_any(py) } Self::Seconds => { let seconds: f64 = either_delta.total_seconds()?; - Ok(seconds.into_py_any(py)?) + seconds.into_py_any(py) } Self::Milliseconds => { let milliseconds: f64 = either_delta.total_milliseconds()?; - Ok(milliseconds.into_py_any(py)?) + milliseconds.into_py_any(py) } } } From e6b5f8290a676241bb842d4250a0d087a0379ad1 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 15:46:01 +0100 Subject: [PATCH 10/16] refactor: use a macro again --- .../type_serializers/datetime_etc.rs | 297 ++++++------------ 1 file changed, 101 insertions(+), 196 deletions(-) diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 1314949d3..0d10f4434 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -59,215 +59,120 @@ fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyR Err(PydanticSerializationUnexpectedValue::new_from_msg(None).to_py_err()) } -#[derive(Debug)] -pub struct DatetimeSerializer { - temporal_mode: TemporalMode, -} - -impl BuildSerializer for DatetimeSerializer { - const EXPECTED_TYPE: &'static str = "datetime"; - - fn build( - _schema: &Bound<'_, PyDict>, - config: Option<&Bound<'_, PyDict>>, - _definitions: &mut DefinitionsBuilder, - ) -> PyResult { - let temporal_mode = TemporalMode::from_config(config)?; - Ok(Self { temporal_mode }.into()) - } -} -impl_py_gc_traverse!(DatetimeSerializer {}); - -impl TypeSerializer for DatetimeSerializer { - fn to_python( - &self, - value: &Bound<'_, PyAny>, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> PyResult { - match extra.mode { - SerMode::Json => match PyAnyMethods::downcast::(value) { - Ok(py_value) => Ok(self.temporal_mode.datetime_to_json(value.py(), py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) - } - }, - _ => infer_to_python(value, include, exclude, extra), +macro_rules! build_temporal_serializer { + ( + $Struct:ident, + $expected_type:literal, + $downcast:path, + $to_json:ident, + $json_key_fn:ident, + $serialize_fn:ident + ) => { + #[derive(Debug)] + pub struct $Struct { + temporal_mode: TemporalMode, } - } - fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { - match PyAnyMethods::downcast::(key) { - Ok(py_value) => Ok(self.temporal_mode.datetime_json_key(py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), key, extra)?; - infer_json_key(key, extra) - } - } - } + impl BuildSerializer for $Struct { + const EXPECTED_TYPE: &'static str = $expected_type; - fn serde_serialize( - &self, - value: &Bound<'_, PyAny>, - serializer: S, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> Result { - match PyAnyMethods::downcast::(value) { - Ok(py_value) => self.temporal_mode.datetime_serialize(py_value, serializer), - Err(_) => { - extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; - infer_serialize(value, serializer, include, exclude, extra) + fn build( + _schema: &Bound<'_, PyDict>, + config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + let temporal_mode = TemporalMode::from_config(config)?; + Ok(Self { temporal_mode }.into()) } } - } - fn get_name(&self) -> &str { - Self::EXPECTED_TYPE - } -} - -#[derive(Debug)] -pub struct DateSerializer { - temporal_mode: TemporalMode, -} - -impl BuildSerializer for DateSerializer { - const EXPECTED_TYPE: &'static str = "date"; - - fn build( - _schema: &Bound<'_, PyDict>, - config: Option<&Bound<'_, PyDict>>, - _definitions: &mut DefinitionsBuilder, - ) -> PyResult { - let temporal_mode = TemporalMode::from_config(config)?; - Ok(Self { temporal_mode }.into()) - } -} -impl_py_gc_traverse!(DateSerializer {}); - -impl TypeSerializer for DateSerializer { - fn to_python( - &self, - value: &Bound<'_, PyAny>, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> PyResult { - match extra.mode { - SerMode::Json => match downcast_date_reject_datetime(value) { - Ok(py_value) => Ok(self.temporal_mode.date_to_json(value.py(), py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) + impl_py_gc_traverse!($Struct {}); + + impl TypeSerializer for $Struct { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + include: Option<&Bound<'_, PyAny>>, + 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) + } + } + } + _ => infer_to_python(value, include, exclude, extra), } - }, - _ => infer_to_python(value, include, exclude, extra), - } - } - - fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { - match downcast_date_reject_datetime(key) { - Ok(py_value) => Ok(self.temporal_mode.date_json_key(py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), key, extra)?; - infer_json_key(key, extra) } - } - } - fn serde_serialize( - &self, - value: &Bound<'_, PyAny>, - serializer: S, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> Result { - match downcast_date_reject_datetime(value) { - Ok(py_value) => self.temporal_mode.date_serialize(py_value, serializer), - Err(_) => { - extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; - infer_serialize(value, serializer, include, exclude, extra) + fn json_key<'a>( + &self, + key: &'a Bound<'_, PyAny>, + extra: &Extra, + ) -> PyResult> { + match $downcast(key) { + Ok(py_value) => Ok(self.temporal_mode.$json_key_fn(py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), key, extra)?; + infer_json_key(key, extra) + } + } } - } - } - - fn get_name(&self) -> &str { - Self::EXPECTED_TYPE - } -} - -#[derive(Debug)] -pub struct TimeSerializer { - temporal_mode: TemporalMode, -} - -impl BuildSerializer for TimeSerializer { - const EXPECTED_TYPE: &'static str = "time"; - fn build( - _schema: &Bound<'_, PyDict>, - config: Option<&Bound<'_, PyDict>>, - _definitions: &mut DefinitionsBuilder, - ) -> PyResult { - let temporal_mode = TemporalMode::from_config(config)?; - Ok(Self { temporal_mode }.into()) - } -} -impl_py_gc_traverse!(TimeSerializer {}); - -impl TypeSerializer for TimeSerializer { - fn to_python( - &self, - value: &Bound<'_, PyAny>, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> PyResult { - match extra.mode { - SerMode::Json => match PyAnyMethods::downcast::(value) { - Ok(py_value) => Ok(self.temporal_mode.time_to_json(value.py(), py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) + fn serde_serialize( + &self, + value: &Bound<'_, PyAny>, + serializer: S, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> Result { + match $downcast(value) { + Ok(py_value) => self.temporal_mode.$serialize_fn(py_value, serializer), + Err(_) => { + extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; + infer_serialize(value, serializer, include, exclude, extra) + } } - }, - _ => infer_to_python(value, include, exclude, extra), - } - } - - fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { - match PyAnyMethods::downcast::(key) { - Ok(py_value) => Ok(self.temporal_mode.time_json_key(py_value)?), - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), key, extra)?; - infer_json_key(key, extra) } - } - } - fn serde_serialize( - &self, - value: &Bound<'_, PyAny>, - serializer: S, - include: Option<&Bound<'_, PyAny>>, - exclude: Option<&Bound<'_, PyAny>>, - extra: &Extra, - ) -> Result { - match PyAnyMethods::downcast::(value) { - Ok(py_value) => self.temporal_mode.time_serialize(py_value, serializer), - Err(_) => { - extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; - infer_serialize(value, serializer, include, exclude, extra) + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE } } - } - - fn get_name(&self) -> &str { - Self::EXPECTED_TYPE - } -} + }; +} + +build_temporal_serializer!( + DatetimeSerializer, + "datetime", + PyAnyMethods::downcast::, + datetime_to_json, + datetime_json_key, + datetime_serialize +); + +build_temporal_serializer!( + DateSerializer, + "date", + downcast_date_reject_datetime, + date_to_json, + date_json_key, + date_serialize +); + +build_temporal_serializer!( + TimeSerializer, + "time", + PyAnyMethods::downcast::, + time_to_json, + time_json_key, + time_serialize +); From 6d2844034d3a45d51a8022f058a6adfadecac3b9 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 15:48:17 +0100 Subject: [PATCH 11/16] fix: linting --- .../type_serializers/datetime_etc.rs | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 0d10f4434..2bac4705e 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -97,26 +97,18 @@ macro_rules! build_temporal_serializer { 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) - } + 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) } - } + }, _ => infer_to_python(value, include, exclude, extra), } } - fn json_key<'a>( - &self, - key: &'a Bound<'_, PyAny>, - extra: &Extra, - ) -> PyResult> { + fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match $downcast(key) { Ok(py_value) => Ok(self.temporal_mode.$json_key_fn(py_value)?), Err(_) => { @@ -137,7 +129,9 @@ macro_rules! build_temporal_serializer { match $downcast(value) { Ok(py_value) => self.temporal_mode.$serialize_fn(py_value, serializer), Err(_) => { - extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; + extra + .warnings + .on_fallback_ser::(self.get_name(), value, extra)?; infer_serialize(value, serializer, include, exclude, extra) } } From 9bf6b8e91d4415716cb4147405c56512f86e064f Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 16:51:45 +0100 Subject: [PATCH 12/16] test: move tests, edit them a bit: --- tests/serializers/test_any.py | 382 ---------------------------- tests/serializers/test_datetime.py | 168 ++++++++++++ tests/serializers/test_timedelta.py | 275 ++++++++++++++++++++ 3 files changed, 443 insertions(+), 382 deletions(-) diff --git a/tests/serializers/test_any.py b/tests/serializers/test_any.py index a2e4db018..09d492177 100644 --- a/tests/serializers/test_any.py +++ b/tests/serializers/test_any.py @@ -722,385 +722,3 @@ class MyEnum(Enum): assert v.to_json({MyEnum.A: 'x'}) == b'{"1":"x"}' assert v.to_python(1) == 1 assert v.to_json(1) == b'1' - - -@pytest.mark.parametrize( - 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', - [ - ( - datetime(2024, 1, 1, 0, 0, 0), - '2024-01-01T00:00:00', - b'"2024-01-01T00:00:00"', - {'2024-01-01T00:00:00': 'foo'}, - b'{"2024-01-01T00:00:00":"foo"}', - 'iso8601', - ), - ( - datetime(2024, 1, 1, 0, 0, 0), - 1704067200.0, - b'1704067200.0', - {'1704067200': 'foo'}, - b'{"1704067200":"foo"}', - 'seconds', - ), - ( - datetime(2024, 1, 1, 0, 0, 0), - 1704067200000.0, - b'1704067200000.0', - {'1704067200000': 'foo'}, - b'{"1704067200000":"foo"}', - 'milliseconds', - ), - ], -) -def test_any_config_datetime( - dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode -): - s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) - assert s.to_python(dt) == dt - 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'} - assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({dt: 'foo'}) == expected_to_json_dict - - -@pytest.mark.parametrize( - 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', - [ - ( - date(2024, 1, 1), - '2024-01-01', - b'"2024-01-01"', - {'2024-01-01': 'foo'}, - b'{"2024-01-01":"foo"}', - 'iso8601', - ), - ( - date(2024, 1, 1), - 1704067200.0, - b'1704067200.0', - {'1704067200': 'foo'}, - b'{"1704067200":"foo"}', - 'seconds', - ), - ( - date(2024, 1, 1), - 1704067200000.0, - b'1704067200000.0', - {'1704067200000': 'foo'}, - b'{"1704067200000":"foo"}', - 'milliseconds', - ), - ], -) -def test_any_config_date( - dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode -): - s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) - assert s.to_python(dt) == dt - 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'} - assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({dt: 'foo'}) == expected_to_json_dict - - -@pytest.mark.parametrize( - 't,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', - [ - ( - time(3, 14, 1, 59263), - '03:14:01.059263', - b'"03:14:01.059263"', - {'03:14:01.059263': 'foo'}, - b'{"03:14:01.059263":"foo"}', - 'iso8601', - ), - ( - time(3, 14, 1, 59263), - 11641.0, - b'11641.0', - {'11641': 'foo'}, - b'{"11641":"foo"}', - 'seconds', - ), - ( - time(3, 14, 1, 59263), - 11641059.0, - b'11641059.0', - {'11641059': 'foo'}, - b'{"11641059":"foo"}', - 'milliseconds', - ), - ], -) -def test_any_config_time( - t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode -): - s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) - assert s.to_python(t) == t - 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'} - assert s.to_python({t: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({t: 'foo'}) == expected_to_json_dict - - -@pytest.mark.parametrize( - 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', - [ - (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), - ( - timedelta(hours=-2), - -7200000.0, - b'-7200000.0', - {'-7200000': 'foo'}, - b'{"-7200000":"foo"}', - 'milliseconds', - ), - (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), - (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), - (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), - ( - timedelta(microseconds=-1), - -0.001, - b'-0.001', - {'-0.001': 'foo'}, - b'{"-0.001":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=1), - 86400000.0, - b'86400000.0', - {'86400000': 'foo'}, - b'{"86400000":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=-1), - -86400000.0, - b'-86400000.0', - {'-86400000': 'foo'}, - b'{"-86400000":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=1, seconds=1), - 86401000.0, - b'86401000.0', - {'86401000': 'foo'}, - b'{"86401000":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=-1, seconds=-1), - -86401000.0, - b'-86401000.0', - {'-86401000': 'foo'}, - b'{"-86401000":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=1, seconds=-1), - 86399000.0, - b'86399000.0', - {'86399000': 'foo'}, - b'{"86399000":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=1, seconds=1, microseconds=1), - 86401000.001, - b'86401000.001', - {'86401000.001': 'foo'}, - b'{"86401000.001":"foo"}', - 'milliseconds', - ), - ( - timedelta(days=-1, seconds=-1, microseconds=-1), - -86401000.001, - b'-86401000.001', - {'-86401000.001': 'foo'}, - b'{"-86401000.001":"foo"}', - 'milliseconds', - ), - (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), - (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), - (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), - (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), - (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), - ( - timedelta(microseconds=-1), - -1e-6, - b'-1e-6', - {'-0.000001': 'foo'}, - b'{"-0.000001":"foo"}', - 'seconds', - ), - (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), - (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), - (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), - ( - timedelta(days=-1, seconds=-1), - -86401.0, - b'-86401.0', - {'-86401': 'foo'}, - b'{"-86401":"foo"}', - 'seconds', - ), - (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), - ( - timedelta(days=1, seconds=1, microseconds=1), - 86401.000001, - b'86401.000001', - {'86401.000001': 'foo'}, - b'{"86401.000001":"foo"}', - 'seconds', - ), - ( - timedelta(days=-1, seconds=-1, microseconds=-1), - -86401.000001, - b'-86401.000001', - {'-86401.000001': 'foo'}, - b'{"-86401.000001":"foo"}', - 'seconds', - ), - ], -) -def test_any_config_timedelta( - td: timedelta, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode -): - s = SchemaSerializer(core_schema.any_schema(), config={'ser_json_temporal': mode}) - 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'} - assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({td: 'foo'}) == expected_to_json_dict - - -@pytest.mark.parametrize( - 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,timedelta_mode', - [ - (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'float'), - (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'float'), - (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'float'), - (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'float'), - (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'float'), - ( - timedelta(microseconds=-1), - -1e-6, - b'-1e-6', - {'-0.000001': 'foo'}, - b'{"-0.000001":"foo"}', - 'float', - ), - (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'float'), - (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'float'), - (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'float'), - ( - timedelta(days=-1, seconds=-1), - -86401.0, - b'-86401.0', - {'-86401': 'foo'}, - b'{"-86401":"foo"}', - 'float', - ), - (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'float'), - ( - timedelta(days=1, seconds=1, microseconds=1), - 86401.000001, - b'86401.000001', - {'86401.000001': 'foo'}, - b'{"86401.000001":"foo"}', - 'float', - ), - ( - timedelta(days=-1, seconds=-1, microseconds=-1), - -86401.000001, - b'-86401.000001', - {'-86401.000001': 'foo'}, - b'{"-86401.000001":"foo"}', - 'float', - ), - (timedelta(hours=2), 'PT2H', b'"PT2H"', {'PT2H': 'foo'}, b'{"PT2H":"foo"}', 'iso8601'), - (timedelta(hours=-2), '-PT2H', b'"-PT2H"', {'-PT2H': 'foo'}, b'{"-PT2H":"foo"}', 'iso8601'), - (timedelta(seconds=1.5), 'PT1.5S', b'"PT1.5S"', {'PT1.5S': 'foo'}, b'{"PT1.5S":"foo"}', 'iso8601'), - (timedelta(seconds=-1.5), '-PT1.5S', b'"-PT1.5S"', {'-PT1.5S': 'foo'}, b'{"-PT1.5S":"foo"}', 'iso8601'), - ( - timedelta(microseconds=1), - 'PT0.000001S', - b'"PT0.000001S"', - {'PT0.000001S': 'foo'}, - b'{"PT0.000001S":"foo"}', - 'iso8601', - ), - ( - timedelta(microseconds=-1), - '-PT0.000001S', - b'"-PT0.000001S"', - {'-PT0.000001S': 'foo'}, - b'{"-PT0.000001S":"foo"}', - 'iso8601', - ), - (timedelta(days=1), 'P1D', b'"P1D"', {'P1D': 'foo'}, b'{"P1D":"foo"}', 'iso8601'), - (timedelta(days=-1), '-P1D', b'"-P1D"', {'-P1D': 'foo'}, b'{"-P1D":"foo"}', 'iso8601'), - (timedelta(days=1, seconds=1), 'P1DT1S', b'"P1DT1S"', {'P1DT1S': 'foo'}, b'{"P1DT1S":"foo"}', 'iso8601'), - ( - timedelta(days=-1, seconds=-1), - '-P1DT1S', - b'"-P1DT1S"', - {'-P1DT1S': 'foo'}, - b'{"-P1DT1S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=1, seconds=-1), - 'PT23H59M59S', - b'"PT23H59M59S"', - {'PT23H59M59S': 'foo'}, - b'{"PT23H59M59S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=1, seconds=1, microseconds=1), - 'P1DT1.000001S', - b'"P1DT1.000001S"', - {'P1DT1.000001S': 'foo'}, - b'{"P1DT1.000001S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=-1, seconds=-1, microseconds=-1), - '-P1DT1.000001S', - b'"-P1DT1.000001S"', - {'-P1DT1.000001S': 'foo'}, - b'{"-P1DT1.000001S":"foo"}', - 'iso8601', - ), - ], -) -@pytest.mark.parametrize('temporal_mode', ['iso8601', 'seconds', 'milliseconds']) -def test_any_config_timedelta_timedelta_ser_flag_prioritised( - td: timedelta, - expected_to_python, - expected_to_json, - expected_to_python_dict, - expected_to_json_dict, - temporal_mode, - timedelta_mode, -): - s = SchemaSerializer( - core_schema.any_schema(), config={'ser_json_temporal': temporal_mode, 'ser_json_timedelta': timedelta_mode} - ) - 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'} - assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict - assert s.to_json({td: 'foo'}) == expected_to_json_dict diff --git a/tests/serializers/test_datetime.py b/tests/serializers/test_datetime.py index e6f71ba0c..59b84c9d4 100644 --- a/tests/serializers/test_datetime.py +++ b/tests/serializers/test_datetime.py @@ -113,3 +113,171 @@ def test_date_datetime_union(): assert v.to_python(datetime(2022, 12, 2, 1)) == datetime(2022, 12, 2, 1) assert v.to_python(datetime(2022, 12, 2, 1), mode='json') == '2022-12-02T01:00:00' assert v.to_json(datetime(2022, 12, 2, 1)) == b'"2022-12-02T01:00:00"' + + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + datetime(2024, 1, 1, 0, 0, 0), + '2024-01-01T00:00:00', + b'"2024-01-01T00:00:00"', + {'2024-01-01T00:00:00': 'foo'}, + b'{"2024-01-01T00:00:00":"foo"}', + 'iso8601', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200.0, + b'1704067200.0', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200000.0, + b'1704067200000.0', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_datetime( + dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.datetime_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + 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'}, mode='json') == expected_to_python_dict + 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_json({dt: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + date(2024, 1, 1), + '2024-01-01', + b'"2024-01-01"', + {'2024-01-01': 'foo'}, + b'{"2024-01-01":"foo"}', + 'iso8601', + ), + ( + date(2024, 1, 1), + 1704067200.0, + b'1704067200.0', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + date(2024, 1, 1), + 1704067200000.0, + b'1704067200000.0', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_date( + dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.date_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + 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'}, mode='json') == expected_to_python_dict + 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_json({dt: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 't,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + time(3, 14, 1, 59263), + '03:14:01.059263', + b'"03:14:01.059263"', + {'03:14:01.059263': 'foo'}, + b'{"03:14:01.059263":"foo"}', + 'iso8601', + ), + ( + time(3, 14, 1, 59263), + 11641.0, + b'11641.0', + {'11641': 'foo'}, + b'{"11641":"foo"}', + 'seconds', + ), + ( + time(3, 14, 1, 59263), + 11641059.0, + b'11641059.0', + {'11641059': 'foo'}, + b'{"11641059":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_time( + t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.time_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(t) == t + 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'}, mode='json') == expected_to_python_dict + 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_json({t: 'foo'}) == expected_to_json_dict diff --git a/tests/serializers/test_timedelta.py b/tests/serializers/test_timedelta.py index b5603ee79..e5100bfb4 100644 --- a/tests/serializers/test_timedelta.py +++ b/tests/serializers/test_timedelta.py @@ -52,3 +52,278 @@ def test_pandas(): assert v.to_python(d) == d assert v.to_python(d, mode='json') == 'PT2H' assert v.to_json(d) == b'"PT2H"' + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), + ( + timedelta(hours=-2), + -7200000.0, + b'-7200000.0', + {'-7200000': 'foo'}, + b'{"-7200000":"foo"}', + 'milliseconds', + ), + (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), + (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), + (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), + ( + timedelta(microseconds=-1), + -0.001, + b'-0.001', + {'-0.001': 'foo'}, + b'{"-0.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1), + 86400000.0, + b'86400000.0', + {'86400000': 'foo'}, + b'{"86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1), + -86400000.0, + b'-86400000.0', + {'-86400000': 'foo'}, + b'{"-86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1), + 86401000.0, + b'86401000.0', + {'86401000': 'foo'}, + b'{"86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1), + -86401000.0, + b'-86401000.0', + {'-86401000': 'foo'}, + b'{"-86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=-1), + 86399000.0, + b'86399000.0', + {'86399000': 'foo'}, + b'{"86399000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401000.001, + b'86401000.001', + {'86401000.001': 'foo'}, + b'{"86401000.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401000.001, + b'-86401000.001', + {'-86401000.001': 'foo'}, + b'{"-86401000.001":"foo"}', + 'milliseconds', + ), + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'seconds', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'seconds', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'seconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'seconds', + ), + ], +) +def test_config_timedelta( + td: timedelta, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.timedelta_schema(), config={'ser_json_temporal': mode}) + 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'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + ): + assert s.to_json({td: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,timedelta_mode', + [ + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'float'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'float'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'float'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'float'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'float'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'float', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'float'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'float'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'float'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'float', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'float'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'float', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'float', + ), + (timedelta(hours=2), 'PT2H', b'"PT2H"', {'PT2H': 'foo'}, b'{"PT2H":"foo"}', 'iso8601'), + (timedelta(hours=-2), '-PT2H', b'"-PT2H"', {'-PT2H': 'foo'}, b'{"-PT2H":"foo"}', 'iso8601'), + (timedelta(seconds=1.5), 'PT1.5S', b'"PT1.5S"', {'PT1.5S': 'foo'}, b'{"PT1.5S":"foo"}', 'iso8601'), + (timedelta(seconds=-1.5), '-PT1.5S', b'"-PT1.5S"', {'-PT1.5S': 'foo'}, b'{"-PT1.5S":"foo"}', 'iso8601'), + ( + timedelta(microseconds=1), + 'PT0.000001S', + b'"PT0.000001S"', + {'PT0.000001S': 'foo'}, + b'{"PT0.000001S":"foo"}', + 'iso8601', + ), + ( + timedelta(microseconds=-1), + '-PT0.000001S', + b'"-PT0.000001S"', + {'-PT0.000001S': 'foo'}, + b'{"-PT0.000001S":"foo"}', + 'iso8601', + ), + (timedelta(days=1), 'P1D', b'"P1D"', {'P1D': 'foo'}, b'{"P1D":"foo"}', 'iso8601'), + (timedelta(days=-1), '-P1D', b'"-P1D"', {'-P1D': 'foo'}, b'{"-P1D":"foo"}', 'iso8601'), + (timedelta(days=1, seconds=1), 'P1DT1S', b'"P1DT1S"', {'P1DT1S': 'foo'}, b'{"P1DT1S":"foo"}', 'iso8601'), + ( + timedelta(days=-1, seconds=-1), + '-P1DT1S', + b'"-P1DT1S"', + {'-P1DT1S': 'foo'}, + b'{"-P1DT1S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=1, seconds=-1), + 'PT23H59M59S', + b'"PT23H59M59S"', + {'PT23H59M59S': 'foo'}, + b'{"PT23H59M59S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 'P1DT1.000001S', + b'"P1DT1.000001S"', + {'P1DT1.000001S': 'foo'}, + b'{"P1DT1.000001S":"foo"}', + 'iso8601', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + '-P1DT1.000001S', + b'"-P1DT1.000001S"', + {'-P1DT1.000001S': 'foo'}, + b'{"-P1DT1.000001S":"foo"}', + 'iso8601', + ), + ], +) +@pytest.mark.parametrize('temporal_mode', ['iso8601', 'seconds', 'milliseconds']) +def test_config_timedelta_timedelta_ser_flag_prioritised( + td: timedelta, + expected_to_python, + expected_to_json, + expected_to_python_dict, + expected_to_json_dict, + temporal_mode, + timedelta_mode, +): + s = SchemaSerializer( + core_schema.timedelta_schema(), + config={'ser_json_temporal': temporal_mode, 'ser_json_timedelta': timedelta_mode}, + ) + 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'}, mode='json') == expected_to_python_dict + 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_json({td: 'foo'}) == expected_to_json_dict From 2cdb4da47622accfc0abd37dfe210406bb5ce8ba Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 22:21:05 +0100 Subject: [PATCH 13/16] fix: implement micro second precision --- src/serializers/config.rs | 4 +-- .../type_serializers/datetime_etc.rs | 31 ++++++++++++++---- tests/serializers/test_datetime.py | 32 ++++++++++++++----- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index 3d9df8969..7dbba396f 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -336,11 +336,11 @@ impl TemporalMode { } Self::Seconds => { let s = time_to_seconds(time).map_err(py_err_se_err)?; - serializer.serialize_f32(s) + serializer.serialize_f64(s) } Self::Milliseconds => { let s = time_to_milliseconds(time).map_err(py_err_se_err)?; - serializer.serialize_f32(s) + serializer.serialize_f64(s) } } } diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 2bac4705e..c780d2fea 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -17,11 +17,23 @@ pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp() as f64) + pydatetime_as_datetime(py_dt).map(|dt| + dt.date.timestamp() as f64 + + dt.time.hour as f64 * 3600.0 + + dt.time.minute as f64 * 60.0 + + dt.time.second as f64 + + dt.time.microsecond as f64 / 1_000_000.0 + ) } pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| dt.timestamp_ms() as f64) + pydatetime_as_datetime(py_dt).map(|dt| + dt.date.timestamp_ms() as f64 + + dt.time.hour as f64 * 3_600_000.0 + + dt.time.minute as f64 * 60_000.0 + + dt.time.second as f64 * 1_000.0 + + dt.time.microsecond as f64 / 1_000.0 + ) } pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { @@ -39,12 +51,19 @@ pub(crate) fn time_to_string(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|dt| dt.to_string()) } -pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { - pytime_as_time(py_time, None).map(|t| t.total_seconds() as f32) +pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| { + t.hour as f64 * 3600.0 + t.minute as f64 * 60.0 + t.second as f64 + t.microsecond as f64 / 1_000_000.0 + }) } -pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { - pytime_as_time(py_time, None).map(|t| t.total_ms() as f32) +pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| { + t.hour as f64 * 3_600_000.0 + + t.minute as f64 * 60_000.0 + + t.second as f64 * 1_000.0 + + t.microsecond as f64 / 1_000.0 + }) } fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyResult<&'a Bound<'py, PyDate>> { diff --git a/tests/serializers/test_datetime.py b/tests/serializers/test_datetime.py index 59b84c9d4..6d5bf29b1 100644 --- a/tests/serializers/test_datetime.py +++ b/tests/serializers/test_datetime.py @@ -142,6 +142,22 @@ def test_date_datetime_union(): b'{"1704067200000":"foo"}', 'milliseconds', ), + ( + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861.000023, + b'1704070861.000023', + {'1704070861.000023': 'foo'}, + b'{"1704070861.000023":"foo"}', + 'seconds', + ), + ( + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861000.023, + b'1704070861000.023', + {'1704070861000.023': 'foo'}, + b'{"1704070861000.023":"foo"}', + 'milliseconds', + ), ], ) def test_config_datetime( @@ -240,18 +256,18 @@ def test_config_date( ), ( time(3, 14, 1, 59263), - 11641.0, - b'11641.0', - {'11641': 'foo'}, - b'{"11641":"foo"}', + 11641.059263, + b'11641.059263', + {'11641.059263': 'foo'}, + b'{"11641.059263":"foo"}', 'seconds', ), ( time(3, 14, 1, 59263), - 11641059.0, - b'11641059.0', - {'11641059': 'foo'}, - b'{"11641059":"foo"}', + 11641059.263, + b'11641059.263', + {'11641059.263': 'foo'}, + b'{"11641059.263":"foo"}', 'milliseconds', ), ], From f856433d329794fd8c151b03263be3165a7606fb Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 22:41:32 +0100 Subject: [PATCH 14/16] style: formatting --- .../type_serializers/datetime_etc.rs | 37 ++++++++++--------- tests/serializers/test_datetime.py | 24 ++++++------ 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index c780d2fea..84c712d6a 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -17,23 +17,23 @@ pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| + pydatetime_as_datetime(py_dt).map(|dt| { dt.date.timestamp() as f64 - + dt.time.hour as f64 * 3600.0 - + dt.time.minute as f64 * 60.0 - + dt.time.second as f64 - + dt.time.microsecond as f64 / 1_000_000.0 - ) + + f64::from(dt.time.hour) * 3600.0 + + f64::from(dt.time.minute) * 60.0 + + f64::from(dt.time.second) + + f64::from(dt.time.microsecond) / 1_000_000.0 + }) } pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { - pydatetime_as_datetime(py_dt).map(|dt| + pydatetime_as_datetime(py_dt).map(|dt| { dt.date.timestamp_ms() as f64 - + dt.time.hour as f64 * 3_600_000.0 - + dt.time.minute as f64 * 60_000.0 - + dt.time.second as f64 * 1_000.0 - + dt.time.microsecond as f64 / 1_000.0 - ) + + f64::from(dt.time.hour) * 3_600_000.0 + + f64::from(dt.time.minute) * 60_000.0 + + f64::from(dt.time.second) * 1_000.0 + + f64::from(dt.time.microsecond) / 1_000.0 + }) } pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { @@ -53,16 +53,19 @@ pub(crate) fn time_to_string(py_time: &Bound<'_, PyTime>) -> PyResult { pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|t| { - t.hour as f64 * 3600.0 + t.minute as f64 * 60.0 + t.second as f64 + t.microsecond as f64 / 1_000_000.0 + f64::from(t.hour) * 3600.0 + + f64::from(t.minute) * 60.0 + + f64::from(t.second) + + f64::from(t.microsecond) / 1_000_000.0 }) } pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|t| { - t.hour as f64 * 3_600_000.0 - + t.minute as f64 * 60_000.0 - + t.second as f64 * 1_000.0 - + t.microsecond as f64 / 1_000.0 + f64::from(t.hour) * 3_600_000.0 + + f64::from(t.minute) * 60_000.0 + + f64::from(t.second) * 1_000.0 + + f64::from(t.microsecond) / 1_000.0 }) } diff --git a/tests/serializers/test_datetime.py b/tests/serializers/test_datetime.py index 6d5bf29b1..88edb205a 100644 --- a/tests/serializers/test_datetime.py +++ b/tests/serializers/test_datetime.py @@ -143,20 +143,20 @@ def test_date_datetime_union(): 'milliseconds', ), ( - datetime(2024, 1, 1, 1, 1, 1, 23), - 1704070861.000023, - b'1704070861.000023', - {'1704070861.000023': 'foo'}, - b'{"1704070861.000023":"foo"}', - 'seconds', + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861.000023, + b'1704070861.000023', + {'1704070861.000023': 'foo'}, + b'{"1704070861.000023":"foo"}', + 'seconds', ), ( - datetime(2024, 1, 1, 1, 1, 1, 23), - 1704070861000.023, - b'1704070861000.023', - {'1704070861000.023': 'foo'}, - b'{"1704070861000.023":"foo"}', - 'milliseconds', + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861000.023, + b'1704070861000.023', + {'1704070861000.023': 'foo'}, + b'{"1704070861000.023":"foo"}', + 'milliseconds', ), ], ) From 52b57999652a3fdcecf23180d776be5ea1b477d5 Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Fri, 20 Jun 2025 23:02:36 +0100 Subject: [PATCH 15/16] fix: rename old name --- src/serializers/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serializers/config.rs b/src/serializers/config.rs index 7dbba396f..ed9717a7d 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -49,13 +49,13 @@ impl SerializationConfig { pub fn from_args( timedelta_mode: &str, - datetime_mode: &str, + temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str, ) -> PyResult { Ok(Self { timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, - temporal_mode: TemporalMode::from_str(datetime_mode)?, + temporal_mode: TemporalMode::from_str(temporal_mode)?, prefer_timedelta_mode: true, // This is not settable via args bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, From efbd0aebd13f9a0279053de8ed3f2f84cc13099b Mon Sep 17 00:00:00 2001 From: Oliver Parker Date: Sat, 21 Jun 2025 11:23:02 +0100 Subject: [PATCH 16/16] fix: tidy up, docs, etc. Might be almost finished --- python/pydantic_core/_pydantic_core.pyi | 10 +- python/pydantic_core/core_schema.py | 4 + src/serializers/config.rs | 92 +++------- src/serializers/infer.rs | 25 +-- src/serializers/type_serializers/timedelta.rs | 60 ++----- tests/serializers/test_timedelta.py | 161 ++++++++++-------- 6 files changed, 136 insertions(+), 216 deletions(-) diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 97839c938..dceddbf99 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -426,8 +426,9 @@ def to_json( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. - temporal_mode: How to serialize datetime like objects, either - `'iso8601'` or `'seconds'` or `'milliseconds'`. + temporal_mode: How to serialize datetime-like objects (`datetime`, `date`, `time`), either `'iso8601'`, `'seconds'`, or `'milliseconds'`. + `iso8601` returns an ISO 8601 string; `seconds` returns the Unix timestamp in seconds as a float; `milliseconds` returns the Unix timestamp in milliseconds as a float. + bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. 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 @@ -508,8 +509,9 @@ def to_jsonable_python( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. - temporal_mode: How to serialize datetime like objects, either - `'iso8601'` or `'seconds'` or `'milliseconds'`. + temporal_mode: How to serialize datetime-like objects (`datetime`, `date`, `time`), either `'iso8601'`, `'seconds'`, or `'milliseconds'`. + `iso8601` returns an ISO 8601 string; `seconds` returns the Unix timestamp in seconds as a float; `milliseconds` returns the Unix timestamp in milliseconds as a float. + bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. 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 diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index c14e1c33b..ea33f32c5 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -61,6 +61,10 @@ class CoreConfig(TypedDict, total=False): str_to_upper: Whether to convert string fields to uppercase. allow_inf_nan: Whether to allow infinity and NaN values for float fields. Default is `True`. ser_json_timedelta: The serialization option for `timedelta` values. Default is 'iso8601'. + Note that if ser_json_temporal is set, then this param will be ignored. + ser_json_temporal: The serialization option for datetime like values. Default is 'iso8601'. + The types this covers are datetime, date, time and timedelta. + If this is set, it will take precedence over ser_json_timedelta ser_json_bytes: The serialization option for `bytes` values. Default is 'utf8'. ser_json_inf_nan: The serialization option for infinity and NaN values in float fields. Default is 'null'. diff --git a/src/serializers/config.rs b/src/serializers/config.rs index ed9717a7d..61e5b0697 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -3,7 +3,7 @@ use std::str::{from_utf8, FromStr, Utf8Error}; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDict, PyString, PyTime}; +use pyo3::types::{PyDate, PyDateTime, PyDict, PyString, PyTime}; use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; @@ -14,7 +14,6 @@ use crate::serializers::type_serializers::datetime_etc::{ date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, datetime_to_string, time_to_milliseconds, time_to_seconds, time_to_string, }; -use crate::serializers::type_serializers::timedelta::EffectiveDeltaMode; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -22,26 +21,25 @@ use super::errors::py_err_se_err; #[derive(Debug, Clone)] #[allow(clippy::struct_field_names)] pub(crate) struct SerializationConfig { - pub timedelta_mode: TimedeltaMode, pub temporal_mode: TemporalMode, - prefer_timedelta_mode: bool, pub bytes_mode: BytesMode, pub inf_nan_mode: InfNanMode, } impl SerializationConfig { pub fn from_config(config: Option<&Bound<'_, PyDict>>) -> PyResult { - let timedelta_mode = TimedeltaMode::from_config(config)?; - let temporal_mode = TemporalMode::from_config(config)?; - let prefer_timedelta_mode = config - .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_timedelta")).ok()) + let temporal_set = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_temporal")).ok()) .unwrap_or(false); + let temporal_mode = if temporal_set { + TemporalMode::from_config(config)? + } else { + TimedeltaMode::from_config(config)?.into() + }; let bytes_mode = BytesMode::from_config(config)?; let inf_nan_mode = InfNanMode::from_config(config)?; Ok(Self { - timedelta_mode, temporal_mode, - prefer_timedelta_mode, bytes_mode, inf_nan_mode, }) @@ -53,22 +51,17 @@ impl SerializationConfig { bytes_mode: &str, inf_nan_mode: &str, ) -> PyResult { + let resolved_temporal_mode = if temporal_mode != "iso8601" { + TemporalMode::from_str(temporal_mode)? + } else { + TimedeltaMode::from_str(timedelta_mode)?.into() + }; Ok(Self { - timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, - temporal_mode: TemporalMode::from_str(temporal_mode)?, - prefer_timedelta_mode: true, // This is not settable via args + temporal_mode: resolved_temporal_mode, bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, }) } - - pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { - if self.prefer_timedelta_mode { - EffectiveDeltaMode::Timedelta(self.timedelta_mode) - } else { - EffectiveDeltaMode::Temporal(self.temporal_mode) - } - } } pub trait FromConfig { @@ -143,58 +136,13 @@ serialization_mode! { Strings => "strings", } -impl TimedeltaMode { - fn total_seconds<'py>(py_timedelta: &Bound<'py, PyDelta>) -> PyResult> { - py_timedelta.call_method0(intern!(py_timedelta.py(), "total_seconds")) - } - - pub fn either_delta_to_json(self, py: Python, either_delta: EitherTimedelta) -> PyResult { - match self { - Self::Iso8601 => { - let d = either_delta.to_duration()?; - d.to_string().into_py_any(py) - } - Self::Float => { - // convert to int via a py timedelta not duration since we know this this case the input would have - // been a py timedelta - let py_timedelta = either_delta.into_pyobject(py)?; - let seconds = Self::total_seconds(&py_timedelta)?; - Ok(seconds.unbind()) - } - } - } - - pub fn json_key<'py>(self, py: Python, either_delta: EitherTimedelta) -> PyResult> { - match self { - Self::Iso8601 => { - let d = either_delta.to_duration()?; - Ok(d.to_string().into()) - } - Self::Float => { - let py_timedelta = either_delta.into_pyobject(py)?; - let seconds: f64 = Self::total_seconds(&py_timedelta)?.extract()?; - Ok(seconds.to_string().into()) - } - } - } +impl TimedeltaMode {} - pub fn timedelta_serialize( - self, - py: Python, - either_delta: EitherTimedelta, - serializer: S, - ) -> Result { - match self { - Self::Iso8601 => { - let d = either_delta.to_duration().map_err(py_err_se_err)?; - serializer.serialize_str(&d.to_string()) - } - Self::Float => { - let py_timedelta = either_delta.into_pyobject(py).map_err(py_err_se_err)?; - let seconds = Self::total_seconds(&py_timedelta).map_err(py_err_se_err)?; - let seconds: f64 = seconds.extract().map_err(py_err_se_err)?; - serializer.serialize_f64(seconds) - } +impl From for TemporalMode { + fn from(value: TimedeltaMode) -> Self { + match value { + TimedeltaMode::Iso8601 => TemporalMode::Iso8601, + TimedeltaMode::Float => TemporalMode::Seconds, } } } diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index 0b970788b..285c2f99a 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -12,7 +12,6 @@ use serde::ser::{Error, Serialize, SerializeMap, SerializeSeq, Serializer}; use crate::input::{EitherTimedelta, Int}; use crate::serializers::type_serializers; -use crate::serializers::type_serializers::timedelta::EffectiveDeltaMode; use crate::tools::{extract_int, py_err, safe_repr}; use crate::url::{PyMultiHostUrl, PyUrl}; @@ -193,15 +192,7 @@ pub(crate) fn infer_to_python_known( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value)?; - let x = match extra.config.effective_delta_mode() { - EffectiveDeltaMode::Temporal(temporal_mode) => { - temporal_mode.timedelta_to_json(value.py(), either_delta) - } - EffectiveDeltaMode::Timedelta(timedelta_mode) => { - timedelta_mode.either_delta_to_json(value.py(), either_delta) - } - }?; - x + extra.config.temporal_mode.timedelta_to_json(value.py(), either_delta)? } ObType::Url => { let py_url: PyUrl = value.extract()?; @@ -480,14 +471,7 @@ pub(crate) fn infer_serialize_known( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value).map_err(py_err_se_err)?; - match extra.config.effective_delta_mode() { - EffectiveDeltaMode::Temporal(temporal_mode) => { - temporal_mode.timedelta_serialize(either_delta, serializer) - } - EffectiveDeltaMode::Timedelta(timedelta_mode) => { - timedelta_mode.timedelta_serialize(value.py(), either_delta, serializer) - } - } + extra.config.temporal_mode.timedelta_serialize(either_delta, serializer) } ObType::Url => { let py_url: PyUrl = value.extract().map_err(py_err_se_err)?; @@ -654,10 +638,7 @@ pub(crate) fn infer_json_key_known<'a>( } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(key)?; - match extra.config.effective_delta_mode() { - EffectiveDeltaMode::Temporal(temporal_mode) => temporal_mode.timedelta_json_key(&either_delta), - EffectiveDeltaMode::Timedelta(timedelta_mode) => timedelta_mode.json_key(key.py(), either_delta), - } + extra.config.temporal_mode.timedelta_json_key(&either_delta) } ObType::Url => { let py_url: PyUrl = key.extract()?; diff --git a/src/serializers/type_serializers/timedelta.rs b/src/serializers/type_serializers/timedelta.rs index d970cf9ff..2cac5345f 100644 --- a/src/serializers/type_serializers/timedelta.rs +++ b/src/serializers/type_serializers/timedelta.rs @@ -14,14 +14,7 @@ use super::{ #[derive(Debug)] pub struct TimeDeltaSerializer { - timedelta_mode: TimedeltaMode, temporal_mode: TemporalMode, - prefer_timedelta: bool, -} - -pub enum EffectiveDeltaMode { - Timedelta(TimedeltaMode), - Temporal(TemporalMode), } impl BuildSerializer for TimeDeltaSerializer { @@ -32,29 +25,17 @@ impl BuildSerializer for TimeDeltaSerializer { config: Option<&Bound<'_, PyDict>>, _definitions: &mut DefinitionsBuilder, ) -> PyResult { - let timedelta_mode = TimedeltaMode::from_config(config)?; - let temporal_mode = TemporalMode::from_config(config)?; - - let prefer_timedelta_mode = config - .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_timedelta")).ok()) + let temporal_set = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_temporal")).ok()) .unwrap_or(false); - - Ok(Self { - timedelta_mode, - temporal_mode, - prefer_timedelta: prefer_timedelta_mode, - } - .into()) - } -} - -impl TimeDeltaSerializer { - pub fn effective_delta_mode(&self) -> EffectiveDeltaMode { - if self.prefer_timedelta { - EffectiveDeltaMode::Timedelta(self.timedelta_mode) + let temporal_mode = if temporal_set { + TemporalMode::from_config(config)? } else { - EffectiveDeltaMode::Temporal(self.temporal_mode) - } + let td_mode = TimedeltaMode::from_config(config)?; + td_mode.into() + }; + + Ok(Self { temporal_mode }.into()) } } @@ -70,14 +51,7 @@ impl TypeSerializer for TimeDeltaSerializer { ) -> PyResult { match extra.mode { SerMode::Json => match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => match self.effective_delta_mode() { - EffectiveDeltaMode::Timedelta(timedelta_mode) => { - Ok(timedelta_mode.either_delta_to_json(value.py(), either_timedelta)?) - } - EffectiveDeltaMode::Temporal(temporal_mode) => { - Ok(temporal_mode.timedelta_to_json(value.py(), either_timedelta)?) - } - }, + 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) @@ -89,10 +63,7 @@ impl TypeSerializer for TimeDeltaSerializer { fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match EitherTimedelta::try_from(key) { - Ok(either_timedelta) => match self.effective_delta_mode() { - EffectiveDeltaMode::Timedelta(timedelta_mode) => timedelta_mode.json_key(key.py(), either_timedelta), - EffectiveDeltaMode::Temporal(temporal_mode) => temporal_mode.timedelta_json_key(&either_timedelta), - }, + Ok(either_timedelta) => self.temporal_mode.timedelta_json_key(&either_timedelta), Err(_) => { extra.warnings.on_fallback_py(self.get_name(), key, extra)?; infer_json_key(key, extra) @@ -109,14 +80,7 @@ impl TypeSerializer for TimeDeltaSerializer { extra: &Extra, ) -> Result { match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => match self.effective_delta_mode() { - EffectiveDeltaMode::Timedelta(timedelta_mode) => { - timedelta_mode.timedelta_serialize(value.py(), either_timedelta, serializer) - } - EffectiveDeltaMode::Temporal(temporal_mode) => { - temporal_mode.timedelta_serialize(either_timedelta, serializer) - } - }, + Ok(either_timedelta) => self.temporal_mode.timedelta_serialize(either_timedelta, serializer), Err(_) => { extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; infer_serialize(value, serializer, include, exclude, extra) diff --git a/tests/serializers/test_timedelta.py b/tests/serializers/test_timedelta.py index e5100bfb4..820c478dc 100644 --- a/tests/serializers/test_timedelta.py +++ b/tests/serializers/test_timedelta.py @@ -193,40 +193,116 @@ def test_config_timedelta( @pytest.mark.parametrize( - 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,timedelta_mode', + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,temporal_mode', [ - (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'float'), - (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'float'), - (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'float'), - (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'float'), - (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'float'), + (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), + ( + timedelta(hours=-2), + -7200000.0, + b'-7200000.0', + {'-7200000': 'foo'}, + b'{"-7200000":"foo"}', + 'milliseconds', + ), + (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), + (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), + (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), + ( + timedelta(microseconds=-1), + -0.001, + b'-0.001', + {'-0.001': 'foo'}, + b'{"-0.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1), + 86400000.0, + b'86400000.0', + {'86400000': 'foo'}, + b'{"86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1), + -86400000.0, + b'-86400000.0', + {'-86400000': 'foo'}, + b'{"-86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1), + 86401000.0, + b'86401000.0', + {'86401000': 'foo'}, + b'{"86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1), + -86401000.0, + b'-86401000.0', + {'-86401000': 'foo'}, + b'{"-86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=-1), + 86399000.0, + b'86399000.0', + {'86399000': 'foo'}, + b'{"86399000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401000.001, + b'86401000.001', + {'86401000.001': 'foo'}, + b'{"86401000.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401000.001, + b'-86401000.001', + {'-86401000.001': 'foo'}, + b'{"-86401000.001":"foo"}', + 'milliseconds', + ), + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), ( timedelta(microseconds=-1), -1e-6, b'-1e-6', {'-0.000001': 'foo'}, b'{"-0.000001":"foo"}', - 'float', + 'seconds', ), - (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'float'), - (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'float'), - (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'float'), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), ( timedelta(days=-1, seconds=-1), -86401.0, b'-86401.0', {'-86401': 'foo'}, b'{"-86401":"foo"}', - 'float', + 'seconds', ), - (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'float'), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), ( timedelta(days=1, seconds=1, microseconds=1), 86401.000001, b'86401.000001', {'86401.000001': 'foo'}, b'{"86401.000001":"foo"}', - 'float', + 'seconds', ), ( timedelta(days=-1, seconds=-1, microseconds=-1), @@ -234,66 +310,11 @@ def test_config_timedelta( b'-86401.000001', {'-86401.000001': 'foo'}, b'{"-86401.000001":"foo"}', - 'float', - ), - (timedelta(hours=2), 'PT2H', b'"PT2H"', {'PT2H': 'foo'}, b'{"PT2H":"foo"}', 'iso8601'), - (timedelta(hours=-2), '-PT2H', b'"-PT2H"', {'-PT2H': 'foo'}, b'{"-PT2H":"foo"}', 'iso8601'), - (timedelta(seconds=1.5), 'PT1.5S', b'"PT1.5S"', {'PT1.5S': 'foo'}, b'{"PT1.5S":"foo"}', 'iso8601'), - (timedelta(seconds=-1.5), '-PT1.5S', b'"-PT1.5S"', {'-PT1.5S': 'foo'}, b'{"-PT1.5S":"foo"}', 'iso8601'), - ( - timedelta(microseconds=1), - 'PT0.000001S', - b'"PT0.000001S"', - {'PT0.000001S': 'foo'}, - b'{"PT0.000001S":"foo"}', - 'iso8601', - ), - ( - timedelta(microseconds=-1), - '-PT0.000001S', - b'"-PT0.000001S"', - {'-PT0.000001S': 'foo'}, - b'{"-PT0.000001S":"foo"}', - 'iso8601', - ), - (timedelta(days=1), 'P1D', b'"P1D"', {'P1D': 'foo'}, b'{"P1D":"foo"}', 'iso8601'), - (timedelta(days=-1), '-P1D', b'"-P1D"', {'-P1D': 'foo'}, b'{"-P1D":"foo"}', 'iso8601'), - (timedelta(days=1, seconds=1), 'P1DT1S', b'"P1DT1S"', {'P1DT1S': 'foo'}, b'{"P1DT1S":"foo"}', 'iso8601'), - ( - timedelta(days=-1, seconds=-1), - '-P1DT1S', - b'"-P1DT1S"', - {'-P1DT1S': 'foo'}, - b'{"-P1DT1S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=1, seconds=-1), - 'PT23H59M59S', - b'"PT23H59M59S"', - {'PT23H59M59S': 'foo'}, - b'{"PT23H59M59S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=1, seconds=1, microseconds=1), - 'P1DT1.000001S', - b'"P1DT1.000001S"', - {'P1DT1.000001S': 'foo'}, - b'{"P1DT1.000001S":"foo"}', - 'iso8601', - ), - ( - timedelta(days=-1, seconds=-1, microseconds=-1), - '-P1DT1.000001S', - b'"-P1DT1.000001S"', - {'-P1DT1.000001S': 'foo'}, - b'{"-P1DT1.000001S":"foo"}', - 'iso8601', + 'seconds', ), ], ) -@pytest.mark.parametrize('temporal_mode', ['iso8601', 'seconds', 'milliseconds']) +@pytest.mark.parametrize('timedelta_mode', ['iso8601', 'float']) def test_config_timedelta_timedelta_ser_flag_prioritised( td: timedelta, expected_to_python,