Skip to main content

nautilus_core/python/
params.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings for [`Params`] type conversion.
17
18use pyo3::{
19    conversion::IntoPyObjectExt,
20    prelude::*,
21    types::{PyCFunction, PyDict, PyList, PyModule, PyTuple},
22};
23use serde_json::Value;
24
25use crate::{
26    params::Params,
27    python::{serialization::serialize_pyobject_pyo3, to_pyvalue_err},
28};
29
30/// Converts a Python dict to `Params` (IndexMap<String, Value>).
31///
32/// An empty dict canonicalizes to `None`, distinguishing "no params provided"
33/// from a populated map. This is not the inverse of [`params_to_pydict`], which
34/// accepts `&Params` (not `Option<&Params>`); callers handle the outer option.
35///
36/// Integer values must fit signed or unsigned 64-bit integers, including in nested containers.
37///
38/// # Errors
39///
40/// Returns an error if:
41/// - The dict cannot be serialized to JSON.
42/// - The JSON is not a valid object.
43/// - An integer value is outside the signed or unsigned 64-bit range (`ValueError`).
44pub fn pydict_to_params(py: Python<'_>, dict: &Py<PyDict>) -> PyResult<Option<Params>> {
45    let dict_bound = dict.bind(py);
46    if dict_bound.is_empty() {
47        return Ok(None);
48    }
49
50    let json_str = serialize_pyobject_pyo3(py, dict_bound.as_any())?;
51
52    let parse_int = PyCFunction::new_closure(
53        py,
54        None,
55        None,
56        |args: &Bound<'_, PyTuple>, _kwargs| -> PyResult<Py<PyAny>> {
57            let text: String = args.get_item(0)?.extract()?;
58            if let Ok(value) = text.parse::<i64>() {
59                return value.into_py_any(args.py());
60            }
61
62            let value = text.parse::<u64>().map_err(|_| {
63                to_pyvalue_err(format!(
64                    "Python integer must be in range [{}, {}]",
65                    i64::MIN,
66                    u64::MAX,
67                ))
68            })?;
69
70            value.into_py_any(args.py())
71        },
72    )?;
73
74    let kwargs = PyDict::new(py);
75    kwargs.set_item("parse_int", parse_int)?;
76    PyModule::import(py, "json")?.call_method("loads", (&json_str,), Some(&kwargs))?;
77
78    serde_json::from_str(&json_str)
79        .map(Some)
80        .map_err(to_pyvalue_err)
81}
82
83/// Converts a `serde_json::Value` to a Python object.
84///
85/// This is a common conversion pattern used when converting `Params` to Python dicts.
86///
87/// # Errors
88///
89/// Returns an error if the value type is unsupported, numeric extraction fails, or conversion
90/// fails.
91pub fn value_to_pyobject(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
92    match val {
93        Value::Null => Ok(py.None()),
94        Value::Bool(b) => b.into_py_any(py),
95        Value::String(s) => s.into_py_any(py),
96        Value::Number(n) => {
97            if n.is_i64() {
98                n.as_i64()
99                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as i64"))?
100                    .into_py_any(py)
101            } else if n.is_u64() {
102                n.as_u64()
103                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as u64"))?
104                    .into_py_any(py)
105            } else if n.is_f64() {
106                n.as_f64()
107                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as f64"))?
108                    .into_py_any(py)
109            } else {
110                Err(to_pyvalue_err("Unsupported JSON number type"))
111            }
112        }
113        Value::Array(arr) => {
114            let py_list = PyList::new(py, &[] as &[Py<PyAny>])?;
115            for item in arr {
116                let py_item = value_to_pyobject(py, item)?;
117                py_list.append(py_item)?;
118            }
119            py_list.into_py_any(py)
120        }
121        Value::Object(_) => {
122            // For nested objects, convert to dict recursively
123            let json_str = serde_json::to_string(val).map_err(to_pyvalue_err)?;
124            let py_dict: Py<PyDict> = PyModule::import(py, "json")?
125                .call_method("loads", (json_str,), None)?
126                .extract()?;
127            py_dict.into_py_any(py)
128        }
129    }
130}
131
132/// Converts `Params` (IndexMap<String, Value>) to a Python dict.
133///
134/// # Errors
135///
136/// Returns an error if conversion of any value fails.
137pub fn params_to_pydict(py: Python<'_>, params: &Params) -> PyResult<Py<PyDict>> {
138    let dict = PyDict::new(py);
139    for (key, value) in params {
140        let py_value = value_to_pyobject(py, value)?;
141        dict.set_item(key, py_value)?;
142    }
143    Ok(dict.into())
144}
145
146#[cfg(test)]
147mod tests {
148    use pyo3::exceptions::PyValueError;
149    use rstest::rstest;
150    use serde_json::json;
151
152    use super::*;
153
154    #[derive(Debug, Clone, Copy)]
155    enum ExpectedNumber {
156        I64(i64),
157        U64(u64),
158        F64(f64),
159    }
160
161    #[rstest]
162    #[case(json!(-100_i64), ExpectedNumber::I64(-100))]
163    #[case(json!(42_u64), ExpectedNumber::U64(42))]
164    #[case(json!(2.5_f64), ExpectedNumber::F64(2.5))]
165    fn test_value_to_pyobject_number_branches(
166        #[case] value: Value,
167        #[case] expected: ExpectedNumber,
168    ) {
169        Python::initialize();
170        Python::attach(|py| {
171            let py_obj = value_to_pyobject(py, &value).unwrap();
172
173            match expected {
174                ExpectedNumber::I64(expected) => {
175                    assert_eq!(py_obj.extract::<i64>(py).unwrap(), expected);
176                }
177                ExpectedNumber::U64(expected) => {
178                    assert_eq!(py_obj.extract::<u64>(py).unwrap(), expected);
179                }
180                ExpectedNumber::F64(expected) => {
181                    let actual = py_obj.extract::<f64>(py).unwrap();
182                    assert!((actual - expected).abs() < f64::EPSILON);
183                }
184            }
185        });
186    }
187
188    #[rstest]
189    fn test_params_pydict_roundtrip() {
190        let mut params = Params::new();
191        params.insert("null".to_string(), Value::Null);
192        params.insert("enabled".to_string(), json!(true));
193        params.insert(
194            "name".to_string(),
195            json!("Nautilus \u{65e5}\u{672c}\u{8a9e}"),
196        );
197        params.insert("signed".to_string(), json!(-7));
198        params.insert("unsigned".to_string(), json!(42_u64));
199        params.insert("signed_min".to_string(), json!(i64::MIN));
200        params.insert("unsigned_max".to_string(), json!(u64::MAX));
201        params.insert("ratio".to_string(), json!(2.5));
202        params.insert("large_float".to_string(), json!(2_f64.powi(65)));
203        params.insert("levels".to_string(), json!([1, "two", false]));
204        params.insert(
205            "nested".to_string(),
206            json!({"venue": "SIM", "limits": [i64::MIN, u64::MAX]}),
207        );
208
209        Python::initialize();
210        Python::attach(|py| {
211            let dict = params_to_pydict(py, &params).unwrap();
212            let restored = pydict_to_params(py, &dict).unwrap().unwrap();
213            let empty = PyDict::new(py).unbind();
214            let absent = pydict_to_params(py, &empty).unwrap();
215
216            assert_eq!(restored, params);
217            assert_eq!(
218                restored.keys().collect::<Vec<_>>(),
219                params.keys().collect::<Vec<_>>()
220            );
221            assert_eq!(absent, None);
222        });
223    }
224
225    #[rstest]
226    #[case("18446744073709551616")]
227    #[case("18446744073709551617")]
228    #[case("-9223372036854775809")]
229    #[case("340282366920938463463374607431768211456")]
230    fn test_params_reject_out_of_range_integer(
231        #[case] integer: &str,
232        #[values("direct", "dict", "list", "tuple")] container: &str,
233    ) {
234        Python::initialize();
235        Python::attach(|py| {
236            let value = PyModule::import(py, "builtins")
237                .unwrap()
238                .getattr("int")
239                .unwrap()
240                .call1((integer,))
241                .unwrap();
242
243            let value = match container {
244                "dict" => {
245                    let nested = PyDict::new(py);
246                    nested.set_item("nested", value).unwrap();
247                    nested.into_any()
248                }
249                "list" => PyList::new(py, [value]).unwrap().into_any(),
250                "tuple" => PyTuple::new(py, [value]).unwrap().into_any(),
251                _ => value,
252            };
253
254            let dict = PyDict::new(py);
255            dict.set_item("value", value).unwrap();
256
257            let error = pydict_to_params(py, &dict.unbind()).unwrap_err();
258
259            assert!(error.is_instance_of::<PyValueError>(py));
260            assert_eq!(
261                error.value(py).to_string(),
262                format!(
263                    "Python integer must be in range [{}, {}]",
264                    i64::MIN,
265                    u64::MAX
266                ),
267            );
268        });
269    }
270
271    #[rstest]
272    fn test_params_reject_out_of_range_integer_from_dict_projection() {
273        Python::initialize();
274        Python::attach(|py| {
275            let module = PyModule::from_code(
276                py,
277                c"class ProjectedDict(dict):\n    calls = 0\n    def items(self):\n        self.calls += 1\n        return [('value', 18446744073709551617)]\n",
278                c"",
279                c"",
280            )
281            .unwrap();
282            let projected = module.getattr("ProjectedDict").unwrap().call0().unwrap();
283            projected.set_item("hidden", &projected).unwrap();
284            let dict = PyDict::new(py);
285            dict.set_item("nested", &projected).unwrap();
286
287            let error = crate::params::from_pydict(py, &dict.unbind()).unwrap_err();
288            let calls: usize = projected.getattr("calls").unwrap().extract().unwrap();
289
290            assert!(error.is_instance_of::<PyValueError>(py));
291            assert_eq!(
292                error.value(py).to_string(),
293                format!(
294                    "Python integer must be in range [{}, {}]",
295                    i64::MIN,
296                    u64::MAX
297                ),
298            );
299            assert_eq!(calls, 1);
300        });
301    }
302}