Skip to main content

pyo3/conversions/
rust_decimal.rs

1#![cfg(feature = "rust_decimal")]
2//! Conversions to and from [rust_decimal](https://docs.rs/rust_decimal)'s [`Decimal`] type.
3//!
4//! This is useful for converting Python's decimal.Decimal into and from a native Rust type.
5//!
6//! # Setup
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 = [\"rust_decimal\"] }")]
13//! rust_decimal = "1.0"
14//! ```
15//!
16//! Note that you must use a compatible version of rust_decimal and PyO3.
17//! The required rust_decimal version may vary based on the version of PyO3.
18//!
19//! # Example
20//!
21//! Rust code to create a function that adds one to a Decimal
22//!
23//! ```rust,no_run
24//! use rust_decimal::Decimal;
25//! use pyo3::prelude::*;
26//!
27//! #[pyfunction]
28//! fn add_one(d: Decimal) -> Decimal {
29//!     d + Decimal::ONE
30//! }
31//!
32//! #[pymodule]
33//! fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
34//!     m.add_function(wrap_pyfunction!(add_one, m)?)?;
35//!     Ok(())
36//! }
37//! ```
38//!
39//! Python code that validates the functionality
40//!
41//!
42//! ```python
43//! from my_module import add_one
44//! from decimal import Decimal
45//!
46//! d = Decimal("2")
47//! value = add_one(d)
48//!
49//! assert d + 1 == value
50//! ```
51
52use crate::conversion::IntoPyObject;
53use crate::exceptions::PyValueError;
54#[cfg(feature = "experimental-inspect")]
55use crate::inspect::PyStaticExpr;
56use crate::platform::prelude::*;
57use crate::sync::PyOnceLock;
58#[cfg(feature = "experimental-inspect")]
59use crate::type_hint_identifier;
60use crate::types::any::PyAnyMethods;
61use crate::types::string::PyStringMethods;
62use crate::types::PyType;
63use crate::{Borrowed, Bound, FromPyObject, Py, PyAny, PyErr, PyResult, Python};
64use core::str::FromStr;
65use rust_decimal::Decimal;
66
67impl FromPyObject<'_, '_> for Decimal {
68    type Error = PyErr;
69
70    #[cfg(feature = "experimental-inspect")]
71    const INPUT_TYPE: PyStaticExpr = type_hint_identifier!("decimal", "Decimal");
72
73    fn extract(obj: Borrowed<'_, '_, PyAny>) -> Result<Self, Self::Error> {
74        // use the string representation to not be lossy
75        if let Ok(val) = obj.extract() {
76            Ok(Decimal::new(val, 0))
77        } else {
78            let py_str = &obj.str()?;
79            let rs_str = &py_str.to_cow()?;
80            Decimal::from_str(rs_str).or_else(|_| {
81                Decimal::from_scientific(rs_str).map_err(|e| PyValueError::new_err(e.to_string()))
82            })
83        }
84    }
85}
86
87static DECIMAL_CLS: PyOnceLock<Py<PyType>> = PyOnceLock::new();
88
89fn get_decimal_cls(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> {
90    DECIMAL_CLS.import(py, "decimal", "Decimal")
91}
92
93impl<'py> IntoPyObject<'py> for Decimal {
94    type Target = PyAny;
95    type Output = Bound<'py, Self::Target>;
96    type Error = PyErr;
97
98    #[cfg(feature = "experimental-inspect")]
99    const OUTPUT_TYPE: PyStaticExpr = type_hint_identifier!("decimal", "Decimal");
100
101    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
102        let dec_cls = get_decimal_cls(py)?;
103        // now call the constructor with the Rust Decimal string-ified
104        // to not be lossy
105        dec_cls.call1((self.to_string(),))
106    }
107}
108
109impl<'py> IntoPyObject<'py> for &Decimal {
110    type Target = PyAny;
111    type Output = Bound<'py, Self::Target>;
112    type Error = PyErr;
113
114    #[cfg(feature = "experimental-inspect")]
115    const OUTPUT_TYPE: PyStaticExpr = Decimal::OUTPUT_TYPE;
116
117    #[inline]
118    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
119        (*self).into_pyobject(py)
120    }
121}
122
123#[cfg(test)]
124mod test_rust_decimal {
125    use super::*;
126    use crate::types::dict::PyDictMethods;
127    use crate::types::PyDict;
128    use alloc::ffi::CString;
129
130    #[cfg(not(target_arch = "wasm32"))]
131    use proptest::prelude::*;
132
133    macro_rules! convert_constants {
134        ($name:ident, $rs:expr, $py:literal) => {
135            #[test]
136            fn $name() {
137                Python::attach(|py| {
138                    let rs_orig = $rs;
139                    let rs_dec = rs_orig.into_pyobject(py).unwrap();
140                    let locals = PyDict::new(py);
141                    locals.set_item("rs_dec", &rs_dec).unwrap();
142                    // Checks if Rust Decimal -> Python Decimal conversion is correct
143                    py.run(
144                        &CString::new(format!(
145                            "import decimal\npy_dec = decimal.Decimal({})\nassert py_dec == rs_dec",
146                            $py
147                        ))
148                        .unwrap(),
149                        None,
150                        Some(&locals),
151                    )
152                    .unwrap();
153                    // Checks if Python Decimal -> Rust Decimal conversion is correct
154                    let py_dec = locals.get_item("py_dec").unwrap().unwrap();
155                    let py_result: Decimal = py_dec.extract().unwrap();
156                    assert_eq!(rs_orig, py_result);
157                })
158            }
159        };
160    }
161
162    convert_constants!(convert_zero, Decimal::ZERO, "0");
163    convert_constants!(convert_one, Decimal::ONE, "1");
164    convert_constants!(convert_neg_one, Decimal::NEGATIVE_ONE, "-1");
165    convert_constants!(convert_two, Decimal::TWO, "2");
166    convert_constants!(convert_ten, Decimal::TEN, "10");
167    convert_constants!(convert_one_hundred, Decimal::ONE_HUNDRED, "100");
168    convert_constants!(convert_one_thousand, Decimal::ONE_THOUSAND, "1000");
169
170    #[cfg(not(target_arch = "wasm32"))]
171    proptest! {
172        #[test]
173        fn test_roundtrip(
174            lo in any::<u32>(),
175            mid in any::<u32>(),
176            high in any::<u32>(),
177            negative in any::<bool>(),
178            scale in 0..28u32
179        ) {
180            let num = Decimal::from_parts(lo, mid, high, negative, scale);
181            Python::attach(|py| {
182                let rs_dec = num.into_pyobject(py).unwrap();
183                let locals = PyDict::new(py);
184                locals.set_item("rs_dec", &rs_dec).unwrap();
185                py.run(
186                    &CString::new(format!(
187                       "import decimal\npy_dec = decimal.Decimal(\"{num}\")\nassert py_dec == rs_dec")).unwrap(),
188                None, Some(&locals)).unwrap();
189                let roundtripped: Decimal = rs_dec.extract().unwrap();
190                assert_eq!(num, roundtripped);
191            })
192        }
193
194        #[test]
195        fn test_integers(num in any::<i64>()) {
196            Python::attach(|py| {
197                let py_num = num.into_pyobject(py).unwrap();
198                let roundtripped: Decimal = py_num.extract().unwrap();
199                let rs_dec = Decimal::new(num, 0);
200                assert_eq!(rs_dec, roundtripped);
201            })
202        }
203    }
204
205    #[test]
206    fn test_nan() {
207        Python::attach(|py| {
208            let locals = PyDict::new(py);
209            py.run(
210                c"import decimal\npy_dec = decimal.Decimal(\"NaN\")",
211                None,
212                Some(&locals),
213            )
214            .unwrap();
215            let py_dec = locals.get_item("py_dec").unwrap().unwrap();
216            let roundtripped: Result<Decimal, PyErr> = py_dec.extract();
217            assert!(roundtripped.is_err());
218        })
219    }
220
221    #[test]
222    fn test_scientific_notation() {
223        Python::attach(|py| {
224            let locals = PyDict::new(py);
225            py.run(
226                c"import decimal\npy_dec = decimal.Decimal(\"1e3\")",
227                None,
228                Some(&locals),
229            )
230            .unwrap();
231            let py_dec = locals.get_item("py_dec").unwrap().unwrap();
232            let roundtripped: Decimal = py_dec.extract().unwrap();
233            let rs_dec = Decimal::from_scientific("1e3").unwrap();
234            assert_eq!(rs_dec, roundtripped);
235        })
236    }
237
238    #[test]
239    fn test_infinity() {
240        Python::attach(|py| {
241            let locals = PyDict::new(py);
242            py.run(
243                c"import decimal\npy_dec = decimal.Decimal(\"Infinity\")",
244                None,
245                Some(&locals),
246            )
247            .unwrap();
248            let py_dec = locals.get_item("py_dec").unwrap().unwrap();
249            let roundtripped: Result<Decimal, PyErr> = py_dec.extract();
250            assert!(roundtripped.is_err());
251        })
252    }
253}