Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -127,6 +128,7 @@ full = [
"indexmap",
"num-bigint",
"num-complex",
"num-rational",
"rust_decimal",
"serde",
"smallvec",
Expand Down
3 changes: 3 additions & 0 deletions guide/src/conversions/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
4 changes: 4 additions & 0 deletions guide/src/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions newsfragments/4148.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Conversion between [num-rational](https://github.com/rust-num/num-rational) and Python's fractions.Fraction.
1 change: 1 addition & 0 deletions src/conversions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
236 changes: 236 additions & 0 deletions src/conversions/num_rational.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#![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(
py,
ffi::PyObject_GetAttrString(
obj.as_ptr(),
"numerator\0".as_ptr() as *const c_char,
),
)
};
let py_denominator_obj = unsafe {
Bound::from_owned_ptr(
py,
ffi::PyObject_GetAttrString(
obj.as_ptr(),
"denominator\0".as_ptr() as *const c_char,
),
)
};
Icxolu marked this conversation as resolved.
Show resolved Hide resolved
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()))
Icxolu marked this conversation as resolved.
Show resolved Hide resolved
.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_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);
})
}

proptest! {
Icxolu marked this conversation as resolved.
Show resolved Hide resolved
#[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());
})
}
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand All @@ -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"
Expand Down
Loading