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::{PyDict, PyList, PyModule},
22};
23use serde_json::Value;
24
25use crate::{
26    params::Params,
27    python::{serialization::from_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/// # Errors
37///
38/// Returns a `PyErr` if:
39/// - the dict cannot be serialized to JSON
40/// - the JSON is not a valid object
41pub fn pydict_to_params(py: Python<'_>, dict: &Py<PyDict>) -> PyResult<Option<Params>> {
42    let dict_bound = dict.bind(py);
43    if dict_bound.is_empty() {
44        return Ok(None);
45    }
46
47    from_pyobject_pyo3(py, dict_bound.as_any()).map(Some)
48}
49
50/// Converts a `serde_json::Value` to a Python object.
51///
52/// This is a common conversion pattern used when converting `Params` to Python dicts.
53///
54/// # Errors
55///
56/// Returns a `PyErr` if the value type is unsupported, numeric extraction fails,
57/// or conversion fails.
58pub fn value_to_pyobject(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
59    match val {
60        Value::Null => Ok(py.None()),
61        Value::Bool(b) => b.into_py_any(py),
62        Value::String(s) => s.into_py_any(py),
63        Value::Number(n) => {
64            if n.is_i64() {
65                n.as_i64()
66                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as i64"))?
67                    .into_py_any(py)
68            } else if n.is_u64() {
69                n.as_u64()
70                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as u64"))?
71                    .into_py_any(py)
72            } else if n.is_f64() {
73                n.as_f64()
74                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as f64"))?
75                    .into_py_any(py)
76            } else {
77                Err(to_pyvalue_err("Unsupported JSON number type"))
78            }
79        }
80        Value::Array(arr) => {
81            let py_list = PyList::new(py, &[] as &[Py<PyAny>])?;
82            for item in arr {
83                let py_item = value_to_pyobject(py, item)?;
84                py_list.append(py_item)?;
85            }
86            py_list.into_py_any(py)
87        }
88        Value::Object(_) => {
89            // For nested objects, convert to dict recursively
90            let json_str = serde_json::to_string(val).map_err(to_pyvalue_err)?;
91            let py_dict: Py<PyDict> = PyModule::import(py, "json")?
92                .call_method("loads", (json_str,), None)?
93                .extract()?;
94            py_dict.into_py_any(py)
95        }
96    }
97}
98
99/// Converts `Params` (IndexMap<String, Value>) to a Python dict.
100///
101/// # Errors
102///
103/// Returns a `PyErr` if conversion of any value fails.
104pub fn params_to_pydict(py: Python<'_>, params: &Params) -> PyResult<Py<PyDict>> {
105    let dict = PyDict::new(py);
106    for (key, value) in params {
107        let py_value = value_to_pyobject(py, value)?;
108        dict.set_item(key, py_value)?;
109    }
110    Ok(dict.into())
111}
112
113#[cfg(test)]
114mod tests {
115    use rstest::rstest;
116    use serde_json::json;
117
118    use super::*;
119
120    #[derive(Debug, Clone, Copy)]
121    enum ExpectedNumber {
122        I64(i64),
123        U64(u64),
124        F64(f64),
125    }
126
127    #[rstest]
128    #[case(json!(-100_i64), ExpectedNumber::I64(-100))]
129    #[case(json!(42_u64), ExpectedNumber::U64(42))]
130    #[case(json!(2.5_f64), ExpectedNumber::F64(2.5))]
131    fn test_value_to_pyobject_number_branches(
132        #[case] value: Value,
133        #[case] expected: ExpectedNumber,
134    ) {
135        Python::initialize();
136        Python::attach(|py| {
137            let py_obj = value_to_pyobject(py, &value).unwrap();
138
139            match expected {
140                ExpectedNumber::I64(expected) => {
141                    assert_eq!(py_obj.extract::<i64>(py).unwrap(), expected);
142                }
143                ExpectedNumber::U64(expected) => {
144                    assert_eq!(py_obj.extract::<u64>(py).unwrap(), expected);
145                }
146                ExpectedNumber::F64(expected) => {
147                    let actual = py_obj.extract::<f64>(py).unwrap();
148                    assert!((actual - expected).abs() < f64::EPSILON);
149                }
150            }
151        });
152    }
153}