Skip to content

Commit e613a79

Browse files
Implement optional feature ordered-float for NotNan/OrderedFloat <-> Python float conversions (#5114)
* Implement OrderedFloat & NotNan conversions for f32 & f64 * Reduce test boilerplate with macros for the wrapper types * Make roundtripped zero test stricter on both Rust & Python side * Simplify conversion implementations using macros * Add documentation for feature * Add newsfragment * Remove unneeded closure * Fix typo * Remove ffi call * Use exact version in Cargo.toml instead of lower bound * Use `py_run!`, `py.eval`, and `py.import` to simplify code * Add type annotations for WASM due to `py_run!` * Change newsfragment filename to use Pull Request ID instead of Issue ID * Add missing #[test] annotation for wasm test
1 parent 7ccfcdc commit e613a79

File tree

7 files changed

+346
-1
lines changed

7 files changed

+346
-1
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jiff-02 = { package = "jiff", version = "0.2", optional = true }
4343
num-bigint = { version = "0.4.2", optional = true }
4444
num-complex = { version = ">= 0.4.6, < 0.5", optional = true }
4545
num-rational = { version = "0.4.1", optional = true }
46+
ordered-float = { version = "5.0.0", default-features = false, optional = true }
4647
rust_decimal = { version = "1.15", default-features = false, optional = true }
4748
time = { version = "0.3.38", default-features = false, optional = true }
4849
serde = { version = "1.0", optional = true }
@@ -135,6 +136,7 @@ full = [
135136
"num-bigint",
136137
"num-complex",
137138
"num-rational",
139+
"ordered-float",
138140
"py-clone",
139141
"rust_decimal",
140142
"serde",

guide/src/conversions/tables.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The table below contains the Python type and the corresponding function argument
1717
| `bytes` | `Vec<u8>`, `&[u8]`, `Cow<[u8]>` | `PyBytes` |
1818
| `bool` | `bool` | `PyBool` |
1919
| `int` | `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `i128`, `u128`, `isize`, `usize`, `num_bigint::BigInt`[^1], `num_bigint::BigUint`[^1] | `PyInt` |
20-
| `float` | `f32`, `f64` | `PyFloat` |
20+
| `float` | `f32`, `f64`, `ordered_float::NotNan`[^10], `ordered_float::OrderedFloat`[^10] | `PyFloat` |
2121
| `complex` | `num_complex::Complex`[^2] | `PyComplex` |
2222
| `fractions.Fraction`| `num_rational::Ratio`[^8] | - |
2323
| `list[T]` | `Vec<T>` | `PyList` |
@@ -119,3 +119,5 @@ Finally, the following Rust types are also able to convert to Python as return v
119119
[^8]: Requires the `num-rational` optional feature.
120120

121121
[^9]: Requires the `bigdecimal` optional feature.
122+
123+
[^10]: Requires the `ordered-float` optional feature.

guide/src/features.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ Adds a dependency on [num-complex](https://docs.rs/num-complex) and enables conv
180180

181181
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.
182182

183+
### `ordered-float`
184+
185+
Adds a dependency on [ordered-float](https://docs.rs/ordered-float) and enables conversions between [ordered-float](https://docs.rs/ordered-float)'s types and Python:
186+
- [NotNan](https://docs.rs/ordered-float/latest/ordered_float/struct.NotNan.html) -> [`PyFloat`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyFloat.html)
187+
- [OrderedFloat](https://docs.rs/ordered-float/latest/ordered_float/struct.OrderedFloat.html) -> [`PyFloat`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyFloat.html)
188+
183189
### `rust_decimal`
184190

185191
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/5114.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added conversion support for `ordered_float::NotNan` & `ordered_float::OrderedFloat` to and from python native float type

src/conversions/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod jiff;
1212
pub mod num_bigint;
1313
pub mod num_complex;
1414
pub mod num_rational;
15+
pub mod ordered_float;
1516
pub mod rust_decimal;
1617
pub mod serde;
1718
pub mod smallvec;

src/conversions/ordered_float.rs

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
#![cfg(feature = "ordered-float")]
2+
//! Conversions to and from [ordered-float](https://docs.rs/ordered-float) types.
3+
//! [`NotNan`]`<`[`f32`]`>` and [`NotNan`]`<`[`f64`]`>`.
4+
//! [`OrderedFloat`]`<`[`f32`]`>` and [`OrderedFloat`]`<`[`f64`]`>`.
5+
//!
6+
//! This is useful for converting between Python's float into and from a native Rust type.
7+
//!
8+
//! Take care when comparing sorted collections of float types between Python and Rust.
9+
//! They will likely differ due to the ambiguous sort order of NaNs in Python.
10+
//
11+
//!
12+
//! To use this feature, add to your **`Cargo.toml`**:
13+
//!
14+
//! ```toml
15+
//! [dependencies]
16+
#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"ordered-float\"] }")]
17+
//! ordered-float = "5.0.0"
18+
//! ```
19+
//!
20+
//! # Example
21+
//!
22+
//! Rust code to create functions that add ordered floats:
23+
//!
24+
//! ```rust,no_run
25+
//! use ordered_float::{NotNan, OrderedFloat};
26+
//! use pyo3::prelude::*;
27+
//!
28+
//! #[pyfunction]
29+
//! fn add_not_nans(a: NotNan<f64>, b: NotNan<f64>) -> NotNan<f64> {
30+
//! a + b
31+
//! }
32+
//!
33+
//! #[pyfunction]
34+
//! fn add_ordered_floats(a: OrderedFloat<f64>, b: OrderedFloat<f64>) -> OrderedFloat<f64> {
35+
//! a + b
36+
//! }
37+
//!
38+
//! #[pymodule]
39+
//! fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
40+
//! m.add_function(wrap_pyfunction!(add_not_nans, m)?)?;
41+
//! m.add_function(wrap_pyfunction!(add_ordered_floats, m)?)?;
42+
//! Ok(())
43+
//! }
44+
//! ```
45+
//!
46+
//! Python code that validates the functionality:
47+
//! ```python
48+
//! from my_module import add_not_nans, add_ordered_floats
49+
//!
50+
//! assert add_not_nans(1.0,2.0) == 3.0
51+
//! assert add_ordered_floats(1.0,2.0) == 3.0
52+
//! ```
53+
54+
use crate::conversion::IntoPyObject;
55+
use crate::exceptions::PyValueError;
56+
use crate::types::{any::PyAnyMethods, PyFloat};
57+
use crate::{Bound, FromPyObject, PyAny, PyResult, Python};
58+
use ordered_float::{NotNan, OrderedFloat};
59+
use std::convert::Infallible;
60+
61+
macro_rules! float_conversions {
62+
($wrapper:ident, $float_type:ty, $constructor:expr) => {
63+
impl FromPyObject<'_> for $wrapper<$float_type> {
64+
fn extract_bound(obj: &Bound<'_, PyAny>) -> PyResult<Self> {
65+
let val: $float_type = obj.extract()?;
66+
$constructor(val)
67+
}
68+
}
69+
70+
impl<'py> IntoPyObject<'py> for $wrapper<$float_type> {
71+
type Target = PyFloat;
72+
type Output = Bound<'py, Self::Target>;
73+
type Error = Infallible;
74+
75+
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
76+
self.into_inner().into_pyobject(py)
77+
}
78+
}
79+
80+
impl<'py> IntoPyObject<'py> for &$wrapper<$float_type> {
81+
type Target = PyFloat;
82+
type Output = Bound<'py, Self::Target>;
83+
type Error = Infallible;
84+
85+
#[inline]
86+
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
87+
(*self).into_pyobject(py)
88+
}
89+
}
90+
};
91+
}
92+
float_conversions!(OrderedFloat, f32, |val| Ok(OrderedFloat(val)));
93+
float_conversions!(OrderedFloat, f64, |val| Ok(OrderedFloat(val)));
94+
float_conversions!(NotNan, f32, |val| NotNan::new(val)
95+
.map_err(|e| PyValueError::new_err(e.to_string())));
96+
float_conversions!(NotNan, f64, |val| NotNan::new(val)
97+
.map_err(|e| PyValueError::new_err(e.to_string())));
98+
99+
#[cfg(test)]
100+
mod test_ordered_float {
101+
use super::*;
102+
use crate::ffi::c_str;
103+
use crate::py_run;
104+
105+
#[cfg(not(target_arch = "wasm32"))]
106+
use proptest::prelude::*;
107+
108+
macro_rules! float_roundtrip_tests {
109+
($wrapper:ident, $float_type:ty, $constructor:expr, $standard_test:ident, $wasm_test:ident, $infinity_test:ident, $zero_test:ident) => {
110+
#[cfg(not(target_arch = "wasm32"))]
111+
proptest! {
112+
#[test]
113+
fn $standard_test(inner_f: $float_type) {
114+
let f = $constructor(inner_f);
115+
116+
Python::with_gil(|py| {
117+
let f_py: Bound<'_, PyFloat> = f.into_pyobject(py).unwrap();
118+
119+
py_run!(
120+
py,
121+
f_py,
122+
&format!(
123+
"import math\nassert math.isclose(f_py, {})",
124+
inner_f as f64 // Always interpret the literal rs float value as f64
125+
// so that it's comparable with the python float
126+
)
127+
);
128+
129+
let roundtripped_f: $wrapper<$float_type> = f_py.extract().unwrap();
130+
131+
assert_eq!(f, roundtripped_f);
132+
})
133+
}
134+
}
135+
136+
#[cfg(target_arch = "wasm32")]
137+
#[test]
138+
fn $wasm_test() {
139+
let inner_f = 10.0;
140+
let f = $constructor(inner_f);
141+
142+
Python::with_gil(|py| {
143+
let f_py: Bound<'_, PyFloat> = f.into_pyobject(py).unwrap();
144+
145+
py_run!(
146+
py,
147+
f_py,
148+
&format!(
149+
"import math\nassert math.isclose(f_py, {})",
150+
inner_f as f64 // Always interpret the literal rs float value as f64
151+
// so that it's comparable with the python float
152+
)
153+
);
154+
155+
let roundtripped_f: $wrapper<$float_type> = f_py.extract().unwrap();
156+
157+
assert_eq!(f, roundtripped_f);
158+
})
159+
}
160+
161+
#[test]
162+
fn $infinity_test() {
163+
let inner_pinf = <$float_type>::INFINITY;
164+
let pinf = $constructor(inner_pinf);
165+
166+
let inner_ninf = <$float_type>::NEG_INFINITY;
167+
let ninf = $constructor(inner_ninf);
168+
169+
Python::with_gil(|py| {
170+
let pinf_py: Bound<'_, PyFloat> = pinf.into_pyobject(py).unwrap();
171+
let ninf_py: Bound<'_, PyFloat> = ninf.into_pyobject(py).unwrap();
172+
173+
py_run!(
174+
py,
175+
pinf_py ninf_py,
176+
"\
177+
assert pinf_py == float('inf')\n\
178+
assert ninf_py == float('-inf')"
179+
);
180+
181+
let roundtripped_pinf: $wrapper<$float_type> = pinf_py.extract().unwrap();
182+
let roundtripped_ninf: $wrapper<$float_type> = ninf_py.extract().unwrap();
183+
184+
assert_eq!(pinf, roundtripped_pinf);
185+
assert_eq!(ninf, roundtripped_ninf);
186+
})
187+
}
188+
189+
#[test]
190+
fn $zero_test() {
191+
let inner_pzero: $float_type = 0.0;
192+
let pzero = $constructor(inner_pzero);
193+
194+
let inner_nzero: $float_type = -0.0;
195+
let nzero = $constructor(inner_nzero);
196+
197+
Python::with_gil(|py| {
198+
let pzero_py: Bound<'_, PyFloat> = pzero.into_pyobject(py).unwrap();
199+
let nzero_py: Bound<'_, PyFloat> = nzero.into_pyobject(py).unwrap();
200+
201+
// This python script verifies that the values are 0.0 in magnitude
202+
// and that the signs are correct(+0.0 vs -0.0)
203+
py_run!(
204+
py,
205+
pzero_py nzero_py,
206+
"\
207+
import math\n\
208+
assert pzero_py == 0.0\n\
209+
assert math.copysign(1.0, pzero_py) > 0.0\n\
210+
assert nzero_py == 0.0\n\
211+
assert math.copysign(1.0, nzero_py) < 0.0"
212+
);
213+
214+
let roundtripped_pzero: $wrapper<$float_type> = pzero_py.extract().unwrap();
215+
let roundtripped_nzero: $wrapper<$float_type> = nzero_py.extract().unwrap();
216+
217+
assert_eq!(pzero, roundtripped_pzero);
218+
assert_eq!(roundtripped_pzero.signum(), 1.0);
219+
assert_eq!(nzero, roundtripped_nzero);
220+
assert_eq!(roundtripped_nzero.signum(), -1.0);
221+
})
222+
}
223+
};
224+
}
225+
float_roundtrip_tests!(
226+
OrderedFloat,
227+
f32,
228+
OrderedFloat,
229+
ordered_float_f32_standard,
230+
ordered_float_f32_wasm,
231+
ordered_float_f32_infinity,
232+
ordered_float_f32_zero
233+
);
234+
float_roundtrip_tests!(
235+
OrderedFloat,
236+
f64,
237+
OrderedFloat,
238+
ordered_float_f64_standard,
239+
ordered_float_f64_wasm,
240+
ordered_float_f64_infinity,
241+
ordered_float_f64_zero
242+
);
243+
float_roundtrip_tests!(
244+
NotNan,
245+
f32,
246+
|val| NotNan::new(val).unwrap(),
247+
not_nan_f32_standard,
248+
not_nan_f32_wasm,
249+
not_nan_f32_infinity,
250+
not_nan_f32_zero
251+
);
252+
float_roundtrip_tests!(
253+
NotNan,
254+
f64,
255+
|val| NotNan::new(val).unwrap(),
256+
not_nan_f64_standard,
257+
not_nan_f64_wasm,
258+
not_nan_f64_infinity,
259+
not_nan_f64_zero
260+
);
261+
262+
macro_rules! ordered_float_pynan_tests {
263+
($test_name:ident, $float_type:ty) => {
264+
#[test]
265+
fn $test_name() {
266+
let inner_nan: $float_type = <$float_type>::NAN;
267+
let nan = OrderedFloat(inner_nan);
268+
269+
Python::with_gil(|py| {
270+
let nan_py: Bound<'_, PyFloat> = nan.into_pyobject(py).unwrap();
271+
272+
py_run!(
273+
py,
274+
nan_py,
275+
"\
276+
import math\n\
277+
assert math.isnan(nan_py)"
278+
);
279+
280+
let roundtripped_nan: OrderedFloat<$float_type> = nan_py.extract().unwrap();
281+
282+
assert_eq!(nan, roundtripped_nan);
283+
})
284+
}
285+
};
286+
}
287+
ordered_float_pynan_tests!(test_ordered_float_pynan_f32, f32);
288+
ordered_float_pynan_tests!(test_ordered_float_pynan_f64, f64);
289+
290+
macro_rules! not_nan_pynan_tests {
291+
($test_name:ident, $float_type:ty) => {
292+
#[test]
293+
fn $test_name() {
294+
Python::with_gil(|py| {
295+
let nan_py = py.eval(c_str!("float('nan')"), None, None).unwrap();
296+
297+
let nan_rs: PyResult<NotNan<$float_type>> = nan_py.extract();
298+
299+
assert!(nan_rs.is_err());
300+
})
301+
}
302+
};
303+
}
304+
not_nan_pynan_tests!(test_not_nan_pynan_f32, f32);
305+
not_nan_pynan_tests!(test_not_nan_pynan_f64, f64);
306+
307+
macro_rules! py64_rs32 {
308+
($test_name:ident, $wrapper:ident, $float_type:ty) => {
309+
#[test]
310+
fn $test_name() {
311+
Python::with_gil(|py| {
312+
let py_64 = py
313+
.import("sys")
314+
.unwrap()
315+
.getattr("float_info")
316+
.unwrap()
317+
.getattr("max")
318+
.unwrap();
319+
let rs_32 = py_64.extract::<$wrapper<f32>>().unwrap();
320+
// The python f64 is not representable in a rust f32
321+
assert!(rs_32.is_infinite());
322+
})
323+
}
324+
};
325+
}
326+
py64_rs32!(ordered_float_f32, OrderedFloat, f32);
327+
py64_rs32!(ordered_float_f64, OrderedFloat, f64);
328+
py64_rs32!(not_nan_f32, NotNan, f32);
329+
py64_rs32!(not_nan_f64, NotNan, f64);
330+
}

0 commit comments

Comments
 (0)