diff --git a/src/input/input_python.rs b/src/input/input_python.rs index e82cbaed7..a51307b22 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, @@ -30,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; @@ -45,6 +47,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 +285,8 @@ 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()))? { + 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)) + } +} diff --git a/tests/validators/test_int.py b/tests/validators/test_int.py index 1de2e0f0a..22818c17f 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,13 +133,22 @@ 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( + 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'),