From 83b6fbad133356f7efda956c2c0e652f0ef9edbd Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:24:15 +0200 Subject: [PATCH 1/3] Properly coerce fractions as int --- src/input/input_python.rs | 24 ++++++++++++++++++++++++ tests/validators/test_int.py | 2 ++ 2 files changed, 26 insertions(+) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index e82cbaed7..b380a7397 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -3,6 +3,7 @@ use std::str::from_utf8; use pyo3::intern; use pyo3::prelude::*; +use pyo3::sync::GILOnceCell; use pyo3::types::PyType; use pyo3::types::{ PyBool, PyByteArray, PyBytes, PyComplex, PyDate, PyDateTime, PyDict, PyFloat, PyFrozenSet, PyInt, PyIterator, @@ -45,6 +46,20 @@ use super::{ Input, }; +static FRACTION_TYPE: GILOnceCell> = GILOnceCell::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()) } @@ -269,6 +284,15 @@ impl<'py> Input<'py> for Bound<'py, PyAny> { 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()))? { + #[cfg(Py_3_11)] + let as_int = self.call_method0("__int__"); + #[cfg(not(Py_3_11))] + let as_int = self.call_method0("__trunc__"); + match as_int { + Ok(i) => Ok(EitherInt::Py(i.as_any().to_owned())), + Err(_) => break 'lax, + } } 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/tests/validators/test_int.py b/tests/validators/test_int.py index 1de2e0f0a..f578d3314 100644 --- a/tests/validators/test_int.py +++ b/tests/validators/test_int.py @@ -1,6 +1,7 @@ import json import re from decimal import Decimal +from fractions import Fraction from typing import Any import pytest @@ -132,6 +133,7 @@ def test_int_py_and_json(py_and_json: PyAndJson, input_value, expected): (-i64_max + 1, -i64_max + 1), (i64_max * 2, i64_max * 2), (-i64_max * 2, -i64_max * 2), + (Fraction(10_935_244_710_974_505), 10_935_244_710_974_505), # https://github.com/pydantic/pydantic/issues/12063 pytest.param( 1.00000000001, Err( From 711b723adb8d6aa630d97838f0d128ee564046d6 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:54:49 +0200 Subject: [PATCH 2/3] Error if not integer --- src/input/input_python.rs | 23 ++++++++++++++++------- tests/validators/test_int.py | 10 +++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index b380a7397..d27dee2a1 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -285,13 +285,22 @@ 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 self.is_instance(get_fraction_type(self.py()))? { - #[cfg(Py_3_11)] - let as_int = self.call_method0("__int__"); - #[cfg(not(Py_3_11))] - let as_int = self.call_method0("__trunc__"); - match as_int { - Ok(i) => Ok(EitherInt::Py(i.as_any().to_owned())), - Err(_) => break 'lax, + #[cfg(Py_3_12)] + let is_integer = self.call_method0("is_integer")?.extract::()?; + #[cfg(not(Py_3_12))] + let is_integer = self.getattr("denominator")?.extract::().map_or(false, |d| d == 1); + + if is_integer { + #[cfg(Py_3_11)] + let as_int = self.call_method0("__int__"); + #[cfg(not(Py_3_11))] + let as_int = self.call_method0("__trunc__"); + match as_int { + Ok(i) => Ok(EitherInt::Py(i.as_any().to_owned())), + Err(_) => break 'lax, + } + } else { + Err(ValError::new(ErrorTypeDefaults::IntFromFloat, self)) } } else if let Ok(float) = self.extract::() { float_as_int(self, float) diff --git a/tests/validators/test_int.py b/tests/validators/test_int.py index f578d3314..22818c17f 100644 --- a/tests/validators/test_int.py +++ b/tests/validators/test_int.py @@ -134,13 +134,21 @@ def test_int_py_and_json(py_and_json: PyAndJson, input_value, expected): (i64_max * 2, i64_max * 2), (-i64_max * 2, -i64_max * 2), (Fraction(10_935_244_710_974_505), 10_935_244_710_974_505), # https://github.com/pydantic/pydantic/issues/12063 + pytest.param( + Fraction(1, 2), + Err( + 'Input should be a valid integer, got a number with a fractional part ' + '[type=int_from_float, input_value=Fraction(1, 2), input_type=Fraction]' + ), + id='fraction-remainder', + ), pytest.param( 1.00000000001, Err( 'Input should be a valid integer, got a number with a fractional part ' '[type=int_from_float, input_value=1.00000000001, input_type=float]' ), - id='decimal-remainder', + id='float-remainder', ), pytest.param( Decimal('1.001'), From 0ca928c6f6daffee40f3f100d82f6bfaccb3526b Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:00:06 +0200 Subject: [PATCH 3/3] As a func --- src/input/input_python.rs | 21 +++------------------ src/input/shared.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/input/input_python.rs b/src/input/input_python.rs index d27dee2a1..a51307b22 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -31,7 +31,8 @@ use super::input_abstract::ValMatch; use super::return_enums::EitherComplex; use super::return_enums::{iterate_attributes, iterate_mapping_items, ValidationMatch}; use super::shared::{ - decimal_as_int, float_as_int, get_enum_meta_object, int_as_bool, str_as_bool, str_as_float, str_as_int, + decimal_as_int, float_as_int, fraction_as_int, get_enum_meta_object, int_as_bool, str_as_bool, str_as_float, + str_as_int, }; use super::Arguments; use super::ConsumeIterator; @@ -285,23 +286,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 self.is_instance(get_fraction_type(self.py()))? { - #[cfg(Py_3_12)] - let is_integer = self.call_method0("is_integer")?.extract::()?; - #[cfg(not(Py_3_12))] - let is_integer = self.getattr("denominator")?.extract::().map_or(false, |d| d == 1); - - if is_integer { - #[cfg(Py_3_11)] - let as_int = self.call_method0("__int__"); - #[cfg(not(Py_3_11))] - let as_int = self.call_method0("__trunc__"); - match as_int { - Ok(i) => Ok(EitherInt::Py(i.as_any().to_owned())), - Err(_) => break 'lax, - } - } else { - Err(ValError::new(ErrorTypeDefaults::IntFromFloat, self)) - } + fraction_as_int(self) } 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 1a90b4142..8ca9c0013 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -227,3 +227,23 @@ 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)) + } +}