From b46fd16f571957c9a62b6e786ffa3ec8f44a437e Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Tue, 7 Oct 2025 18:05:34 +0200 Subject: [PATCH 01/12] WIP: add fraction --- python/pydantic_core/core_schema.py | 56 +++++++++++++ src/serializers/infer.rs | 33 +++++++- src/serializers/ob_type.rs | 40 ++++++++- src/serializers/shared.rs | 2 + src/serializers/type_serializers/fraction.rs | 86 ++++++++++++++++++++ src/serializers/type_serializers/mod.rs | 1 + 6 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/serializers/type_serializers/fraction.rs diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 0ba056555..0951d1d32 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -809,6 +809,62 @@ def decimal_schema( serialization=serialization, ) +def fraction_schema( + *, + allow_inf_nan: bool | None = None, + multiple_of: Fraction | None = None, + le: Fraction | None = None, + ge: Fraction | None = None, + lt: Fraction | None = None, + gt: Fraction | None = None, + max_digits: int | None = None, + decimal_places: int | None = None, + strict: bool | None = None, + ref: str | None = None, + metadata: dict[str, Any] | None = None, + serialization: SerSchema | None = None, +) -> FractionSchema: + """ + Returns a schema that matches a decimal value, e.g.: + + ```py + from fractions import Fraction + from pydantic_core import SchemaValidator, core_schema + + schema = core_schema.fraction_schema(le=0.8, ge=0.2) + v = SchemaValidator(schema) + assert v.validate_python(1, 2) == Fraction(1, 2) + ``` + + Args: + allow_inf_nan: Whether to allow inf and nan values + multiple_of: The value must be a multiple of this number + le: The value must be less than or equal to this number + ge: The value must be greater than or equal to this number + lt: The value must be strictly less than this number + gt: The value must be strictly greater than this number + max_digits: The maximum number of decimal digits allowed + decimal_places: The maximum number of decimal places allowed + strict: Whether the value should be a float or a value that can be converted to a float + ref: optional unique identifier of the schema, used to reference the schema in other places + metadata: Any other information you want to include with the schema, not used by pydantic-core + serialization: Custom serialization schema + """ + return _dict_not_none( + type='fraction', + gt=gt, + ge=ge, + lt=lt, + le=le, + max_digits=max_digits, + decimal_places=decimal_places, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + strict=strict, + ref=ref, + metadata=metadata, + serialization=serialization, + ) class ComplexSchema(TypedDict, total=False): type: Required[Literal['complex']] diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index ee2a788a7..cda5496e7 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -136,7 +136,16 @@ pub(crate) fn infer_to_python_known( } v.into_py_any(py)? } - ObType::Decimal => value.to_string().into_py_any(py)?, + ObType::Decimal => { + // todo: delete before PR ready + println!("[RUST] infer_to_python_known - SerMode::Json - serializing ObType::Decimal"); + value.to_string().into_py_any(py)? + }, + ObType::Fraction => { + // todo: delete before PR ready + println!("[RUST] infer_to_python_known - SerMode::Json - serializing ObType::Fraction"); + value.to_string().into_py_any(py)? + }, ObType::StrSubclass => PyString::new(py, value.downcast::()?.to_str()?).into(), ObType::Bytes => extra .config @@ -430,7 +439,16 @@ pub(crate) fn infer_serialize_known( let v = value.extract::().map_err(py_err_se_err)?; type_serializers::float::serialize_f64(v, serializer, extra.config.inf_nan_mode) } - ObType::Decimal => value.to_string().serialize(serializer), + ObType::Decimal => { + // todo: delete before PR ready + println!("[RUST] infer_serialize_known - serializing ObType::Decimal"); + value.to_string().serialize(serializer) + }, + ObType::Fraction => { + // todo: delete before PR ready + println!("[RUST] infer_serialize_known - serializing ObType::Fraction"); + value.to_string().serialize(serializer) + }, ObType::Str | ObType::StrSubclass => { let py_str = value.downcast::().map_err(py_err_se_err)?; super::type_serializers::string::serialize_py_str(py_str, serializer) @@ -612,7 +630,16 @@ pub(crate) fn infer_json_key_known<'a>( super::type_serializers::simple::to_str_json_key(key) } } - ObType::Decimal => Ok(Cow::Owned(key.to_string())), + ObType::Decimal => { + // todo: delete before PR ready + println!("[RUST] infer_json_key_known - converting ObType::Decimal to json key"); + Ok(Cow::Owned(key.to_string())) + }, + ObType::Fraction => { + // todo: delete before PR ready + println!("[RUST] infer_json_key_known - converting ObType::Fraction to json key"); + Ok(Cow::Owned(key.to_string())) + }, ObType::Bool => super::type_serializers::simple::bool_json_key(key), ObType::Str | ObType::StrSubclass => key.downcast::()?.to_cow(), ObType::Bytes => extra diff --git a/src/serializers/ob_type.rs b/src/serializers/ob_type.rs index f1c161dfb..e14c35ffb 100644 --- a/src/serializers/ob_type.rs +++ b/src/serializers/ob_type.rs @@ -23,6 +23,7 @@ pub struct ObTypeLookup { dict: usize, // other numeric types decimal_object: Py, + fraction_object: Py, // other string types bytes: usize, bytearray: usize, @@ -62,6 +63,8 @@ pub enum IsType { impl ObTypeLookup { fn new(py: Python) -> Self { + // todo: delete before PR ready + println!("[RUST] ObTypeLookup::new"); Self { none: PyNone::type_object_raw(py) as usize, int: PyInt::type_object_raw(py) as usize, @@ -69,7 +72,16 @@ impl ObTypeLookup { float: PyFloat::type_object_raw(py) as usize, list: PyList::type_object_raw(py) as usize, dict: PyDict::type_object_raw(py) as usize, - decimal_object: py.import("decimal").unwrap().getattr("Decimal").unwrap().unbind(), + decimal_object: { + // todo: delete before PR ready + println!("[RUST] ObTypeLookup::new - loading decimal_object"); + py.import("decimal").unwrap().getattr("Decimal").unwrap().unbind() + }, + fraction_object: { + // todo: delete before PR ready + println!("[RUST] ObTypeLookup::new - loading fraction_object"); + py.import("fractions").unwrap().getattr("Fraction").unwrap().unbind() + }, string: PyString::type_object_raw(py) as usize, bytes: PyBytes::type_object_raw(py) as usize, bytearray: PyByteArray::type_object_raw(py) as usize, @@ -96,6 +108,7 @@ impl ObTypeLookup { } pub fn is_type(&self, value: &Bound<'_, PyAny>, expected_ob_type: ObType) -> IsType { + println!("[RUST] is_type - expected_ob_type: {expected_ob_type}"); match self.ob_type_is_expected(Some(value), &value.get_type(), &expected_ob_type) { IsType::False => { if expected_ob_type == self.fallback_isinstance(value) { @@ -116,6 +129,7 @@ impl ObTypeLookup { ) -> IsType { let type_ptr = py_type.as_ptr(); let ob_type = type_ptr as usize; + println!("[RUST] ob_type_is_expected - ob_type: {ob_type}, expected_ob_type: {expected_ob_type}"); let ans = match expected_ob_type { ObType::None => self.none == ob_type, ObType::Int => self.int == ob_type, @@ -137,7 +151,16 @@ impl ObTypeLookup { ObType::Str => self.string == ob_type, ObType::List => self.list == ob_type, ObType::Dict => self.dict == ob_type, - ObType::Decimal => self.decimal_object.as_ptr() as usize == ob_type, + ObType::Decimal => { + // todo: delete before PR ready + println!("[RUST] ob_type_is_expected - checking ObType::Decimal"); + self.decimal_object.as_ptr() as usize == ob_type + }, + ObType::Fraction => { + // todo: delete before PR ready + println!("[RUST] ob_type_is_expected - checking ObType::Fraction"); + self.fraction_object.as_ptr() as usize == ob_type + }, ObType::StrSubclass => self.string == ob_type && op_value.is_none(), ObType::Tuple => self.tuple == ob_type, ObType::Set => self.set == ob_type, @@ -214,7 +237,13 @@ impl ObTypeLookup { } else if ob_type == self.dict { ObType::Dict } else if ob_type == self.decimal_object.as_ptr() as usize { + // todo: delete before PR ready + println!("[RUST] lookup_by_ob_type - found ObType::Decimal"); ObType::Decimal + } else if ob_type == self.fraction_object.as_ptr() as usize { + // todo: delete before PR ready + println!("[RUST] lookup_by_ob_type - found ObType::Fraction"); + ObType::Fraction } else if ob_type == self.bytes { ObType::Bytes } else if ob_type == self.tuple { @@ -322,7 +351,13 @@ impl ObTypeLookup { } else if value.is_instance_of::() { ObType::MultiHostUrl } else if value.is_instance(self.decimal_object.bind(py)).unwrap_or(false) { + // todo: delete before PR ready + println!("[RUST] fallback_isinstance - found ObType::Decimal"); ObType::Decimal + } else if value.is_instance(self.fraction_object.bind(py)).unwrap_or(false) { + // todo: delete before PR ready + println!("[RUST] fallback_isinstance - found ObType::Fraction"); + ObType::Fraction } else if value.is_instance(self.uuid_object.bind(py)).unwrap_or(false) { ObType::Uuid } else if value.is_instance(self.enum_object.bind(py)).unwrap_or(false) { @@ -380,6 +415,7 @@ pub enum ObType { Float, FloatSubclass, Decimal, + Fraction, // string types Str, StrSubclass, diff --git a/src/serializers/shared.rs b/src/serializers/shared.rs index 01971d480..6e3c5bfcc 100644 --- a/src/serializers/shared.rs +++ b/src/serializers/shared.rs @@ -118,6 +118,7 @@ combined_serializer! { Bool: super::type_serializers::simple::BoolSerializer; Float: super::type_serializers::float::FloatSerializer; Decimal: super::type_serializers::decimal::DecimalSerializer; + Fraction: super::type_serializers::fraction::FractionSerializer; Str: super::type_serializers::string::StrSerializer; Bytes: super::type_serializers::bytes::BytesSerializer; Datetime: super::type_serializers::datetime_etc::DatetimeSerializer; @@ -321,6 +322,7 @@ impl PyGcTraverse for CombinedSerializer { CombinedSerializer::Bool(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Float(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Decimal(inner) => inner.py_gc_traverse(visit), + CombinedSerializer::Fraction(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Str(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Bytes(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Datetime(inner) => inner.py_gc_traverse(visit), diff --git a/src/serializers/type_serializers/fraction.rs b/src/serializers/type_serializers/fraction.rs new file mode 100644 index 000000000..7bf9ccedb --- /dev/null +++ b/src/serializers/type_serializers/fraction.rs @@ -0,0 +1,86 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::build_tools::LazyLock; +use crate::definitions::DefinitionsBuilder; +use crate::serializers::infer::{infer_json_key_known, infer_serialize_known, infer_to_python_known}; +use crate::serializers::ob_type::{IsType, ObType}; + +use super::{ + infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, TypeSerializer, +}; + +#[derive(Debug)] +pub struct FractionSerializer {} + +static FRACTION_SERIALIZER: LazyLock> = LazyLock::new(|| Arc::new(FractionSerializer {}.into())); + +impl BuildSerializer for FractionSerializer { + const EXPECTED_TYPE: &'static str = "decimal"; + + fn build( + _schema: &Bound<'_, PyDict>, + _config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder>, + ) -> PyResult> { + Ok(FRACTION_SERIALIZER.clone()) + } +} + +impl_py_gc_traverse!(FractionSerializer {}); + +impl TypeSerializer for FractionSerializer { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + include: Option<&Bound<'_, PyAny>>, + exclude: Option<&Bound<'_, PyAny>>, + extra: &Extra, + ) -> PyResult> { + let _py = value.py(); + println!("[RUST] FractionSerializer to_python called"); + match extra.ob_type_lookup.is_type(value, ObType::Fraction) { + IsType::Exact | IsType::Subclass => infer_to_python_known(ObType::Fraction, value, include, exclude, extra), + IsType::False => { + 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 extra.ob_type_lookup.is_type(key, ObType::Fraction) { + IsType::Exact | IsType::Subclass => infer_json_key_known(ObType::Fraction, key, extra), + IsType::False => { + 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 extra.ob_type_lookup.is_type(value, ObType::Fraction) { + IsType::Exact | IsType::Subclass => { + infer_serialize_known(ObType::Fraction, value, serializer, include, exclude, extra) + } + IsType::False => { + 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/src/serializers/type_serializers/mod.rs b/src/serializers/type_serializers/mod.rs index 5fe990382..d487cd4ef 100644 --- a/src/serializers/type_serializers/mod.rs +++ b/src/serializers/type_serializers/mod.rs @@ -4,6 +4,7 @@ pub mod complex; pub mod dataclass; pub mod datetime_etc; pub mod decimal; +pub mod fraction; pub mod definitions; pub mod dict; pub mod enum_; From 99460bf4006153e5ebbc381f92fbd434c08c41fa Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Sat, 11 Oct 2025 11:16:23 +0200 Subject: [PATCH 02/12] WIP: add fraction decimal --- python/pydantic_core/core_schema.py | 26 ++-- src/errors/types.rs | 5 + src/input/input_abstract.rs | 2 + src/input/input_json.rs | 14 +++ src/input/input_python.rs | 60 +++++++-- src/input/input_string.rs | 8 ++ src/validators/fraction.rs | 189 ++++++++++++++++++++++++++++ src/validators/mod.rs | 5 + test_fraction_decimal.py | 46 +++++++ 9 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 src/validators/fraction.rs create mode 100644 test_fraction_decimal.py diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 0951d1d32..74c798b58 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -10,6 +10,7 @@ from collections.abc import Hashable, Mapping from datetime import date, datetime, time, timedelta from decimal import Decimal +from fractions import Fraction from re import Pattern from typing import TYPE_CHECKING, Any, Callable, Literal, Union @@ -809,23 +810,30 @@ def decimal_schema( serialization=serialization, ) +class FractionSchema(TypedDict, total=False): + type: Required[Literal['decimal']] + le: Decimal + ge: Decimal + lt: Decimal + gt: Decimal + strict: bool + ref: str + metadata: dict[str, Any] + serialization: SerSchema + def fraction_schema( *, - allow_inf_nan: bool | None = None, - multiple_of: Fraction | None = None, le: Fraction | None = None, ge: Fraction | None = None, lt: Fraction | None = None, gt: Fraction | None = None, - max_digits: int | None = None, - decimal_places: int | None = None, strict: bool | None = None, ref: str | None = None, metadata: dict[str, Any] | None = None, serialization: SerSchema | None = None, ) -> FractionSchema: """ - Returns a schema that matches a decimal value, e.g.: + Returns a schema that matches a fraction value, e.g.: ```py from fractions import Fraction @@ -837,14 +845,10 @@ def fraction_schema( ``` Args: - allow_inf_nan: Whether to allow inf and nan values - multiple_of: The value must be a multiple of this number le: The value must be less than or equal to this number ge: The value must be greater than or equal to this number lt: The value must be strictly less than this number gt: The value must be strictly greater than this number - max_digits: The maximum number of decimal digits allowed - decimal_places: The maximum number of decimal places allowed strict: Whether the value should be a float or a value that can be converted to a float ref: optional unique identifier of the schema, used to reference the schema in other places metadata: Any other information you want to include with the schema, not used by pydantic-core @@ -856,10 +860,6 @@ def fraction_schema( ge=ge, lt=lt, le=le, - max_digits=max_digits, - decimal_places=decimal_places, - multiple_of=multiple_of, - allow_inf_nan=allow_inf_nan, strict=strict, ref=ref, metadata=metadata, diff --git a/src/errors/types.rs b/src/errors/types.rs index 3dc18565a..8b1f358c8 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -430,6 +430,9 @@ error_types! { DecimalWholeDigits { whole_digits: {ctx_type: u64, ctx_fn: field_from_context}, }, + // Fraction errors + FractionType {}, + FractionParsing {}, // Complex errors ComplexType {}, ComplexStrParsing {}, @@ -579,6 +582,8 @@ impl ErrorType { Self::DecimalMaxDigits {..} => "Decimal input should have no more than {max_digits} digit{expected_plural} in total", Self::DecimalMaxPlaces {..} => "Decimal input should have no more than {decimal_places} decimal place{expected_plural}", Self::DecimalWholeDigits {..} => "Decimal input should have no more than {whole_digits} digit{expected_plural} before the decimal point", + Self::FractionParsing {..} => "Fraction input should be an integer, float, string or Fraction object", + Self::FractionType {..} => "Fraction input should be an integer, float, string or Fraction object", Self::ComplexType {..} => "Input should be a valid python complex object, a number, or a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", Self::ComplexStrParsing {..} => "Input should be a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", } diff --git a/src/input/input_abstract.rs b/src/input/input_abstract.rs index 17c9546db..d6309be20 100644 --- a/src/input/input_abstract.rs +++ b/src/input/input_abstract.rs @@ -115,6 +115,8 @@ pub trait Input<'py>: fmt::Debug { fn validate_decimal(&self, strict: bool, py: Python<'py>) -> ValMatch>; + fn validate_fraction(&self, strict: bool, py: Python<'py>) -> ValMatch>; + type Dict<'a>: ValidatedDict<'py> where Self: 'a; diff --git a/src/input/input_json.rs b/src/input/input_json.rs index b66de5395..978f731c0 100644 --- a/src/input/input_json.rs +++ b/src/input/input_json.rs @@ -12,6 +12,7 @@ use crate::input::return_enums::EitherComplex; use crate::lookup_key::{LookupKey, LookupPath}; use crate::validators::complex::string_to_complex; use crate::validators::decimal::create_decimal; +use crate::validators::fraction::create_fraction; use crate::validators::{TemporalUnitMode, ValBytesMode}; use super::datetime::{ @@ -199,6 +200,15 @@ impl<'py, 'data> Input<'py> for JsonValue<'data> { } } + fn validate_fraction(&self, _strict: bool, py: Python<'py>) -> ValMatch> { + match self { + JsonValue::Str(..) | JsonValue::Int(..) | JsonValue::BigInt(..) => { + create_fraction(&self.into_pyobject(py)?, self).map(ValidationMatch::strict) + } + _ => Err(ValError::new(ErrorTypeDefaults::DecimalType, self)), + } + } + type Dict<'a> = &'a JsonObject<'data> where @@ -454,6 +464,10 @@ impl<'py> Input<'py> for str { create_decimal(self.into_pyobject(py)?.as_any(), self).map(ValidationMatch::lax) } + fn validate_fraction(&self, _strict: bool, py: Python<'py>) -> ValMatch> { + create_fraction(self.into_pyobject(py)?.as_any(), self).map(ValidationMatch::lax) + } + type Dict<'a> = Never; #[cfg_attr(has_coverage_attribute, coverage(off))] diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 0f4ceb672..fd1a4caf7 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -18,6 +18,7 @@ use crate::errors::{ErrorType, ErrorTypeDefaults, InputValue, LocItem, ValError, use crate::tools::{extract_i64, safe_repr}; use crate::validators::complex::string_to_complex; use crate::validators::decimal::{create_decimal, get_decimal_type}; +use crate::validators::fraction::{create_fraction, get_fraction_type}; use crate::validators::Exactness; use crate::validators::TemporalUnitMode; use crate::validators::ValBytesMode; @@ -50,18 +51,6 @@ use super::{ static FRACTION_TYPE: PyOnceLock> = PyOnceLock::new(); -pub fn get_fraction_type(py: Python<'_>) -> &Bound<'_, PyType> { - FRACTION_TYPE - .get_or_init(py, || { - py.import("fractions") - .and_then(|fractions_module| fractions_module.getattr("Fraction")) - .unwrap() - .extract() - .unwrap() - }) - .bind(py) -} - pub(crate) fn downcast_python_input<'py, T: PyTypeCheck>(input: &(impl Input<'py> + ?Sized)) -> Option<&Bound<'py, T>> { input.as_python().and_then(|any| any.downcast::().ok()) } @@ -70,6 +59,7 @@ pub(crate) fn input_as_python_instance<'a, 'py>( input: &'a (impl Input<'py> + ?Sized), class: &Bound<'py, PyType>, ) -> Option<&'a Bound<'py, PyAny>> { + println!("input_as_python_instance: class={:?}", class); input.as_python().filter(|any| any.is_instance(class).unwrap_or(false)) } @@ -168,6 +158,7 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { strict: bool, coerce_numbers_to_str: bool, ) -> ValResult>> { + println!("[RUST]: Call validate_str with {:?}, and strict {:?}", self, strict); if let Ok(py_str) = self.downcast_exact::() { return Ok(ValidationMatch::exact(py_str.clone().into())); } else if let Ok(py_str) = self.downcast::() { @@ -284,13 +275,14 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { 'lax: { if !strict { + println!("[RUST]: validate_int lax path for {:?}", self); return if let Some(s) = maybe_as_string(self, ErrorTypeDefaults::IntParsing)? { str_as_int(self, s) } else if self.is_exact_instance_of::() { float_as_int(self, self.extract::()?) } else if let Ok(decimal) = self.validate_decimal(true, self.py()) { decimal_as_int(self, &decimal.into_inner()) - } else if self.is_instance(get_fraction_type(self.py()))? { + } else if let Ok(fraction) = self.validate_fraction(true, self.py()) { fraction_as_int(self) } else if let Ok(float) = self.extract::() { float_as_int(self, float) @@ -349,7 +341,49 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { Err(ValError::new(ErrorTypeDefaults::FloatType, self)) } + fn validate_fraction(&self, strict: bool, py: Python<'py>) -> ValMatch> { + println!("[RUST]: Call validate_fraction with {:?}, and strict {:?}", self, strict); + let fraction_type = get_fraction_type(py); + + // Fast path for existing decimal objects + if self.is_exact_instance(fraction_type) { + return Ok(ValidationMatch::exact(self.to_owned().clone())); + } + + if !strict { + if self.is_instance_of::() || (self.is_instance_of::() && !self.is_instance_of::()) + { + // Checking isinstance for str / int / bool is fast compared to decimal / float + return create_fraction(self, self).map(ValidationMatch::lax); + } + + if self.is_instance_of::() { + return create_fraction(self.str()?.as_any(), self).map(ValidationMatch::lax); + } + } + + if self.is_instance(fraction_type)? { + // Upcast subclasses to decimal + return create_decimal(self, self).map(ValidationMatch::strict); + } + + let error_type = if strict { + ErrorType::IsInstanceOf { + class: fraction_type + .qualname() + .and_then(|name| name.extract()) + .unwrap_or_else(|_| "Decimal".to_owned()), + context: None, + } + } else { + ErrorTypeDefaults::FractionType + }; + + Err(ValError::new(error_type, self)) + } + fn validate_decimal(&self, strict: bool, py: Python<'py>) -> ValMatch> { + println!("[RUST]: Call validate_decimal with {:?}, and strict {:?}", self, strict); let decimal_type = get_decimal_type(py); // Fast path for existing decimal objects diff --git a/src/input/input_string.rs b/src/input/input_string.rs index a635188a8..5e410d231 100644 --- a/src/input/input_string.rs +++ b/src/input/input_string.rs @@ -9,6 +9,7 @@ use crate::lookup_key::{LookupKey, LookupPath}; use crate::tools::safe_repr; use crate::validators::complex::string_to_complex; use crate::validators::decimal::create_decimal; +use crate::validators::fraction::create_fraction; use crate::validators::{TemporalUnitMode, ValBytesMode}; use super::datetime::{ @@ -154,6 +155,13 @@ impl<'py> Input<'py> for StringMapping<'py> { } } + fn validate_fraction(&self, _strict: bool, _py: Python<'py>) -> ValMatch> { + match self { + Self::String(s) => create_fraction(s, self).map(ValidationMatch::strict), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::DecimalType, self)), + } + } + type Dict<'a> = StringMappingDict<'py> where diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs new file mode 100644 index 000000000..84d1cabd1 --- /dev/null +++ b/src/validators/fraction.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::intern; +use pyo3::sync::PyOnceLock; +use pyo3::types::{IntoPyDict, PyDict, PyString, PyTuple, PyType}; +use pyo3::{prelude::*, PyTypeInfo}; + +use crate::build_tools::{is_strict, schema_or_config_same}; +use crate::errors::ErrorType; +use crate::errors::ValResult; +use crate::errors::{ErrorTypeDefaults, Number}; +use crate::errors::{ToErrorValue, ValError}; +use crate::input::Input; +use crate::tools::SchemaDict; + +use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; + +static FRACTION_TYPE: PyOnceLock> = PyOnceLock::new(); + +pub fn get_fraction_type(py: Python<'_>) -> &Bound<'_, PyType> { + FRACTION_TYPE + .get_or_init(py, || { + py.import("fractions") + .and_then(|fraction_module| fraction_module.getattr("Fraction")) + .unwrap() + .extract() + .unwrap() + }) + .bind(py) +} + +fn validate_as_fraction( + py: Python, + schema: &Bound<'_, PyDict>, + key: &Bound<'_, PyString>, +) -> PyResult>> { + match schema.get_item(key)? { + Some(value) => match value.validate_fraction(false, py) { + Ok(v) => Ok(Some(v.into_inner().unbind())), + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to a Decimal instance", + ))), + }, + None => Ok(None), + } +} + +#[derive(Debug, Clone)] +pub struct FractionValidator { + strict: bool, + le: Option>, + lt: Option>, + ge: Option>, + gt: Option>, +} + +impl BuildValidator for FractionValidator { + const EXPECTED_TYPE: &'static str = "fraction"; + fn build( + schema: &Bound<'_, PyDict>, + config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder>, + ) -> PyResult> { + let py = schema.py(); + + let allow_inf_nan = schema_or_config_same(schema, config, intern!(py, "allow_inf_nan"))?.unwrap_or(false); + + Ok(CombinedValidator::Fraction(Self { + strict: is_strict(schema, config)?, + le: validate_as_fraction(py, schema, intern!(py, "le"))?, + lt: validate_as_fraction(py, schema, intern!(py, "lt"))?, + ge: validate_as_fraction(py, schema, intern!(py, "ge"))?, + gt: validate_as_fraction(py, schema, intern!(py, "gt"))?, + }) + .into()) + } +} + +impl_py_gc_traverse!(FractionValidator { + le, + lt, + ge, + gt +}); + +fn extract_fraction_as_ints(fraction: &Bound<'_, PyAny>) -> ValResult<(i64, i64)> { + let py = fraction.py(); + // just call fraction.numerator and fraction.denominator + let numerator: i64 = fraction.getattr(intern!(py, "numerator"))?.extract()?; + let denominator: i64 = fraction.getattr(intern!(py, "denominator"))?.extract()?; + + Ok((numerator, denominator)) +} + +impl Validator for FractionValidator { + fn validate<'py>( + &self, + py: Python<'py>, + input: &(impl Input<'py> + ?Sized), + state: &mut ValidationState<'_, 'py>, + ) -> ValResult> { + let fraction = input.validate_fraction(state.strict_or(self.strict), py)?.unpack(state); + + // let mut is_nan: Option = None; + // let mut is_nan = || -> PyResult { + // match is_nan { + // Some(is_nan) => Ok(is_nan), + // None => Ok(*is_nan.insert(decimal.call_method0(intern!(py, "is_nan"))?.extract()?)), + // } + // }; + + // if let Some(le) = &self.le { + // if is_nan()? || !decimal.le(le)? { + // return Err(ValError::new( + // ErrorType::LessThanEqual { + // le: Number::String(le.to_string()), + // context: Some([("le", le)].into_py_dict(py)?.into()), + // }, + // input, + // )); + // } + // } + // if let Some(lt) = &self.lt { + // if is_nan()? || !decimal.lt(lt)? { + // return Err(ValError::new( + // ErrorType::LessThan { + // lt: Number::String(lt.to_string()), + // context: Some([("lt", lt)].into_py_dict(py)?.into()), + // }, + // input, + // )); + // } + // } + // if let Some(ge) = &self.ge { + // if is_nan()? || !decimal.ge(ge)? { + // return Err(ValError::new( + // ErrorType::GreaterThanEqual { + // ge: Number::String(ge.to_string()), + // context: Some([("ge", ge)].into_py_dict(py)?.into()), + // }, + // input, + // )); + // } + // } + // if let Some(gt) = &self.gt { + // if is_nan()? || !decimal.gt(gt)? { + // return Err(ValError::new( + // ErrorType::GreaterThan { + // gt: Number::String(gt.to_string()), + // context: Some([("gt", gt)].into_py_dict(py)?.into()), + // }, + // input, + // )); + // } + // } + + Ok(fraction.into()) + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} + +pub(crate) fn create_fraction<'py>(arg: &Bound<'py, PyAny>, input: impl ToErrorValue) -> ValResult> { + let py = arg.py(); + get_fraction_type(py).call1((arg,)).map_err(|e| { + let fraction_exception = match py + .import("fractions") + .and_then(|fraction_module| fraction_module.getattr("FractionException")) + { + Ok(fraction_exception) => fraction_exception, + Err(e) => return ValError::InternalErr(e), + }; + handle_fraction_new_error(input, e, fraction_exception) + }) +} + +fn handle_fraction_new_error(input: impl ToErrorValue, error: PyErr, fraction_exception: Bound<'_, PyAny>) -> ValError { + let py = fraction_exception.py(); + if error.matches(py, fraction_exception).unwrap_or(false) { + ValError::new(ErrorTypeDefaults::FractionParsing, input) + } else if error.matches(py, PyTypeError::type_object(py)).unwrap_or(false) { + ValError::new(ErrorTypeDefaults::FractionType, input) + } else { + ValError::InternalErr(error) + } +} diff --git a/src/validators/mod.rs b/src/validators/mod.rs index 7f6a5bdd7..f9ac8855e 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -38,6 +38,7 @@ mod definitions; mod dict; mod enum_; mod float; +pub(crate) mod fraction; mod frozenset; mod function; mod generator; @@ -592,6 +593,8 @@ fn build_validator_inner( float::FloatBuilder, // decimals decimal::DecimalValidator, + // fractions + fraction::FractionValidator, // tuples tuple::TupleValidator, // list/arrays @@ -767,6 +770,8 @@ pub enum CombinedValidator { ConstrainedFloat(float::ConstrainedFloatValidator), // decimals Decimal(decimal::DecimalValidator), + // fractions + Fraction(fraction::FractionValidator), // lists List(list::ListValidator), // sets - unique lists diff --git a/test_fraction_decimal.py b/test_fraction_decimal.py new file mode 100644 index 000000000..e777f19ea --- /dev/null +++ b/test_fraction_decimal.py @@ -0,0 +1,46 @@ +"""Test script to demonstrate Fraction and Decimal serialization in Pydantic.""" + +from decimal import Decimal +from fractions import Fraction +from pydantic import BaseModel + + +class MyModel(BaseModel): + fraction_field: Fraction + decimal_field: Decimal + + +# Create instance +model = MyModel( + fraction_field=Fraction(3, 4), + decimal_field=Decimal("3.14159") +) + +print("=" * 60) +print("Python mode serialization (mode='python'):") +python_serialized = model.model_dump(mode='python') +print(f" Result: {python_serialized}") +print() + +print("=" * 60) +print("JSON mode serialization (mode='json'):") +json_serialized = model.model_dump(mode='json') +print(f" Result: {json_serialized}") +print(f" fraction_field: {json_serialized['fraction_field']} (type: {type(json_serialized['fraction_field']).__name__})") +print(f" decimal_field: {json_serialized['decimal_field']} (type: {type(json_serialized['decimal_field']).__name__})") +print() + +print("=" * 60) +print("JSON string serialization (model_dump_json()):") +json_string = model.model_dump_json() +print(f" Result: {json_string}") +print() + +print("=" * 60) +print("Expected behavior:") +print(" Python mode:") +print(" - Fraction should be serialized as Fraction object (like Decimal)") +print(" - Decimal should be serialized as Decimal object") +print(" JSON mode:") +print(" - Both should be serialized as strings (or numbers)") +print() From 2fce2cc721c14f874c2e39a3569580d2837062c4 Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 13:18:41 +0200 Subject: [PATCH 03/12] WIP: try to fix linting --- benches/main.rs | 4 --- src/input/input_python.rs | 7 +--- src/serializers/infer.rs | 36 ++++---------------- src/serializers/ob_type.rs | 35 +++---------------- src/serializers/type_serializers/fraction.rs | 6 ++-- src/serializers/type_serializers/mod.rs | 2 +- src/validators/fraction.rs | 9 ++--- 7 files changed, 17 insertions(+), 82 deletions(-) diff --git a/benches/main.rs b/benches/main.rs index 4f5ba1496..39f8fba16 100644 --- a/benches/main.rs +++ b/benches/main.rs @@ -151,7 +151,6 @@ fn list_error_json(bench: &mut Bencher) { Ok(_) => panic!("unexpectedly valid"), Err(e) => { let v = e.value(py); - // println!("error: {}", v.to_string()); assert_eq!(v.getattr("title").unwrap().to_string(), "list[int]"); let error_count: i64 = v.call_method0("error_count").unwrap().extract().unwrap(); assert_eq!(error_count, 100); @@ -184,7 +183,6 @@ fn list_error_python_input(py: Python<'_>) -> (SchemaValidator, PyObject) { Ok(_) => panic!("unexpectedly valid"), Err(e) => { let v = e.value(py); - // println!("error: {}", v.to_string()); assert_eq!(v.getattr("title").unwrap().to_string(), "list[int]"); let error_count: i64 = v.call_method0("error_count").unwrap().extract().unwrap(); assert_eq!(error_count, 100); @@ -357,7 +355,6 @@ fn dict_value_error(bench: &mut Bencher) { Ok(_) => panic!("unexpectedly valid"), Err(e) => { let v = e.value(py); - // println!("error: {}", v.to_string()); assert_eq!(v.getattr("title").unwrap().to_string(), "dict[str,constrained-int]"); let error_count: i64 = v.call_method0("error_count").unwrap().extract().unwrap(); assert_eq!(error_count, 100); @@ -484,7 +481,6 @@ fn typed_dict_deep_error(bench: &mut Bencher) { Ok(_) => panic!("unexpectedly valid"), Err(e) => { let v = e.value(py); - // println!("error: {}", v.to_string()); assert_eq!(v.getattr("title").unwrap().to_string(), "typed-dict"); let error_count: i64 = v.call_method0("error_count").unwrap().extract().unwrap(); assert_eq!(error_count, 1); diff --git a/src/input/input_python.rs b/src/input/input_python.rs index fd1a4caf7..bf64ec887 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -49,7 +49,7 @@ use super::{ Input, }; -static FRACTION_TYPE: PyOnceLock> = PyOnceLock::new(); + pub(crate) fn downcast_python_input<'py, T: PyTypeCheck>(input: &(impl Input<'py> + ?Sized)) -> Option<&Bound<'py, T>> { input.as_python().and_then(|any| any.downcast::().ok()) @@ -59,7 +59,6 @@ pub(crate) fn input_as_python_instance<'a, 'py>( input: &'a (impl Input<'py> + ?Sized), class: &Bound<'py, PyType>, ) -> Option<&'a Bound<'py, PyAny>> { - println!("input_as_python_instance: class={:?}", class); input.as_python().filter(|any| any.is_instance(class).unwrap_or(false)) } @@ -158,7 +157,6 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { strict: bool, coerce_numbers_to_str: bool, ) -> ValResult>> { - println!("[RUST]: Call validate_str with {:?}, and strict {:?}", self, strict); if let Ok(py_str) = self.downcast_exact::() { return Ok(ValidationMatch::exact(py_str.clone().into())); } else if let Ok(py_str) = self.downcast::() { @@ -275,7 +273,6 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { 'lax: { if !strict { - println!("[RUST]: validate_int lax path for {:?}", self); return if let Some(s) = maybe_as_string(self, ErrorTypeDefaults::IntParsing)? { str_as_int(self, s) } else if self.is_exact_instance_of::() { @@ -342,7 +339,6 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { } fn validate_fraction(&self, strict: bool, py: Python<'py>) -> ValMatch> { - println!("[RUST]: Call validate_fraction with {:?}, and strict {:?}", self, strict); let fraction_type = get_fraction_type(py); // Fast path for existing decimal objects @@ -383,7 +379,6 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { } fn validate_decimal(&self, strict: bool, py: Python<'py>) -> ValMatch> { - println!("[RUST]: Call validate_decimal with {:?}, and strict {:?}", self, strict); let decimal_type = get_decimal_type(py); // Fast path for existing decimal objects diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index cda5496e7..114571611 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -136,16 +136,8 @@ pub(crate) fn infer_to_python_known( } v.into_py_any(py)? } - ObType::Decimal => { - // todo: delete before PR ready - println!("[RUST] infer_to_python_known - SerMode::Json - serializing ObType::Decimal"); - value.to_string().into_py_any(py)? - }, - ObType::Fraction => { - // todo: delete before PR ready - println!("[RUST] infer_to_python_known - SerMode::Json - serializing ObType::Fraction"); - value.to_string().into_py_any(py)? - }, + ObType::Decimal => value.to_string().into_py_any(py)?, + ObType::Fraction => value.to_string().into_py_any(py)?, ObType::StrSubclass => PyString::new(py, value.downcast::()?.to_str()?).into(), ObType::Bytes => extra .config @@ -439,16 +431,8 @@ pub(crate) fn infer_serialize_known( let v = value.extract::().map_err(py_err_se_err)?; type_serializers::float::serialize_f64(v, serializer, extra.config.inf_nan_mode) } - ObType::Decimal => { - // todo: delete before PR ready - println!("[RUST] infer_serialize_known - serializing ObType::Decimal"); - value.to_string().serialize(serializer) - }, - ObType::Fraction => { - // todo: delete before PR ready - println!("[RUST] infer_serialize_known - serializing ObType::Fraction"); - value.to_string().serialize(serializer) - }, + ObType::Decimal => value.to_string().serialize(serializer), + ObType::Fraction => value.to_string().serialize(serializer), ObType::Str | ObType::StrSubclass => { let py_str = value.downcast::().map_err(py_err_se_err)?; super::type_serializers::string::serialize_py_str(py_str, serializer) @@ -630,16 +614,8 @@ pub(crate) fn infer_json_key_known<'a>( super::type_serializers::simple::to_str_json_key(key) } } - ObType::Decimal => { - // todo: delete before PR ready - println!("[RUST] infer_json_key_known - converting ObType::Decimal to json key"); - Ok(Cow::Owned(key.to_string())) - }, - ObType::Fraction => { - // todo: delete before PR ready - println!("[RUST] infer_json_key_known - converting ObType::Fraction to json key"); - Ok(Cow::Owned(key.to_string())) - }, + ObType::Decimal => Ok(Cow::Owned(key.to_string())), + ObType::Fraction => Ok(Cow::Owned(key.to_string())), ObType::Bool => super::type_serializers::simple::bool_json_key(key), ObType::Str | ObType::StrSubclass => key.downcast::()?.to_cow(), ObType::Bytes => extra diff --git a/src/serializers/ob_type.rs b/src/serializers/ob_type.rs index e14c35ffb..792863a0a 100644 --- a/src/serializers/ob_type.rs +++ b/src/serializers/ob_type.rs @@ -64,7 +64,6 @@ pub enum IsType { impl ObTypeLookup { fn new(py: Python) -> Self { // todo: delete before PR ready - println!("[RUST] ObTypeLookup::new"); Self { none: PyNone::type_object_raw(py) as usize, int: PyInt::type_object_raw(py) as usize, @@ -72,16 +71,8 @@ impl ObTypeLookup { float: PyFloat::type_object_raw(py) as usize, list: PyList::type_object_raw(py) as usize, dict: PyDict::type_object_raw(py) as usize, - decimal_object: { - // todo: delete before PR ready - println!("[RUST] ObTypeLookup::new - loading decimal_object"); - py.import("decimal").unwrap().getattr("Decimal").unwrap().unbind() - }, - fraction_object: { - // todo: delete before PR ready - println!("[RUST] ObTypeLookup::new - loading fraction_object"); - py.import("fractions").unwrap().getattr("Fraction").unwrap().unbind() - }, + decimal_object: py.import("decimal").unwrap().getattr("Decimal").unwrap().unbind(), + fraction_object: py.import("fractions").unwrap().getattr("Fraction").unwrap().unbind(), string: PyString::type_object_raw(py) as usize, bytes: PyBytes::type_object_raw(py) as usize, bytearray: PyByteArray::type_object_raw(py) as usize, @@ -108,7 +99,6 @@ impl ObTypeLookup { } pub fn is_type(&self, value: &Bound<'_, PyAny>, expected_ob_type: ObType) -> IsType { - println!("[RUST] is_type - expected_ob_type: {expected_ob_type}"); match self.ob_type_is_expected(Some(value), &value.get_type(), &expected_ob_type) { IsType::False => { if expected_ob_type == self.fallback_isinstance(value) { @@ -129,7 +119,6 @@ impl ObTypeLookup { ) -> IsType { let type_ptr = py_type.as_ptr(); let ob_type = type_ptr as usize; - println!("[RUST] ob_type_is_expected - ob_type: {ob_type}, expected_ob_type: {expected_ob_type}"); let ans = match expected_ob_type { ObType::None => self.none == ob_type, ObType::Int => self.int == ob_type, @@ -151,16 +140,8 @@ impl ObTypeLookup { ObType::Str => self.string == ob_type, ObType::List => self.list == ob_type, ObType::Dict => self.dict == ob_type, - ObType::Decimal => { - // todo: delete before PR ready - println!("[RUST] ob_type_is_expected - checking ObType::Decimal"); - self.decimal_object.as_ptr() as usize == ob_type - }, - ObType::Fraction => { - // todo: delete before PR ready - println!("[RUST] ob_type_is_expected - checking ObType::Fraction"); - self.fraction_object.as_ptr() as usize == ob_type - }, + ObType::Decimal => self.decimal_object.as_ptr() as usize == ob_type, + ObType::Fraction => self.fraction_object.as_ptr() as usize == ob_type, ObType::StrSubclass => self.string == ob_type && op_value.is_none(), ObType::Tuple => self.tuple == ob_type, ObType::Set => self.set == ob_type, @@ -237,12 +218,8 @@ impl ObTypeLookup { } else if ob_type == self.dict { ObType::Dict } else if ob_type == self.decimal_object.as_ptr() as usize { - // todo: delete before PR ready - println!("[RUST] lookup_by_ob_type - found ObType::Decimal"); ObType::Decimal } else if ob_type == self.fraction_object.as_ptr() as usize { - // todo: delete before PR ready - println!("[RUST] lookup_by_ob_type - found ObType::Fraction"); ObType::Fraction } else if ob_type == self.bytes { ObType::Bytes @@ -351,12 +328,8 @@ impl ObTypeLookup { } else if value.is_instance_of::() { ObType::MultiHostUrl } else if value.is_instance(self.decimal_object.bind(py)).unwrap_or(false) { - // todo: delete before PR ready - println!("[RUST] fallback_isinstance - found ObType::Decimal"); ObType::Decimal } else if value.is_instance(self.fraction_object.bind(py)).unwrap_or(false) { - // todo: delete before PR ready - println!("[RUST] fallback_isinstance - found ObType::Fraction"); ObType::Fraction } else if value.is_instance(self.uuid_object.bind(py)).unwrap_or(false) { ObType::Uuid diff --git a/src/serializers/type_serializers/fraction.rs b/src/serializers/type_serializers/fraction.rs index 7bf9ccedb..7fba769c6 100644 --- a/src/serializers/type_serializers/fraction.rs +++ b/src/serializers/type_serializers/fraction.rs @@ -16,10 +16,11 @@ use super::{ #[derive(Debug)] pub struct FractionSerializer {} -static FRACTION_SERIALIZER: LazyLock> = LazyLock::new(|| Arc::new(FractionSerializer {}.into())); +static FRACTION_SERIALIZER: LazyLock> = + LazyLock::new(|| Arc::new(FractionSerializer {}.into())); impl BuildSerializer for FractionSerializer { - const EXPECTED_TYPE: &'static str = "decimal"; + const EXPECTED_TYPE: &'static str = "fraction"; fn build( _schema: &Bound<'_, PyDict>, @@ -41,7 +42,6 @@ impl TypeSerializer for FractionSerializer { extra: &Extra, ) -> PyResult> { let _py = value.py(); - println!("[RUST] FractionSerializer to_python called"); match extra.ob_type_lookup.is_type(value, ObType::Fraction) { IsType::Exact | IsType::Subclass => infer_to_python_known(ObType::Fraction, value, include, exclude, extra), IsType::False => { diff --git a/src/serializers/type_serializers/mod.rs b/src/serializers/type_serializers/mod.rs index d487cd4ef..04315fa06 100644 --- a/src/serializers/type_serializers/mod.rs +++ b/src/serializers/type_serializers/mod.rs @@ -4,12 +4,12 @@ pub mod complex; pub mod dataclass; pub mod datetime_etc; pub mod decimal; -pub mod fraction; pub mod definitions; pub mod dict; pub mod enum_; pub mod float; pub mod format; +pub mod fraction; pub mod function; pub mod generator; pub mod json; diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index 84d1cabd1..b54de7c74 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::intern; use pyo3::sync::PyOnceLock; -use pyo3::types::{IntoPyDict, PyDict, PyString, PyTuple, PyType}; +use pyo3::types::{PyDict, PyString, PyType}; use pyo3::{prelude::*, PyTypeInfo}; use crate::build_tools::{is_strict, schema_or_config_same}; @@ -77,12 +77,7 @@ impl BuildValidator for FractionValidator { } } -impl_py_gc_traverse!(FractionValidator { - le, - lt, - ge, - gt -}); +impl_py_gc_traverse!(FractionValidator { le, lt, ge, gt }); fn extract_fraction_as_ints(fraction: &Bound<'_, PyAny>) -> ValResult<(i64, i64)> { let py = fraction.py(); From 2028880b4588c8506320d957e593756fa8b2663b Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 13:43:24 +0200 Subject: [PATCH 04/12] WIP: fix linting errors and added test_fraction.py --- src/input/input_python.rs | 2 +- src/input/shared.rs | 30 +++++++++---------- src/validators/fraction.rs | 8 ++--- tests/serializers/test_fraction.py | 47 ++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 24 deletions(-) create mode 100644 tests/serializers/test_fraction.py diff --git a/src/input/input_python.rs b/src/input/input_python.rs index bf64ec887..2165c0309 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -280,7 +280,7 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { } else if let Ok(decimal) = self.validate_decimal(true, self.py()) { decimal_as_int(self, &decimal.into_inner()) } else if let Ok(fraction) = self.validate_fraction(true, self.py()) { - fraction_as_int(self) + fraction_as_int(self, &fraction.into_inner()) } else if let Ok(float) = self.extract::() { float_as_int(self, float) } else if let Some(enum_val) = maybe_as_enum(self) { diff --git a/src/input/shared.rs b/src/input/shared.rs index f192c97ef..59862c4f3 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -228,22 +228,18 @@ pub fn decimal_as_int<'py>( Ok(EitherInt::Py(numerator)) } -pub fn fraction_as_int<'py>(input: &Bound<'py, PyAny>) -> ValResult> { - #[cfg(Py_3_12)] - let is_integer = input.call_method0("is_integer")?.extract::()?; - #[cfg(not(Py_3_12))] - let is_integer = input.getattr("denominator")?.extract::().map_or(false, |d| d == 1); - - if is_integer { - #[cfg(Py_3_11)] - let as_int = input.call_method0("__int__"); - #[cfg(not(Py_3_11))] - let as_int = input.call_method0("__trunc__"); - match as_int { - Ok(i) => Ok(EitherInt::Py(i.as_any().to_owned())), - Err(_) => Err(ValError::new(ErrorTypeDefaults::IntType, input)), - } - } else { - Err(ValError::new(ErrorTypeDefaults::IntFromFloat, input)) +pub fn fraction_as_int<'py>( + input: &(impl Input<'py> + ?Sized), + fraction: &Bound<'py, PyAny>, + ) -> ValResult> { + let py = fraction.py(); + + let (numerator, denominator) = fraction + .call_method0(intern!(py, "as_integer_ratio"))? + .extract::<(Bound<'_, PyAny>, Bound<'_, PyAny>)>()?; + if denominator.extract::().map_or(true, |d| d != 1) { + return Err(ValError::new(ErrorTypeDefaults::IntFromFloat, input)); } + Ok(EitherInt::Py(numerator)) + } diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index b54de7c74..9bfb08fdd 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -6,13 +6,11 @@ use pyo3::sync::PyOnceLock; use pyo3::types::{PyDict, PyString, PyType}; use pyo3::{prelude::*, PyTypeInfo}; -use crate::build_tools::{is_strict, schema_or_config_same}; -use crate::errors::ErrorType; +use crate::build_tools::is_strict; use crate::errors::ValResult; -use crate::errors::{ErrorTypeDefaults, Number}; +use crate::errors::ErrorTypeDefaults; use crate::errors::{ToErrorValue, ValError}; use crate::input::Input; -use crate::tools::SchemaDict; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; @@ -64,8 +62,6 @@ impl BuildValidator for FractionValidator { ) -> PyResult> { let py = schema.py(); - let allow_inf_nan = schema_or_config_same(schema, config, intern!(py, "allow_inf_nan"))?.unwrap_or(false); - Ok(CombinedValidator::Fraction(Self { strict: is_strict(schema, config)?, le: validate_as_fraction(py, schema, intern!(py, "le"))?, diff --git a/tests/serializers/test_fraction.py b/tests/serializers/test_fraction.py new file mode 100644 index 000000000..6d06c70e2 --- /dev/null +++ b/tests/serializers/test_fraction.py @@ -0,0 +1,47 @@ +from fractions import Fraction + +import pytest + +from pydantic_core import SchemaSerializer, core_schema + + +def test_fraction(): + v = SchemaSerializer(core_schema.fraction_schema()) + assert v.to_python(Fraction('3 / 4')) == Fraction(3, 4) + assert v.to_python(Fraction(3, 4)) == Fraction(3, 4) + + # check correct casting to int when denominator is 1 + assert v.to_python(Fraction(10, 10), mode='json') == '1' + assert v.to_python(Fraction(1, 10), mode='json') == '1/10' + + assert v.to_json(Fraction(3, 4)) == b'"3/4"' + + +def test_fraction_key(): + v = SchemaSerializer(core_schema.dict_schema(core_schema.fraction_schema(), core_schema.fraction_schema())) + assert v.to_python({Fraction(3, 4): Fraction(1, 10)}) == {Fraction(3, 4): Fraction(1, 10)} + assert v.to_python({Fraction(3, 4): Fraction(1, 10)}, mode='json') == {'3/4': '1/10'} + assert v.to_json({Fraction(3, 4): Fraction(1, 10)}) == b'{"3/4":"1/10"}' + + +@pytest.mark.parametrize( + 'value,expected', + [ + (Fraction(3, 4), '3/4'), + (Fraction(1, 10), '1/10'), + (Fraction(10, 1), '10'), + (Fraction(-5, 2), '-5/2'), + ], +) +def test_fraction_json(value, expected): + v = SchemaSerializer(core_schema.fraction_schema()) + assert v.to_python(value, mode='json') == expected + assert v.to_json(value).decode() == f'"{expected}"' + + +def test_any_fraction_key(): + v = SchemaSerializer(core_schema.dict_schema()) + input_value = {Fraction(3, 4): 1} + + assert v.to_python(input_value, mode='json') == {'3/4': 1} + assert v.to_json(input_value) == b'{"3/4":1}' From ff4df279d725029b015a176f59bfd8f7adcd1a9a Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 14:41:35 +0200 Subject: [PATCH 05/12] fix linting errors --- src/input/input_python.rs | 3 --- src/input/shared.rs | 3 +-- src/validators/fraction.rs | 11 +---------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 2165c0309..fd1dbc8f3 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -3,7 +3,6 @@ use std::str::from_utf8; use pyo3::intern; use pyo3::prelude::*; -use pyo3::sync::PyOnceLock; use pyo3::types::PyType; use pyo3::types::{ PyBool, PyByteArray, PyBytes, PyComplex, PyDate, PyDateTime, PyDict, PyFloat, PyFrozenSet, PyInt, PyIterator, @@ -49,8 +48,6 @@ use super::{ Input, }; - - pub(crate) fn downcast_python_input<'py, T: PyTypeCheck>(input: &(impl Input<'py> + ?Sized)) -> Option<&Bound<'py, T>> { input.as_python().and_then(|any| any.downcast::().ok()) } diff --git a/src/input/shared.rs b/src/input/shared.rs index 59862c4f3..45120d639 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -231,7 +231,7 @@ pub fn decimal_as_int<'py>( pub fn fraction_as_int<'py>( input: &(impl Input<'py> + ?Sized), fraction: &Bound<'py, PyAny>, - ) -> ValResult> { +) -> ValResult> { let py = fraction.py(); let (numerator, denominator) = fraction @@ -241,5 +241,4 @@ pub fn fraction_as_int<'py>( return Err(ValError::new(ErrorTypeDefaults::IntFromFloat, input)); } Ok(EitherInt::Py(numerator)) - } diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index 9bfb08fdd..33618f3ad 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -7,8 +7,8 @@ use pyo3::types::{PyDict, PyString, PyType}; use pyo3::{prelude::*, PyTypeInfo}; use crate::build_tools::is_strict; -use crate::errors::ValResult; use crate::errors::ErrorTypeDefaults; +use crate::errors::ValResult; use crate::errors::{ToErrorValue, ValError}; use crate::input::Input; @@ -75,15 +75,6 @@ impl BuildValidator for FractionValidator { impl_py_gc_traverse!(FractionValidator { le, lt, ge, gt }); -fn extract_fraction_as_ints(fraction: &Bound<'_, PyAny>) -> ValResult<(i64, i64)> { - let py = fraction.py(); - // just call fraction.numerator and fraction.denominator - let numerator: i64 = fraction.getattr(intern!(py, "numerator"))?.extract()?; - let denominator: i64 = fraction.getattr(intern!(py, "denominator"))?.extract()?; - - Ok((numerator, denominator)) -} - impl Validator for FractionValidator { fn validate<'py>( &self, From 5f66a6580529e875be8d4bb04d24d797c88613c9 Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 14:53:09 +0200 Subject: [PATCH 06/12] remove test_fraction_decimal.py --- test_fraction_decimal.py | 46 ---------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 test_fraction_decimal.py diff --git a/test_fraction_decimal.py b/test_fraction_decimal.py deleted file mode 100644 index e777f19ea..000000000 --- a/test_fraction_decimal.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Test script to demonstrate Fraction and Decimal serialization in Pydantic.""" - -from decimal import Decimal -from fractions import Fraction -from pydantic import BaseModel - - -class MyModel(BaseModel): - fraction_field: Fraction - decimal_field: Decimal - - -# Create instance -model = MyModel( - fraction_field=Fraction(3, 4), - decimal_field=Decimal("3.14159") -) - -print("=" * 60) -print("Python mode serialization (mode='python'):") -python_serialized = model.model_dump(mode='python') -print(f" Result: {python_serialized}") -print() - -print("=" * 60) -print("JSON mode serialization (mode='json'):") -json_serialized = model.model_dump(mode='json') -print(f" Result: {json_serialized}") -print(f" fraction_field: {json_serialized['fraction_field']} (type: {type(json_serialized['fraction_field']).__name__})") -print(f" decimal_field: {json_serialized['decimal_field']} (type: {type(json_serialized['decimal_field']).__name__})") -print() - -print("=" * 60) -print("JSON string serialization (model_dump_json()):") -json_string = model.model_dump_json() -print(f" Result: {json_string}") -print() - -print("=" * 60) -print("Expected behavior:") -print(" Python mode:") -print(" - Fraction should be serialized as Fraction object (like Decimal)") -print(" - Decimal should be serialized as Decimal object") -print(" JSON mode:") -print(" - Both should be serialized as strings (or numbers)") -print() From 2c710aa899308a328626be51bb11224994ff3198 Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 16:37:23 +0200 Subject: [PATCH 07/12] hand zero division error through --- python/pydantic_core/core_schema.py | 4 ++++ src/errors/types.rs | 4 ++-- src/validators/fraction.rs | 32 +++++++++++++---------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 74c798b58..28dd1ca73 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -4165,6 +4165,7 @@ def definition_reference_schema( IntSchema, FloatSchema, DecimalSchema, + FractionSchema, StringSchema, BytesSchema, DateSchema, @@ -4224,6 +4225,7 @@ def definition_reference_schema( 'int', 'float', 'decimal', + 'fraction', 'str', 'bytes', 'date', @@ -4374,6 +4376,8 @@ def definition_reference_schema( 'uuid_version', 'decimal_type', 'decimal_parsing', + 'fraction_type', + 'fraction_parsing', 'decimal_max_digits', 'decimal_max_places', 'decimal_whole_digits', diff --git a/src/errors/types.rs b/src/errors/types.rs index 8b1f358c8..88c84f311 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -582,8 +582,8 @@ impl ErrorType { Self::DecimalMaxDigits {..} => "Decimal input should have no more than {max_digits} digit{expected_plural} in total", Self::DecimalMaxPlaces {..} => "Decimal input should have no more than {decimal_places} decimal place{expected_plural}", Self::DecimalWholeDigits {..} => "Decimal input should have no more than {whole_digits} digit{expected_plural} before the decimal point", - Self::FractionParsing {..} => "Fraction input should be an integer, float, string or Fraction object", - Self::FractionType {..} => "Fraction input should be an integer, float, string or Fraction object", + Self::FractionParsing {..} => "Fraction input should be a tuple of two integers, a string or a Fraction object", + Self::FractionType {..} => "Fraction input should be a tuple of two integers, or a string or Fraction object", Self::ComplexType {..} => "Input should be a valid python complex object, a number, or a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", Self::ComplexStrParsing {..} => "Input should be a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", } diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index 33618f3ad..73f426e17 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::exceptions::{PyTypeError, PyValueError, PyZeroDivisionError}; use pyo3::intern; use pyo3::sync::PyOnceLock; use pyo3::types::{PyDict, PyString, PyType}; @@ -148,24 +148,20 @@ impl Validator for FractionValidator { pub(crate) fn create_fraction<'py>(arg: &Bound<'py, PyAny>, input: impl ToErrorValue) -> ValResult> { let py = arg.py(); get_fraction_type(py).call1((arg,)).map_err(|e| { - let fraction_exception = match py - .import("fractions") - .and_then(|fraction_module| fraction_module.getattr("FractionException")) - { - Ok(fraction_exception) => fraction_exception, - Err(e) => return ValError::InternalErr(e), - }; - handle_fraction_new_error(input, e, fraction_exception) + handle_fraction_new_error(input, e) }) } -fn handle_fraction_new_error(input: impl ToErrorValue, error: PyErr, fraction_exception: Bound<'_, PyAny>) -> ValError { - let py = fraction_exception.py(); - if error.matches(py, fraction_exception).unwrap_or(false) { - ValError::new(ErrorTypeDefaults::FractionParsing, input) - } else if error.matches(py, PyTypeError::type_object(py)).unwrap_or(false) { - ValError::new(ErrorTypeDefaults::FractionType, input) - } else { - ValError::InternalErr(error) - } +fn handle_fraction_new_error(input: impl ToErrorValue, error: PyErr) -> ValError { + Python::with_gil(|py| { + if error.matches(py, PyValueError::type_object(py)).unwrap_or(false) { + ValError::new(ErrorTypeDefaults::FractionParsing, input) + } else if error.matches(py, PyTypeError::type_object(py)).unwrap_or(false) { + ValError::new(ErrorTypeDefaults::FractionType, input) + } else { + // Let ZeroDivisionError and other exceptions bubble up as InternalErr + // which will be shown to the user with the original Python error message + ValError::InternalErr(error) + } + }) } From e8f13852cae6ac520d42d5ea7de1bdd686e52baa Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Thu, 23 Oct 2025 16:57:43 +0200 Subject: [PATCH 08/12] remove unnecessary code parts --- src/input/input_python.rs | 19 +++---------------- src/input/shared.rs | 1 + src/serializers/ob_type.rs | 1 - src/validators/fraction.rs | 12 ++++++------ 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index fd1dbc8f3..1e22e8f09 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -343,21 +343,8 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { return Ok(ValidationMatch::exact(self.to_owned().clone())); } - if !strict { - if self.is_instance_of::() || (self.is_instance_of::() && !self.is_instance_of::()) - { - // Checking isinstance for str / int / bool is fast compared to decimal / float - return create_fraction(self, self).map(ValidationMatch::lax); - } - - if self.is_instance_of::() { - return create_fraction(self.str()?.as_any(), self).map(ValidationMatch::lax); - } - } - - if self.is_instance(fraction_type)? { - // Upcast subclasses to decimal - return create_decimal(self, self).map(ValidationMatch::strict); + if !strict && self.is_instance_of::() { + return create_fraction(self, self).map(ValidationMatch::lax); } let error_type = if strict { @@ -365,7 +352,7 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { class: fraction_type .qualname() .and_then(|name| name.extract()) - .unwrap_or_else(|_| "Decimal".to_owned()), + .unwrap_or_else(|_| "Fraction".to_owned()), context: None, } } else { diff --git a/src/input/shared.rs b/src/input/shared.rs index 45120d639..b0deb9869 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -234,6 +234,7 @@ pub fn fraction_as_int<'py>( ) -> ValResult> { let py = fraction.py(); + // as_integer_ratio was added in Python 3.8, so this should be fine let (numerator, denominator) = fraction .call_method0(intern!(py, "as_integer_ratio"))? .extract::<(Bound<'_, PyAny>, Bound<'_, PyAny>)>()?; diff --git a/src/serializers/ob_type.rs b/src/serializers/ob_type.rs index 792863a0a..d81ac1aad 100644 --- a/src/serializers/ob_type.rs +++ b/src/serializers/ob_type.rs @@ -63,7 +63,6 @@ pub enum IsType { impl ObTypeLookup { fn new(py: Python) -> Self { - // todo: delete before PR ready Self { none: PyNone::type_object_raw(py) as usize, int: PyInt::type_object_raw(py) as usize, diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index 73f426e17..b716b198c 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pyo3::exceptions::{PyTypeError, PyValueError, PyZeroDivisionError}; +use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::intern; use pyo3::sync::PyOnceLock; use pyo3::types::{PyDict, PyString, PyType}; @@ -37,7 +37,7 @@ fn validate_as_fraction( Some(value) => match value.validate_fraction(false, py) { Ok(v) => Ok(Some(v.into_inner().unbind())), Err(_) => Err(PyValueError::new_err(format!( - "'{key}' must be coercible to a Decimal instance", + "'{key}' must be coercible to a Fraction instance", ))), }, None => Ok(None), @@ -147,13 +147,13 @@ impl Validator for FractionValidator { pub(crate) fn create_fraction<'py>(arg: &Bound<'py, PyAny>, input: impl ToErrorValue) -> ValResult> { let py = arg.py(); - get_fraction_type(py).call1((arg,)).map_err(|e| { - handle_fraction_new_error(input, e) - }) + get_fraction_type(py) + .call1((arg,)) + .map_err(|e| handle_fraction_new_error(input, e)) } fn handle_fraction_new_error(input: impl ToErrorValue, error: PyErr) -> ValError { - Python::with_gil(|py| { + Python::attach(|py| { if error.matches(py, PyValueError::type_object(py)).unwrap_or(false) { ValError::new(ErrorTypeDefaults::FractionParsing, input) } else if error.matches(py, PyTypeError::type_object(py)).unwrap_or(false) { From 5ef6e2b9b7075e18646ac0b3df6d36ff1a121c7d Mon Sep 17 00:00:00 2001 From: Tobias Pitters <31857876+CloseChoice@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:36:49 +0100 Subject: [PATCH 09/12] Update src/input/input_python.rs Co-authored-by: David Hewitt --- src/input/input_python.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 1e22e8f09..56849bc8b 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -338,7 +338,7 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { fn validate_fraction(&self, strict: bool, py: Python<'py>) -> ValMatch> { let fraction_type = get_fraction_type(py); - // Fast path for existing decimal objects + // Fast path for existing fraction objects if self.is_exact_instance(fraction_type) { return Ok(ValidationMatch::exact(self.to_owned().clone())); } From ea511428b28423628dafdfaf3d8b3faec03ffd5d Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Sun, 2 Nov 2025 19:15:47 +0100 Subject: [PATCH 10/12] update fraction, add fraction validator tests and adjust to pass the tests --- python/pydantic_core/core_schema.py | 8 +- src/errors/types.rs | 4 +- src/input/input_json.rs | 5 +- src/input/input_python.rs | 19 +- src/validators/decimal.rs | 2 +- src/validators/fraction.rs | 92 ++++---- tests/validators/test_fraction.py | 335 ++++++++++++++++++++++++++++ 7 files changed, 408 insertions(+), 57 deletions(-) create mode 100644 tests/validators/test_fraction.py diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 28dd1ca73..1d468ea88 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -812,10 +812,10 @@ def decimal_schema( class FractionSchema(TypedDict, total=False): type: Required[Literal['decimal']] - le: Decimal - ge: Decimal - lt: Decimal - gt: Decimal + le: Fraction + ge: Fraction + lt: Fraction + gt: Fraction strict: bool ref: str metadata: dict[str, Any] diff --git a/src/errors/types.rs b/src/errors/types.rs index 88c84f311..64e7cb2bb 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -582,8 +582,8 @@ impl ErrorType { Self::DecimalMaxDigits {..} => "Decimal input should have no more than {max_digits} digit{expected_plural} in total", Self::DecimalMaxPlaces {..} => "Decimal input should have no more than {decimal_places} decimal place{expected_plural}", Self::DecimalWholeDigits {..} => "Decimal input should have no more than {whole_digits} digit{expected_plural} before the decimal point", - Self::FractionParsing {..} => "Fraction input should be a tuple of two integers, a string or a Fraction object", - Self::FractionType {..} => "Fraction input should be a tuple of two integers, or a string or Fraction object", + Self::FractionParsing {..} => "Input should be a valid fraction", + Self::FractionType {..} => "Fraction input should be an integer, float, string or Fraction object", Self::ComplexType {..} => "Input should be a valid python complex object, a number, or a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", Self::ComplexStrParsing {..} => "Input should be a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex", } diff --git a/src/input/input_json.rs b/src/input/input_json.rs index 978f731c0..922a41311 100644 --- a/src/input/input_json.rs +++ b/src/input/input_json.rs @@ -202,10 +202,13 @@ impl<'py, 'data> Input<'py> for JsonValue<'data> { fn validate_fraction(&self, _strict: bool, py: Python<'py>) -> ValMatch> { match self { + JsonValue::Float(f) => { + create_fraction(&PyString::new(py, &f.to_string()), self).map(ValidationMatch::strict) + } JsonValue::Str(..) | JsonValue::Int(..) | JsonValue::BigInt(..) => { create_fraction(&self.into_pyobject(py)?, self).map(ValidationMatch::strict) } - _ => Err(ValError::new(ErrorTypeDefaults::DecimalType, self)), + _ => Err(ValError::new(ErrorTypeDefaults::FractionType, self)), } } diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 1e22e8f09..8b537b1cd 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -338,13 +338,26 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { fn validate_fraction(&self, strict: bool, py: Python<'py>) -> ValMatch> { let fraction_type = get_fraction_type(py); - // Fast path for existing decimal objects + // Fast path for existing fraction objects if self.is_exact_instance(fraction_type) { return Ok(ValidationMatch::exact(self.to_owned().clone())); } - if !strict && self.is_instance_of::() { - return create_fraction(self, self).map(ValidationMatch::lax); + // Check for fraction subclasses + if self.is_instance(fraction_type)? { + return Ok(ValidationMatch::lax(self.to_owned().clone())); + } + + if !strict { + if self.is_instance_of::() || (self.is_instance_of::() && !self.is_instance_of::()) + { + // Checking isinstance for str / int / bool is fast compared to fraction / float + return create_fraction(self, self).map(ValidationMatch::lax); + } + + if self.is_instance_of::() { + return create_fraction(self.str()?.as_any(), self).map(ValidationMatch::lax); + } } let error_type = if strict { diff --git a/src/validators/decimal.rs b/src/validators/decimal.rs index 56f0aa766..52e258dbe 100644 --- a/src/validators/decimal.rs +++ b/src/validators/decimal.rs @@ -37,7 +37,7 @@ fn validate_as_decimal( ) -> PyResult>> { match schema.get_item(key)? { Some(value) => match value.validate_decimal(false, py) { - Ok(v) => Ok(Some(v.into_inner().unbind())), + Ok(v) => { return Ok(Some(v.into_inner().unbind()))}, Err(_) => Err(PyValueError::new_err(format!( "'{key}' must be coercible to a Decimal instance", ))), diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index b716b198c..ad2f78680 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -3,13 +3,13 @@ use std::sync::Arc; use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::intern; use pyo3::sync::PyOnceLock; -use pyo3::types::{PyDict, PyString, PyType}; +use pyo3::types::{IntoPyDict, PyDict, PyString, PyType}; use pyo3::{prelude::*, PyTypeInfo}; use crate::build_tools::is_strict; use crate::errors::ErrorTypeDefaults; use crate::errors::ValResult; -use crate::errors::{ToErrorValue, ValError}; +use crate::errors::{ToErrorValue, ValError, Number, ErrorType}; use crate::input::Input; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; @@ -92,50 +92,50 @@ impl Validator for FractionValidator { // } // }; - // if let Some(le) = &self.le { - // if is_nan()? || !decimal.le(le)? { - // return Err(ValError::new( - // ErrorType::LessThanEqual { - // le: Number::String(le.to_string()), - // context: Some([("le", le)].into_py_dict(py)?.into()), - // }, - // input, - // )); - // } - // } - // if let Some(lt) = &self.lt { - // if is_nan()? || !decimal.lt(lt)? { - // return Err(ValError::new( - // ErrorType::LessThan { - // lt: Number::String(lt.to_string()), - // context: Some([("lt", lt)].into_py_dict(py)?.into()), - // }, - // input, - // )); - // } - // } - // if let Some(ge) = &self.ge { - // if is_nan()? || !decimal.ge(ge)? { - // return Err(ValError::new( - // ErrorType::GreaterThanEqual { - // ge: Number::String(ge.to_string()), - // context: Some([("ge", ge)].into_py_dict(py)?.into()), - // }, - // input, - // )); - // } - // } - // if let Some(gt) = &self.gt { - // if is_nan()? || !decimal.gt(gt)? { - // return Err(ValError::new( - // ErrorType::GreaterThan { - // gt: Number::String(gt.to_string()), - // context: Some([("gt", gt)].into_py_dict(py)?.into()), - // }, - // input, - // )); - // } - // } + if let Some(le) = &self.le { + if !fraction.le(le)? { + return Err(ValError::new( + ErrorType::LessThanEqual { + le: Number::String(le.to_string()), + context: Some([("le", le)].into_py_dict(py)?.into()), + }, + input, + )); + } + } + if let Some(lt) = &self.lt { + if !fraction.lt(lt)? { + return Err(ValError::new( + ErrorType::LessThan { + lt: Number::String(lt.to_string()), + context: Some([("lt", lt)].into_py_dict(py)?.into()), + }, + input, + )); + } + } + if let Some(ge) = &self.ge { + if !fraction.ge(ge)? { + return Err(ValError::new( + ErrorType::GreaterThanEqual { + ge: Number::String(ge.to_string()), + context: Some([("ge", ge)].into_py_dict(py)?.into()), + }, + input, + )); + } + } + if let Some(gt) = &self.gt { + if !fraction.gt(gt)? { + return Err(ValError::new( + ErrorType::GreaterThan { + gt: Number::String(gt.to_string()), + context: Some([("gt", gt)].into_py_dict(py)?.into()), + }, + input, + )); + } + } Ok(fraction.into()) } diff --git a/tests/validators/test_fraction.py b/tests/validators/test_fraction.py new file mode 100644 index 000000000..dd28b4ccc --- /dev/null +++ b/tests/validators/test_fraction.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import re +from fractions import Fraction +from typing import Any + +import pytest +from dirty_equals import IsStr + +from pydantic_core import SchemaError, SchemaValidator, ValidationError +from pydantic_core import core_schema as cs + +from ..conftest import Err, PyAndJson, plain_repr + + +class FractionSubclass(Fraction): + pass + + +@pytest.mark.parametrize( + 'constraint', + ['le', 'lt', 'ge', 'gt'], +) +def test_constraints_schema_validation_error(constraint: str) -> None: + with pytest.raises(SchemaError, match=f"'{constraint}' must be coercible to a Fraction instance"): + SchemaValidator(cs.fraction_schema(**{constraint: 'bad_value'})) + + +def test_constraints_schema_validation() -> None: + val = SchemaValidator(cs.fraction_schema(gt='1')) + with pytest.raises(ValidationError): + val.validate_python('0') + + +@pytest.mark.parametrize( + 'input_value,expected', + [ + (0, Fraction(0)), + (1, Fraction(1)), + (42, Fraction(42)), + ('42', Fraction(42)), + ('42.123', Fraction('42.123')), + (42.0, Fraction(42)), + (42.5, Fraction('42.5')), + (1e10, Fraction('1E10')), + (Fraction('42.0'), Fraction(42)), + (Fraction('42.5'), Fraction('42.5')), + (Fraction('1e10'), Fraction('1E10')), + ( + Fraction('123456789123456789123456789.123456789123456789123456789'), + Fraction('123456789123456789123456789.123456789123456789123456789'), + ), + (FractionSubclass('42.0'), Fraction(42)), + (FractionSubclass('42.5'), Fraction('42.5')), + (FractionSubclass('1e10'), Fraction('1E10')), + ( + True, + Err( + 'Fraction input should be an integer, float, string or Fraction object [type=fraction_type, input_value=True, input_type=bool]' + ), + ), + ( + False, + Err( + 'Fraction input should be an integer, float, string or Fraction object [type=fraction_type, input_value=False, input_type=bool]' + ), + ), + ('wrong', Err('Input should be a valid fraction [type=fraction_parsing')), + ( + [1, 2], + Err( + 'Fraction input should be an integer, float, string or Fraction object [type=fraction_type, input_value=[1, 2], input_type=list]' + ), + ), + ], +) +def test_fraction(py_and_json: PyAndJson, input_value, expected): + v = py_and_json({'type': 'fraction'}) + # Fraction types are not JSON serializable + if v.validator_type == 'json' and isinstance(input_value, Fraction): + input_value = str(input_value) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_test(input_value) + else: + output = v.validate_test(input_value) + assert output == expected + assert isinstance(output, Fraction) + + +@pytest.mark.parametrize( + 'input_value,expected', + [ + (Fraction(0), Fraction(0)), + (Fraction(1), Fraction(1)), + (Fraction(42), Fraction(42)), + (Fraction('42.0'), Fraction('42.0')), + (Fraction('42.5'), Fraction('42.5')), + (42.0, Err('Input should be an instance of Fraction [type=is_instance_of, input_value=42.0, input_type=float]')), + ('42', Err("Input should be an instance of Fraction [type=is_instance_of, input_value='42', input_type=str]")), + (42, Err('Input should be an instance of Fraction [type=is_instance_of, input_value=42, input_type=int]')), + (True, Err('Input should be an instance of Fraction [type=is_instance_of, input_value=True, input_type=bool]')), + ], + ids=repr, +) +def test_fraction_strict_py(input_value, expected): + v = SchemaValidator(cs.fraction_schema(strict=True)) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_python(input_value) + else: + output = v.validate_python(input_value) + assert output == expected + assert isinstance(output, Fraction) + + +@pytest.mark.parametrize( + 'input_value,expected', + [ + (0, Fraction(0)), + (1, Fraction(1)), + (42, Fraction(42)), + ('42.0', Fraction('42.0')), + ('42.5', Fraction('42.5')), + (42.0, Fraction('42.0')), + ('42', Fraction('42')), + ( + True, + Err( + 'Fraction input should be an integer, float, string or Fraction object [type=fraction_type, input_value=True, input_type=bool]' + ), + ), + ], + ids=repr, +) +def test_fraction_strict_json(input_value, expected): + v = SchemaValidator(cs.fraction_schema(strict=True)) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_json(json.dumps(input_value)) + else: + output = v.validate_json(json.dumps(input_value)) + assert output == expected + assert isinstance(output, Fraction) + + +@pytest.mark.parametrize( + 'kwargs,input_value,expected', + [ + ({}, 0, Fraction(0)), + ({}, '123.456', Fraction('123.456')), + ({'ge': 0}, 0, Fraction(0)), + ( + {'ge': 0}, + -0.1, + Err( + 'Input should be greater than or equal to 0 ' + '[type=greater_than_equal, input_value=-0.1, input_type=float]' + ), + ), + ({'gt': 0}, 0.1, Fraction('0.1')), + ({'gt': 0}, 0, Err('Input should be greater than 0 [type=greater_than, input_value=0, input_type=int]')), + ({'le': 0}, 0, Fraction(0)), + ({'le': 0}, -1, Fraction(-1)), + ({'le': 0}, 0.1, Err('Input should be less than or equal to 0')), + ({'lt': 0}, 0, Err('Input should be less than 0')), + ({'lt': 0.123456}, 1, Err('Input should be less than 1929/15625')), + ({'lt': 0.123456}, '0.1', Fraction('0.1')), + ], +) +def test_fraction_kwargs(py_and_json: PyAndJson, kwargs: dict[str, Any], input_value, expected): + v = py_and_json({'type': 'fraction', **kwargs}) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_test(input_value) + else: + output = v.validate_test(input_value) + assert output == expected + assert isinstance(output, Fraction) + + +def test_union_fraction_py(): + v = SchemaValidator(cs.union_schema(choices=[cs.fraction_schema(strict=True), cs.fraction_schema(gt=0)])) + assert v.validate_python('14') == 14 + assert v.validate_python(Fraction(5)) == 5 + with pytest.raises(ValidationError) as exc_info: + v.validate_python('-5') + assert exc_info.value.errors(include_url=False) == [ + { + 'type': 'is_instance_of', + 'loc': ('fraction',), + 'msg': 'Input should be an instance of Fraction', + 'input': '-5', + 'ctx': {'class': 'Fraction'}, + }, + { + 'type': 'greater_than', + 'loc': ('fraction',), + 'msg': 'Input should be greater than 0', + 'input': '-5', + 'ctx': {'gt': Fraction(0)}, + }, + ] + + +def test_union_fraction_json(): + v = SchemaValidator(cs.union_schema(choices=[cs.fraction_schema(strict=True), cs.fraction_schema(gt=0)])) + assert v.validate_json(json.dumps('14')) == 14 + assert v.validate_json(json.dumps('5')) == 5 + + +def test_union_fraction_simple(py_and_json: PyAndJson): + v = py_and_json({'type': 'union', 'choices': [{'type': 'fraction'}, {'type': 'list'}]}) + assert v.validate_test('5') == 5 + with pytest.raises(ValidationError) as exc_info: + v.validate_test('xxx') + + assert exc_info.value.errors(include_url=False) == [ + {'type': 'fraction_parsing', 'loc': ('fraction',), 'msg': 'Input should be a valid fraction', 'input': 'xxx'}, + { + 'type': 'list_type', + 'loc': ('list[any]',), + 'msg': IsStr(regex='Input should be a valid (list|array)'), + 'input': 'xxx', + }, + ] + + +def test_fraction_repr(): + v = SchemaValidator(cs.fraction_schema()) + assert plain_repr(v).startswith( + 'SchemaValidator(title="fraction",validator=Fraction(FractionValidator{strict:false' + ) + v = SchemaValidator(cs.fraction_schema(strict=True)) + assert plain_repr(v).startswith( + 'SchemaValidator(title="fraction",validator=Fraction(FractionValidator{strict:true' + ) + + +@pytest.mark.parametrize('input_value,expected', [(Fraction('1.23'), Fraction('1.23')), (Fraction('1'), Fraction('1.0'))]) +def test_fraction_not_json(input_value, expected): + v = SchemaValidator(cs.fraction_schema()) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_python(input_value) + else: + output = v.validate_python(input_value) + assert output == expected + assert isinstance(output, Fraction) + + +def test_fraction_key(py_and_json: PyAndJson): + v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'fraction'}, 'values_schema': {'type': 'int'}}) + assert v.validate_test({'1': 1, '2': 2}) == {Fraction('1'): 1, Fraction('2'): 2} + assert v.validate_test({'1.5': 1, '2.4': 2}) == {Fraction('1.5'): 1, Fraction('2.4'): 2} + if v.validator_type == 'python': + with pytest.raises(ValidationError, match='Input should be an instance of Fraction'): + v.validate_test({'1.5': 1, '2.5': 2}, strict=True) + else: + assert v.validate_test({'1.5': 1, '2.4': 2}, strict=True) == {Fraction('1.5'): 1, Fraction('2.4'): 2} + + +@pytest.mark.parametrize( + 'input_value,expected', + [ + ('NaN', Err("Input should be a valid fraction [type=fraction_parsing, input_value='NaN', input_type=str]")), + ('0.7', Fraction('0.7')), + ( + 'pika', + Err("Input should be a valid fraction [type=fraction_parsing, input_value='pika', input_type=str]"), + ), + ], +) +def test_non_finite_json_values(py_and_json: PyAndJson, input_value, expected): + v = py_and_json({'type': 'fraction'}) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_test(input_value) + else: + assert v.validate_test(input_value) == expected + + +@pytest.mark.parametrize( + 'input_value,expected', + [ + # lower e, minus + ('1.0e-12', Fraction('1e-12')), + ('1e-12', Fraction('1e-12')), + ('12e-1', Fraction('12e-1')), + # upper E, minus + ('1.0E-12', Fraction('1e-12')), + ('1E-12', Fraction('1e-12')), + ('12E-1', Fraction('12e-1')), + # lower E, plus + ('1.0e+12', Fraction(' 1e12')), + ('1e+12', Fraction(' 1e12')), + ('12e+1', Fraction(' 12e1')), + # upper E, plus + ('1.0E+12', Fraction(' 1e12')), + ('1E+12', Fraction(' 1e12')), + ('12E+1', Fraction(' 12e1')), + # lower E, unsigned + ('1.0e12', Fraction(' 1e12')), + ('1e12', Fraction(' 1e12')), + ('12e1', Fraction(' 12e1')), + # upper E, unsigned + ('1.0E12', Fraction(' 1e12')), + ('1E12', Fraction(' 1e12')), + ('12E1', Fraction(' 12e1')), + ], +) +def test_validate_scientific_notation_from_json(input_value, expected): + v = SchemaValidator(cs.fraction_schema()) + assert v.validate_json(input_value) == expected + + +def test_str_validation_w_strict() -> None: + s = SchemaValidator(cs.fraction_schema(strict=True)) + + with pytest.raises(ValidationError): + assert s.validate_python('1.23') + + +def test_str_validation_w_lax() -> None: + s = SchemaValidator(cs.fraction_schema(strict=False)) + + assert s.validate_python('1.23') == Fraction('1.23') + + +def test_union_with_str_prefers_str() -> None: + s = SchemaValidator(cs.union_schema([cs.fraction_schema(), cs.str_schema()])) + + assert s.validate_python('1.23') == '1.23' + assert s.validate_python(1.23) == Fraction('1.23') From 556009db8c56a3b7e8efee2fbddf15119eca70a0 Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Sun, 2 Nov 2025 19:19:19 +0100 Subject: [PATCH 11/12] remove debug statement from decimal --- src/validators/decimal.rs | 2 +- src/validators/fraction.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/validators/decimal.rs b/src/validators/decimal.rs index 52e258dbe..56f0aa766 100644 --- a/src/validators/decimal.rs +++ b/src/validators/decimal.rs @@ -37,7 +37,7 @@ fn validate_as_decimal( ) -> PyResult>> { match schema.get_item(key)? { Some(value) => match value.validate_decimal(false, py) { - Ok(v) => { return Ok(Some(v.into_inner().unbind()))}, + Ok(v) => Ok(Some(v.into_inner().unbind())), Err(_) => Err(PyValueError::new_err(format!( "'{key}' must be coercible to a Decimal instance", ))), diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index ad2f78680..d2ef7af8f 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -9,7 +9,7 @@ use pyo3::{prelude::*, PyTypeInfo}; use crate::build_tools::is_strict; use crate::errors::ErrorTypeDefaults; use crate::errors::ValResult; -use crate::errors::{ToErrorValue, ValError, Number, ErrorType}; +use crate::errors::{ErrorType, Number, ToErrorValue, ValError}; use crate::input::Input; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; From fb5cecb222107f27ea079823e0472f63917c5ee8 Mon Sep 17 00:00:00 2001 From: Tobias Pitters Date: Sun, 2 Nov 2025 19:22:31 +0100 Subject: [PATCH 12/12] adjustments based on PR comments --- src/input/shared.rs | 1 - src/validators/fraction.rs | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/input/shared.rs b/src/input/shared.rs index b0deb9869..45120d639 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -234,7 +234,6 @@ pub fn fraction_as_int<'py>( ) -> ValResult> { let py = fraction.py(); - // as_integer_ratio was added in Python 3.8, so this should be fine let (numerator, denominator) = fraction .call_method0(intern!(py, "as_integer_ratio"))? .extract::<(Bound<'_, PyAny>, Bound<'_, PyAny>)>()?; diff --git a/src/validators/fraction.rs b/src/validators/fraction.rs index d2ef7af8f..58d2606e6 100644 --- a/src/validators/fraction.rs +++ b/src/validators/fraction.rs @@ -84,14 +84,6 @@ impl Validator for FractionValidator { ) -> ValResult> { let fraction = input.validate_fraction(state.strict_or(self.strict), py)?.unpack(state); - // let mut is_nan: Option = None; - // let mut is_nan = || -> PyResult { - // match is_nan { - // Some(is_nan) => Ok(is_nan), - // None => Ok(*is_nan.insert(decimal.call_method0(intern!(py, "is_nan"))?.extract()?)), - // } - // }; - if let Some(le) = &self.le { if !fraction.le(le)? { return Err(ValError::new(