Add `num-rational` support for Python's `fractions.Fraction` type (#4148)

* Add `num-rational` support for Python's `fractions.Fraction` type

* Add newsfragment

* Use Bound instead

* Handle objs which atts are incorrect

* Add extra test

* Add tests for wasm32 arch

* add type for wasm32 clipppy
This commit is contained in:
David Matos 2024-05-09 17:37:53 +02:00 committed by GitHub
parent 635cb8075c
commit 2d19b7e2a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 291 additions and 0 deletions

View File

@ -40,6 +40,7 @@ hashbrown = { version = ">= 0.9, < 0.15", optional = true }
indexmap = { version = ">= 1.6, < 3", optional = true }
num-bigint = { version = "0.4", optional = true }
num-complex = { version = ">= 0.2, < 0.5", optional = true }
num-rational = {version = "0.4.1", optional = true }
rust_decimal = { version = "1.0.0", default-features = false, optional = true }
serde = { version = "1.0", optional = true }
smallvec = { version = "1.0", optional = true }
@ -127,6 +128,7 @@ full = [
"indexmap",
"num-bigint",
"num-complex",
"num-rational",
"rust_decimal",
"serde",
"smallvec",

View File

@ -19,6 +19,7 @@ The table below contains the Python type and the corresponding function argument
| `int` | `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `i128`, `u128`, `isize`, `usize`, `num_bigint::BigInt`[^1], `num_bigint::BigUint`[^1] | `PyLong` |
| `float` | `f32`, `f64` | `PyFloat` |
| `complex` | `num_complex::Complex`[^2] | `PyComplex` |
| `fractions.Fraction`| `num_rational::Ratio`[^8] | - |
| `list[T]` | `Vec<T>` | `PyList` |
| `dict[K, V]` | `HashMap<K, V>`, `BTreeMap<K, V>`, `hashbrown::HashMap<K, V>`[^3], `indexmap::IndexMap<K, V>`[^4] | `PyDict` |
| `tuple[T, U]` | `(T, U)`, `Vec<T>` | `PyTuple` |
@ -113,3 +114,5 @@ Finally, the following Rust types are also able to convert to Python as return v
[^6]: Requires the `chrono-tz` optional feature.
[^7]: Requires the `rust_decimal` optional feature.
[^8]: Requires the `num-rational` optional feature.

View File

@ -157,6 +157,10 @@ Adds a dependency on [num-bigint](https://docs.rs/num-bigint) and enables conver
Adds a dependency on [num-complex](https://docs.rs/num-complex) and enables conversions into its [`Complex`](https://docs.rs/num-complex/latest/num_complex/struct.Complex.html) type.
### `num-rational`
Adds a dependency on [num-rational](https://docs.rs/num-rational) and enables conversions into its [`Ratio`](https://docs.rs/num-rational/latest/num_rational/struct.Ratio.html) type.
### `rust_decimal`
Adds a dependency on [rust_decimal](https://docs.rs/rust_decimal) and enables conversions into its [`Decimal`](https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html) type.

View File

@ -0,0 +1 @@
Conversion between [num-rational](https://github.com/rust-num/num-rational) and Python's fractions.Fraction.

View File

@ -9,6 +9,7 @@ pub mod hashbrown;
pub mod indexmap;
pub mod num_bigint;
pub mod num_complex;
pub mod num_rational;
pub mod rust_decimal;
pub mod serde;
pub mod smallvec;

View File

@ -0,0 +1,277 @@
#![cfg(feature = "num-rational")]
//! Conversions to and from [num-rational](https://docs.rs/num-rational) types.
//!
//! This is useful for converting between Python's [fractions.Fraction](https://docs.python.org/3/library/fractions.html) into and from a native Rust
//! type.
//!
//!
//! To use this feature, add to your **`Cargo.toml`**:
//!
//! ```toml
//! [dependencies]
#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"num-rational\"] }")]
//! num-rational = "0.4.1"
//! ```
//!
//! # Example
//!
//! Rust code to create a function that adds five to a fraction:
//!
//! ```rust
//! use num_rational::Ratio;
//! use pyo3::prelude::*;
//!
//! #[pyfunction]
//! fn add_five_to_fraction(fraction: Ratio<i32>) -> Ratio<i32> {
//! fraction + Ratio::new(5, 1)
//! }
//!
//! #[pymodule]
//! fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
//! m.add_function(wrap_pyfunction!(add_five_to_fraction, m)?)?;
//! Ok(())
//! }
//! ```
//!
//! Python code that validates the functionality:
//! ```python
//! from my_module import add_five_to_fraction
//! from fractions import Fraction
//!
//! fraction = Fraction(2,1)
//! fraction_plus_five = add_five_to_fraction(f)
//! assert fraction + 5 == fraction_plus_five
//! ```
use crate::ffi;
use crate::sync::GILOnceCell;
use crate::types::any::PyAnyMethods;
use crate::types::PyType;
use crate::{Bound, FromPyObject, IntoPy, Py, PyAny, PyObject, PyResult, Python, ToPyObject};
use std::os::raw::c_char;
#[cfg(feature = "num-bigint")]
use num_bigint::BigInt;
use num_rational::Ratio;
static FRACTION_CLS: GILOnceCell<Py<PyType>> = GILOnceCell::new();
fn get_fraction_cls(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> {
FRACTION_CLS.get_or_try_init_type_ref(py, "fractions", "Fraction")
}
macro_rules! rational_conversion {
($int: ty) => {
impl<'py> FromPyObject<'py> for Ratio<$int> {
fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult<Self> {
let py = obj.py();
let py_numerator_obj = unsafe {
Bound::from_owned_ptr_or_err(
py,
ffi::PyObject_GetAttrString(
obj.as_ptr(),
"numerator\0".as_ptr() as *const c_char,
),
)
};
let py_denominator_obj = unsafe {
Bound::from_owned_ptr_or_err(
py,
ffi::PyObject_GetAttrString(
obj.as_ptr(),
"denominator\0".as_ptr() as *const c_char,
),
)
};
let numerator_owned = unsafe {
Bound::from_owned_ptr_or_err(
py,
ffi::PyNumber_Long(py_numerator_obj?.as_ptr()),
)?
};
let denominator_owned = unsafe {
Bound::from_owned_ptr_or_err(
py,
ffi::PyNumber_Long(py_denominator_obj?.as_ptr()),
)?
};
let rs_numerator: $int = numerator_owned.extract()?;
let rs_denominator: $int = denominator_owned.extract()?;
Ok(Ratio::new(rs_numerator, rs_denominator))
}
}
impl ToPyObject for Ratio<$int> {
fn to_object(&self, py: Python<'_>) -> PyObject {
let fraction_cls = get_fraction_cls(py).expect("failed to load fractions.Fraction");
let ret = fraction_cls
.call1((self.numer().clone(), self.denom().clone()))
.expect("failed to call fractions.Fraction(value)");
ret.to_object(py)
}
}
impl IntoPy<PyObject> for Ratio<$int> {
fn into_py(self, py: Python<'_>) -> PyObject {
self.to_object(py)
}
}
};
}
rational_conversion!(i8);
rational_conversion!(i16);
rational_conversion!(i32);
rational_conversion!(isize);
rational_conversion!(i64);
#[cfg(feature = "num-bigint")]
rational_conversion!(BigInt);
#[cfg(test)]
mod tests {
use super::*;
use crate::types::dict::PyDictMethods;
use crate::types::PyDict;
#[cfg(not(target_arch = "wasm32"))]
use proptest::prelude::*;
#[test]
fn test_negative_fraction() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
py.run_bound(
"import fractions\npy_frac = fractions.Fraction(-0.125)",
None,
Some(&locals),
)
.unwrap();
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
let rs_frac = Ratio::new(-1, 8);
assert_eq!(roundtripped, rs_frac);
})
}
#[test]
fn test_obj_with_incorrect_atts() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
py.run_bound(
"not_fraction = \"contains_incorrect_atts\"",
None,
Some(&locals),
)
.unwrap();
let py_frac = locals.get_item("not_fraction").unwrap().unwrap();
assert!(py_frac.extract::<Ratio<i32>>().is_err());
})
}
#[test]
fn test_fraction_with_fraction_type() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
py.run_bound(
"import fractions\npy_frac = fractions.Fraction(fractions.Fraction(10))",
None,
Some(&locals),
)
.unwrap();
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
let rs_frac = Ratio::new(10, 1);
assert_eq!(roundtripped, rs_frac);
})
}
#[test]
fn test_fraction_with_decimal() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
py.run_bound(
"import fractions\n\nfrom decimal import Decimal\npy_frac = fractions.Fraction(Decimal(\"1.1\"))",
None,
Some(&locals),
)
.unwrap();
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
let rs_frac = Ratio::new(11, 10);
assert_eq!(roundtripped, rs_frac);
})
}
#[test]
fn test_fraction_with_num_den() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
py.run_bound(
"import fractions\npy_frac = fractions.Fraction(10,5)",
None,
Some(&locals),
)
.unwrap();
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
let rs_frac = Ratio::new(10, 5);
assert_eq!(roundtripped, rs_frac);
})
}
#[cfg(target_arch = "wasm32")]
#[test]
fn test_int_roundtrip() {
Python::with_gil(|py| {
let rs_frac = Ratio::new(1, 2);
let py_frac: PyObject = rs_frac.into_py(py);
let roundtripped: Ratio<i32> = py_frac.extract(py).unwrap();
assert_eq!(rs_frac, roundtripped);
// float conversion
})
}
#[cfg(target_arch = "wasm32")]
#[test]
fn test_big_int_roundtrip() {
Python::with_gil(|py| {
let rs_frac = Ratio::from_float(5.5).unwrap();
let py_frac: PyObject = rs_frac.clone().into_py(py);
let roundtripped: Ratio<BigInt> = py_frac.extract(py).unwrap();
assert_eq!(rs_frac, roundtripped);
})
}
#[cfg(not(target_arch = "wasm32"))]
proptest! {
#[test]
fn test_int_roundtrip(num in any::<i32>(), den in any::<i32>()) {
Python::with_gil(|py| {
let rs_frac = Ratio::new(num, den);
let py_frac = rs_frac.into_py(py);
let roundtripped: Ratio<i32> = py_frac.extract(py).unwrap();
assert_eq!(rs_frac, roundtripped);
})
}
#[test]
#[cfg(feature = "num-bigint")]
fn test_big_int_roundtrip(num in any::<f32>()) {
Python::with_gil(|py| {
let rs_frac = Ratio::from_float(num).unwrap();
let py_frac = rs_frac.clone().into_py(py);
let roundtripped: Ratio<BigInt> = py_frac.extract(py).unwrap();
assert_eq!(roundtripped, rs_frac);
})
}
}
#[test]
fn test_infinity() {
Python::with_gil(|py| {
let locals = PyDict::new_bound(py);
let py_bound = py.run_bound(
"import fractions\npy_frac = fractions.Fraction(\"Infinity\")",
None,
Some(&locals),
);
assert!(py_bound.is_err());
})
}
}

View File

@ -108,6 +108,7 @@
//! [`BigUint`] types.
//! - [`num-complex`]: Enables conversions between Python objects and [num-complex]'s [`Complex`]
//! type.
//! - [`num-rational`]: Enables conversions between Python's fractions.Fraction and [num-rational]'s types
//! - [`rust_decimal`]: Enables conversions between Python's decimal.Decimal and [rust_decimal]'s
//! [`Decimal`] type.
//! - [`serde`]: Allows implementing [serde]'s [`Serialize`] and [`Deserialize`] traits for
@ -288,6 +289,7 @@
//! [`maturin`]: https://github.com/PyO3/maturin "Build and publish crates with pyo3, rust-cpython and cffi bindings as well as rust binaries as python packages"
//! [`num-bigint`]: ./num_bigint/index.html "Documentation about the `num-bigint` feature."
//! [`num-complex`]: ./num_complex/index.html "Documentation about the `num-complex` feature."
//! [`num-rational`]: ./num_rational/index.html "Documentation about the `num-rational` feature."
//! [`pyo3-build-config`]: https://docs.rs/pyo3-build-config
//! [rust_decimal]: https://docs.rs/rust_decimal
//! [`rust_decimal`]: ./rust_decimal/index.html "Documenation about the `rust_decimal` feature."
@ -303,6 +305,7 @@
//! [manual_builds]: https://pyo3.rs/latest/building-and-distribution.html#manual-builds "Manual builds - Building and Distribution - PyO3 user guide"
//! [num-bigint]: https://docs.rs/num-bigint
//! [num-complex]: https://docs.rs/num-complex
//! [num-rational]: https://docs.rs/num-rational
//! [serde]: https://docs.rs/serde
//! [setuptools-rust]: https://github.com/PyO3/setuptools-rust "Setuptools plugin for Rust extensions"
//! [the guide]: https://pyo3.rs "PyO3 user guide"