Skip to content

Commit 2d19b7e

Browse files
authored
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
1 parent 635cb80 commit 2d19b7e

File tree

7 files changed

+291
-0
lines changed

7 files changed

+291
-0
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ hashbrown = { version = ">= 0.9, < 0.15", optional = true }
4040
indexmap = { version = ">= 1.6, < 3", optional = true }
4141
num-bigint = { version = "0.4", optional = true }
4242
num-complex = { version = ">= 0.2, < 0.5", optional = true }
43+
num-rational = {version = "0.4.1", optional = true }
4344
rust_decimal = { version = "1.0.0", default-features = false, optional = true }
4445
serde = { version = "1.0", optional = true }
4546
smallvec = { version = "1.0", optional = true }
@@ -127,6 +128,7 @@ full = [
127128
"indexmap",
128129
"num-bigint",
129130
"num-complex",
131+
"num-rational",
130132
"rust_decimal",
131133
"serde",
132134
"smallvec",

guide/src/conversions/tables.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The table below contains the Python type and the corresponding function argument
1919
| `int` | `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `i128`, `u128`, `isize`, `usize`, `num_bigint::BigInt`[^1], `num_bigint::BigUint`[^1] | `PyLong` |
2020
| `float` | `f32`, `f64` | `PyFloat` |
2121
| `complex` | `num_complex::Complex`[^2] | `PyComplex` |
22+
| `fractions.Fraction`| `num_rational::Ratio`[^8] | - |
2223
| `list[T]` | `Vec<T>` | `PyList` |
2324
| `dict[K, V]` | `HashMap<K, V>`, `BTreeMap<K, V>`, `hashbrown::HashMap<K, V>`[^3], `indexmap::IndexMap<K, V>`[^4] | `PyDict` |
2425
| `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
113114
[^6]: Requires the `chrono-tz` optional feature.
114115

115116
[^7]: Requires the `rust_decimal` optional feature.
117+
118+
[^8]: Requires the `num-rational` optional feature.

guide/src/features.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ Adds a dependency on [num-bigint](https://docs.rs/num-bigint) and enables conver
157157

158158
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.
159159

160+
### `num-rational`
161+
162+
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.
163+
160164
### `rust_decimal`
161165

162166
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.

newsfragments/4148.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Conversion between [num-rational](https://github.com/rust-num/num-rational) and Python's fractions.Fraction.

src/conversions/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod hashbrown;
99
pub mod indexmap;
1010
pub mod num_bigint;
1111
pub mod num_complex;
12+
pub mod num_rational;
1213
pub mod rust_decimal;
1314
pub mod serde;
1415
pub mod smallvec;

src/conversions/num_rational.rs

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
#![cfg(feature = "num-rational")]
2+
//! Conversions to and from [num-rational](https://docs.rs/num-rational) types.
3+
//!
4+
//! This is useful for converting between Python's [fractions.Fraction](https://docs.python.org/3/library/fractions.html) into and from a native Rust
5+
//! type.
6+
//!
7+
//!
8+
//! To use this feature, add to your **`Cargo.toml`**:
9+
//!
10+
//! ```toml
11+
//! [dependencies]
12+
#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"num-rational\"] }")]
13+
//! num-rational = "0.4.1"
14+
//! ```
15+
//!
16+
//! # Example
17+
//!
18+
//! Rust code to create a function that adds five to a fraction:
19+
//!
20+
//! ```rust
21+
//! use num_rational::Ratio;
22+
//! use pyo3::prelude::*;
23+
//!
24+
//! #[pyfunction]
25+
//! fn add_five_to_fraction(fraction: Ratio<i32>) -> Ratio<i32> {
26+
//! fraction + Ratio::new(5, 1)
27+
//! }
28+
//!
29+
//! #[pymodule]
30+
//! fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
31+
//! m.add_function(wrap_pyfunction!(add_five_to_fraction, m)?)?;
32+
//! Ok(())
33+
//! }
34+
//! ```
35+
//!
36+
//! Python code that validates the functionality:
37+
//! ```python
38+
//! from my_module import add_five_to_fraction
39+
//! from fractions import Fraction
40+
//!
41+
//! fraction = Fraction(2,1)
42+
//! fraction_plus_five = add_five_to_fraction(f)
43+
//! assert fraction + 5 == fraction_plus_five
44+
//! ```
45+
46+
use crate::ffi;
47+
use crate::sync::GILOnceCell;
48+
use crate::types::any::PyAnyMethods;
49+
use crate::types::PyType;
50+
use crate::{Bound, FromPyObject, IntoPy, Py, PyAny, PyObject, PyResult, Python, ToPyObject};
51+
use std::os::raw::c_char;
52+
53+
#[cfg(feature = "num-bigint")]
54+
use num_bigint::BigInt;
55+
use num_rational::Ratio;
56+
57+
static FRACTION_CLS: GILOnceCell<Py<PyType>> = GILOnceCell::new();
58+
59+
fn get_fraction_cls(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> {
60+
FRACTION_CLS.get_or_try_init_type_ref(py, "fractions", "Fraction")
61+
}
62+
63+
macro_rules! rational_conversion {
64+
($int: ty) => {
65+
impl<'py> FromPyObject<'py> for Ratio<$int> {
66+
fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult<Self> {
67+
let py = obj.py();
68+
let py_numerator_obj = unsafe {
69+
Bound::from_owned_ptr_or_err(
70+
py,
71+
ffi::PyObject_GetAttrString(
72+
obj.as_ptr(),
73+
"numerator\0".as_ptr() as *const c_char,
74+
),
75+
)
76+
};
77+
let py_denominator_obj = unsafe {
78+
Bound::from_owned_ptr_or_err(
79+
py,
80+
ffi::PyObject_GetAttrString(
81+
obj.as_ptr(),
82+
"denominator\0".as_ptr() as *const c_char,
83+
),
84+
)
85+
};
86+
let numerator_owned = unsafe {
87+
Bound::from_owned_ptr_or_err(
88+
py,
89+
ffi::PyNumber_Long(py_numerator_obj?.as_ptr()),
90+
)?
91+
};
92+
let denominator_owned = unsafe {
93+
Bound::from_owned_ptr_or_err(
94+
py,
95+
ffi::PyNumber_Long(py_denominator_obj?.as_ptr()),
96+
)?
97+
};
98+
let rs_numerator: $int = numerator_owned.extract()?;
99+
let rs_denominator: $int = denominator_owned.extract()?;
100+
Ok(Ratio::new(rs_numerator, rs_denominator))
101+
}
102+
}
103+
104+
impl ToPyObject for Ratio<$int> {
105+
fn to_object(&self, py: Python<'_>) -> PyObject {
106+
let fraction_cls = get_fraction_cls(py).expect("failed to load fractions.Fraction");
107+
let ret = fraction_cls
108+
.call1((self.numer().clone(), self.denom().clone()))
109+
.expect("failed to call fractions.Fraction(value)");
110+
ret.to_object(py)
111+
}
112+
}
113+
impl IntoPy<PyObject> for Ratio<$int> {
114+
fn into_py(self, py: Python<'_>) -> PyObject {
115+
self.to_object(py)
116+
}
117+
}
118+
};
119+
}
120+
rational_conversion!(i8);
121+
rational_conversion!(i16);
122+
rational_conversion!(i32);
123+
rational_conversion!(isize);
124+
rational_conversion!(i64);
125+
#[cfg(feature = "num-bigint")]
126+
rational_conversion!(BigInt);
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
use crate::types::dict::PyDictMethods;
131+
use crate::types::PyDict;
132+
133+
#[cfg(not(target_arch = "wasm32"))]
134+
use proptest::prelude::*;
135+
#[test]
136+
fn test_negative_fraction() {
137+
Python::with_gil(|py| {
138+
let locals = PyDict::new_bound(py);
139+
py.run_bound(
140+
"import fractions\npy_frac = fractions.Fraction(-0.125)",
141+
None,
142+
Some(&locals),
143+
)
144+
.unwrap();
145+
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
146+
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
147+
let rs_frac = Ratio::new(-1, 8);
148+
assert_eq!(roundtripped, rs_frac);
149+
})
150+
}
151+
#[test]
152+
fn test_obj_with_incorrect_atts() {
153+
Python::with_gil(|py| {
154+
let locals = PyDict::new_bound(py);
155+
py.run_bound(
156+
"not_fraction = \"contains_incorrect_atts\"",
157+
None,
158+
Some(&locals),
159+
)
160+
.unwrap();
161+
let py_frac = locals.get_item("not_fraction").unwrap().unwrap();
162+
assert!(py_frac.extract::<Ratio<i32>>().is_err());
163+
})
164+
}
165+
166+
#[test]
167+
fn test_fraction_with_fraction_type() {
168+
Python::with_gil(|py| {
169+
let locals = PyDict::new_bound(py);
170+
py.run_bound(
171+
"import fractions\npy_frac = fractions.Fraction(fractions.Fraction(10))",
172+
None,
173+
Some(&locals),
174+
)
175+
.unwrap();
176+
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
177+
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
178+
let rs_frac = Ratio::new(10, 1);
179+
assert_eq!(roundtripped, rs_frac);
180+
})
181+
}
182+
183+
#[test]
184+
fn test_fraction_with_decimal() {
185+
Python::with_gil(|py| {
186+
let locals = PyDict::new_bound(py);
187+
py.run_bound(
188+
"import fractions\n\nfrom decimal import Decimal\npy_frac = fractions.Fraction(Decimal(\"1.1\"))",
189+
None,
190+
Some(&locals),
191+
)
192+
.unwrap();
193+
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
194+
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
195+
let rs_frac = Ratio::new(11, 10);
196+
assert_eq!(roundtripped, rs_frac);
197+
})
198+
}
199+
200+
#[test]
201+
fn test_fraction_with_num_den() {
202+
Python::with_gil(|py| {
203+
let locals = PyDict::new_bound(py);
204+
py.run_bound(
205+
"import fractions\npy_frac = fractions.Fraction(10,5)",
206+
None,
207+
Some(&locals),
208+
)
209+
.unwrap();
210+
let py_frac = locals.get_item("py_frac").unwrap().unwrap();
211+
let roundtripped: Ratio<i32> = py_frac.extract().unwrap();
212+
let rs_frac = Ratio::new(10, 5);
213+
assert_eq!(roundtripped, rs_frac);
214+
})
215+
}
216+
217+
#[cfg(target_arch = "wasm32")]
218+
#[test]
219+
fn test_int_roundtrip() {
220+
Python::with_gil(|py| {
221+
let rs_frac = Ratio::new(1, 2);
222+
let py_frac: PyObject = rs_frac.into_py(py);
223+
let roundtripped: Ratio<i32> = py_frac.extract(py).unwrap();
224+
assert_eq!(rs_frac, roundtripped);
225+
// float conversion
226+
})
227+
}
228+
229+
#[cfg(target_arch = "wasm32")]
230+
#[test]
231+
fn test_big_int_roundtrip() {
232+
Python::with_gil(|py| {
233+
let rs_frac = Ratio::from_float(5.5).unwrap();
234+
let py_frac: PyObject = rs_frac.clone().into_py(py);
235+
let roundtripped: Ratio<BigInt> = py_frac.extract(py).unwrap();
236+
assert_eq!(rs_frac, roundtripped);
237+
})
238+
}
239+
240+
#[cfg(not(target_arch = "wasm32"))]
241+
proptest! {
242+
#[test]
243+
fn test_int_roundtrip(num in any::<i32>(), den in any::<i32>()) {
244+
Python::with_gil(|py| {
245+
let rs_frac = Ratio::new(num, den);
246+
let py_frac = rs_frac.into_py(py);
247+
let roundtripped: Ratio<i32> = py_frac.extract(py).unwrap();
248+
assert_eq!(rs_frac, roundtripped);
249+
})
250+
}
251+
252+
#[test]
253+
#[cfg(feature = "num-bigint")]
254+
fn test_big_int_roundtrip(num in any::<f32>()) {
255+
Python::with_gil(|py| {
256+
let rs_frac = Ratio::from_float(num).unwrap();
257+
let py_frac = rs_frac.clone().into_py(py);
258+
let roundtripped: Ratio<BigInt> = py_frac.extract(py).unwrap();
259+
assert_eq!(roundtripped, rs_frac);
260+
})
261+
}
262+
263+
}
264+
265+
#[test]
266+
fn test_infinity() {
267+
Python::with_gil(|py| {
268+
let locals = PyDict::new_bound(py);
269+
let py_bound = py.run_bound(
270+
"import fractions\npy_frac = fractions.Fraction(\"Infinity\")",
271+
None,
272+
Some(&locals),
273+
);
274+
assert!(py_bound.is_err());
275+
})
276+
}
277+
}

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
//! [`BigUint`] types.
109109
//! - [`num-complex`]: Enables conversions between Python objects and [num-complex]'s [`Complex`]
110110
//! type.
111+
//! - [`num-rational`]: Enables conversions between Python's fractions.Fraction and [num-rational]'s types
111112
//! - [`rust_decimal`]: Enables conversions between Python's decimal.Decimal and [rust_decimal]'s
112113
//! [`Decimal`] type.
113114
//! - [`serde`]: Allows implementing [serde]'s [`Serialize`] and [`Deserialize`] traits for
@@ -288,6 +289,7 @@
288289
//! [`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"
289290
//! [`num-bigint`]: ./num_bigint/index.html "Documentation about the `num-bigint` feature."
290291
//! [`num-complex`]: ./num_complex/index.html "Documentation about the `num-complex` feature."
292+
//! [`num-rational`]: ./num_rational/index.html "Documentation about the `num-rational` feature."
291293
//! [`pyo3-build-config`]: https://docs.rs/pyo3-build-config
292294
//! [rust_decimal]: https://docs.rs/rust_decimal
293295
//! [`rust_decimal`]: ./rust_decimal/index.html "Documenation about the `rust_decimal` feature."
@@ -303,6 +305,7 @@
303305
//! [manual_builds]: https://pyo3.rs/latest/building-and-distribution.html#manual-builds "Manual builds - Building and Distribution - PyO3 user guide"
304306
//! [num-bigint]: https://docs.rs/num-bigint
305307
//! [num-complex]: https://docs.rs/num-complex
308+
//! [num-rational]: https://docs.rs/num-rational
306309
//! [serde]: https://docs.rs/serde
307310
//! [setuptools-rust]: https://github.com/PyO3/setuptools-rust "Setuptools plugin for Rust extensions"
308311
//! [the guide]: https://pyo3.rs "PyO3 user guide"

0 commit comments

Comments
 (0)