From adf22ce616f72052b74d518633697bbce0d67609 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 May 2024 21:49:32 +0200 Subject: [PATCH 1/7] Add `num-rational` support for Python's `fractions.Fraction` type --- Cargo.toml | 2 + guide/src/conversions/tables.md | 3 + guide/src/features.md | 4 + src/conversions/mod.rs | 1 + src/conversions/num_rational.rs | 228 ++++++++++++++++++++++++++++++++ src/lib.rs | 3 + 6 files changed, 241 insertions(+) create mode 100644 src/conversions/num_rational.rs diff --git a/Cargo.toml b/Cargo.toml index 9202c69ef92..4c5a083060d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/guide/src/conversions/tables.md b/guide/src/conversions/tables.md index eb33b17acf7..208e61671ec 100644 --- a/guide/src/conversions/tables.md +++ b/guide/src/conversions/tables.md @@ -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` | `PyList` | | `dict[K, V]` | `HashMap`, `BTreeMap`, `hashbrown::HashMap`[^3], `indexmap::IndexMap`[^4] | `PyDict` | | `tuple[T, U]` | `(T, U)`, `Vec` | `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. diff --git a/guide/src/features.md b/guide/src/features.md index 0816770a781..07085a9e89c 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -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. diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 3d785c02381..53ecf849c07 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -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; diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs new file mode 100644 index 00000000000..e099b0ba6ea --- /dev/null +++ b/src/conversions/num_rational.rs @@ -0,0 +1,228 @@ +#![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) -> Ratio { +//! 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::PyLong; +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> = 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 { + let py = obj.py(); + let py_numerator_obj = unsafe { + ffi::PyObject_GetAttrString( + obj.as_ptr(), + "numerator\0".as_ptr() as *const c_char, + ) + }; + let py_denominator_obj = unsafe { + ffi::PyObject_GetAttrString( + obj.as_ptr(), + "denominator\0".as_ptr() as *const c_char, + ) + }; + let numerator_owned: Py = + unsafe { Py::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_numerator_obj))? }; + let denominator_owned: Py = unsafe { + Py::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_denominator_obj))? + }; + let rs_numerator: $int = numerator_owned.bind(py).extract()?; + let rs_denominator: $int = denominator_owned.bind(py).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.to_string(),)) + .expect("failed to call fractions.Fraction(value)"); + ret.to_object(py) + } + } + impl IntoPy 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 = py_frac.extract().unwrap(); + let rs_frac = Ratio::new(-1, 8); + assert_eq!(roundtripped, rs_frac); + }) + } + + #[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 = 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 = 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 = py_frac.extract().unwrap(); + let rs_frac = Ratio::new(10, 5); + assert_eq!(roundtripped, rs_frac); + }) + } + + proptest! { + #[test] + fn test_int_roundtrip(num in any::(), den in any::()) { + Python::with_gil(|py| { + let rs_frac = Ratio::new(num, den); + let py_frac = rs_frac.into_py(py); + let roundtripped: Ratio = py_frac.extract(py).unwrap(); + assert_eq!(rs_frac, roundtripped); + }) + } + + #[test] + #[cfg(feature = "num-bigint")] + fn test_big_int_roundtrip(num in any::()) { + Python::with_gil(|py| { + let rs_frac = Ratio::from_float(num).unwrap(); + let py_frac = rs_frac.clone().into_py(py); + let roundtripped: Ratio = 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()); + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index b400f143f5a..3923257f5f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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" From d18eadf77cb8b090d19ddc922626457294c012f0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 May 2024 22:14:57 +0200 Subject: [PATCH 2/7] Add newsfragment --- newsfragments/4148.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/4148.added.md diff --git a/newsfragments/4148.added.md b/newsfragments/4148.added.md new file mode 100644 index 00000000000..16da3d2db37 --- /dev/null +++ b/newsfragments/4148.added.md @@ -0,0 +1 @@ +Conversion between [num-rational](https://github.com/rust-num/num-rational) and Python's fractions.Fraction. From efcfc77e0c7cb96a11dac288847970c9e3e2a609 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 2 May 2024 20:05:59 +0200 Subject: [PATCH 3/7] Use Bound instead --- src/conversions/num_rational.rs | 38 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs index e099b0ba6ea..83e1c1fea00 100644 --- a/src/conversions/num_rational.rs +++ b/src/conversions/num_rational.rs @@ -46,7 +46,6 @@ use crate::ffi; use crate::sync::GILOnceCell; use crate::types::any::PyAnyMethods; -use crate::types::PyLong; use crate::types::PyType; use crate::{Bound, FromPyObject, IntoPy, Py, PyAny, PyObject, PyResult, Python, ToPyObject}; use std::os::raw::c_char; @@ -67,24 +66,34 @@ macro_rules! rational_conversion { fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult { let py = obj.py(); let py_numerator_obj = unsafe { - ffi::PyObject_GetAttrString( - obj.as_ptr(), - "numerator\0".as_ptr() as *const c_char, + Bound::from_owned_ptr( + py, + ffi::PyObject_GetAttrString( + obj.as_ptr(), + "numerator\0".as_ptr() as *const c_char, + ), ) }; let py_denominator_obj = unsafe { - ffi::PyObject_GetAttrString( - obj.as_ptr(), - "denominator\0".as_ptr() as *const c_char, + Bound::from_owned_ptr( + py, + ffi::PyObject_GetAttrString( + obj.as_ptr(), + "denominator\0".as_ptr() as *const c_char, + ), ) }; - let numerator_owned: Py = - unsafe { Py::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_numerator_obj))? }; - let denominator_owned: Py = unsafe { - Py::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_denominator_obj))? + let numerator_owned = unsafe { + Bound::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_numerator_obj.as_ptr()))? }; - let rs_numerator: $int = numerator_owned.bind(py).extract()?; - let rs_denominator: $int = denominator_owned.bind(py).extract()?; + 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)) } } @@ -93,7 +102,7 @@ macro_rules! rational_conversion { 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.to_string(),)) + .call1((self.numer().clone(), self.denom().clone())) .expect("failed to call fractions.Fraction(value)"); ret.to_object(py) } @@ -105,7 +114,6 @@ macro_rules! rational_conversion { } }; } - rational_conversion!(i8); rational_conversion!(i16); rational_conversion!(i32); From 951bdf00f4f820beedb736df451e1b4bca6186c6 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 2 May 2024 21:22:15 +0200 Subject: [PATCH 4/7] Handle objs which atts are incorrect --- src/conversions/num_rational.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs index 83e1c1fea00..e1f69ccdb90 100644 --- a/src/conversions/num_rational.rs +++ b/src/conversions/num_rational.rs @@ -66,7 +66,7 @@ macro_rules! rational_conversion { fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult { let py = obj.py(); let py_numerator_obj = unsafe { - Bound::from_owned_ptr( + Bound::from_owned_ptr_or_err( py, ffi::PyObject_GetAttrString( obj.as_ptr(), @@ -75,7 +75,7 @@ macro_rules! rational_conversion { ) }; let py_denominator_obj = unsafe { - Bound::from_owned_ptr( + Bound::from_owned_ptr_or_err( py, ffi::PyObject_GetAttrString( obj.as_ptr(), @@ -84,12 +84,15 @@ macro_rules! rational_conversion { ) }; let numerator_owned = unsafe { - Bound::from_owned_ptr_or_err(py, ffi::PyNumber_Long(py_numerator_obj.as_ptr()))? + 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()), + ffi::PyNumber_Long(py_denominator_obj?.as_ptr()), )? }; let rs_numerator: $int = numerator_owned.extract()?; @@ -145,6 +148,20 @@ mod tests { 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::>().is_err()); + }) + } #[test] fn test_fraction_with_fraction_type() { From bd5a5a506045bbdc2d3c13ecdb534f44d5d168aa Mon Sep 17 00:00:00 2001 From: David Date: Thu, 9 May 2024 08:55:48 +0200 Subject: [PATCH 5/7] Add extra test --- src/conversions/num_rational.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs index e1f69ccdb90..26c4d195747 100644 --- a/src/conversions/num_rational.rs +++ b/src/conversions/num_rational.rs @@ -163,6 +163,19 @@ mod tests { }) } + #[test] + fn test_fraction_as_tuples() { + Python::with_gil(|py| { + let locals = PyDict::new_bound(py); + let py_bound = py.run_bound( + "import fractions\npy_frac = fractions.Fraction(fractions.Fraction((10,)))", + None, + Some(&locals), + ); + assert!(py_bound.is_err()); + }) + } + #[test] fn test_fraction_with_fraction_type() { Python::with_gil(|py| { From 21c0e39b1391ec9db611ea6fd6b5d17ccecb8f60 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 9 May 2024 13:44:40 +0200 Subject: [PATCH 6/7] Add tests for wasm32 arch --- src/conversions/num_rational.rs | 37 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs index 26c4d195747..eb5513c8c8f 100644 --- a/src/conversions/num_rational.rs +++ b/src/conversions/num_rational.rs @@ -163,19 +163,6 @@ mod tests { }) } - #[test] - fn test_fraction_as_tuples() { - Python::with_gil(|py| { - let locals = PyDict::new_bound(py); - let py_bound = py.run_bound( - "import fractions\npy_frac = fractions.Fraction(fractions.Fraction((10,)))", - None, - Some(&locals), - ); - assert!(py_bound.is_err()); - }) - } - #[test] fn test_fraction_with_fraction_type() { Python::with_gil(|py| { @@ -227,6 +214,30 @@ mod tests { }) } + #[cfg(target_arch = "wasm32")] + #[test] + fn test_int_roundtrip() { + Python::with_gil(|py| { + let rs_frac = Ratio::new(1, 2); + let py_frac = rs_frac.into_py(py); + let roundtripped: Ratio = 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 = rs_frac.clone().into_py(py); + let roundtripped: Ratio = py_frac.extract(py).unwrap(); + assert_eq!(rs_frac, roundtripped); + }) + } + + #[cfg(not(target_arch = "wasm32"))] proptest! { #[test] fn test_int_roundtrip(num in any::(), den in any::()) { From 7941a930791599f7c8a2411f04a61541b46d76a5 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 9 May 2024 14:44:55 +0200 Subject: [PATCH 7/7] add type for wasm32 clipppy --- src/conversions/num_rational.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conversions/num_rational.rs b/src/conversions/num_rational.rs index eb5513c8c8f..31eb7ca1c7b 100644 --- a/src/conversions/num_rational.rs +++ b/src/conversions/num_rational.rs @@ -219,7 +219,7 @@ mod tests { fn test_int_roundtrip() { Python::with_gil(|py| { let rs_frac = Ratio::new(1, 2); - let py_frac = rs_frac.into_py(py); + let py_frac: PyObject = rs_frac.into_py(py); let roundtripped: Ratio = py_frac.extract(py).unwrap(); assert_eq!(rs_frac, roundtripped); // float conversion @@ -231,7 +231,7 @@ mod tests { fn test_big_int_roundtrip() { Python::with_gil(|py| { let rs_frac = Ratio::from_float(5.5).unwrap(); - let py_frac = rs_frac.clone().into_py(py); + let py_frac: PyObject = rs_frac.clone().into_py(py); let roundtripped: Ratio = py_frac.extract(py).unwrap(); assert_eq!(rs_frac, roundtripped); })