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`.
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` 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.borrowed_entries() {
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(#[values(false, true)] shared: bool) {
190        let params = Params::new(
191            [
192                ("null".to_string(), Value::Null),
193                ("enabled".to_string(), json!(true)),
194                (
195                    "name".to_string(),
196                    json!("Nautilus \u{65e5}\u{672c}\u{8a9e}"),
197                ),
198                ("signed".to_string(), json!(-7)),
199                ("unsigned".to_string(), json!(42_u64)),
200                ("signed_min".to_string(), json!(i64::MIN)),
201                ("unsigned_max".to_string(), json!(u64::MAX)),
202                ("ratio".to_string(), json!(2.5)),
203                ("large_float".to_string(), json!(2_f64.powi(65))),
204                ("levels".to_string(), json!([1, "two", false])),
205                (
206                    "nested".to_string(),
207                    json!({"venue": "SIM", "limits": [i64::MIN, u64::MAX]}),
208                ),
209            ]
210            .into(),
211        );
212
213        let params = if shared {
214            Params::from_shared(params.to_shared_map())
215        } else {
216            params
217        };
218
219        let retained = params.estimated_heap_size();
220        Python::initialize();
221        Python::attach(|py| {
222            let dict = params_to_pydict(py, &params).unwrap();
223            assert_eq!(params.estimated_heap_size(), retained);
224            let restored = pydict_to_params(py, &dict).unwrap().unwrap();
225            let empty = PyDict::new(py).unbind();
226            let absent = pydict_to_params(py, &empty).unwrap();
227
228            assert_eq!(restored, params);
229            assert_eq!(
230                restored.keys().collect::<Vec<_>>(),
231                params.keys().collect::<Vec<_>>()
232            );
233            assert_eq!(absent, None);
234        });
235    }
236
237    #[rstest]
238    #[case("18446744073709551616")]
239    #[case("18446744073709551617")]
240    #[case("-9223372036854775809")]
241    #[case("340282366920938463463374607431768211456")]
242    fn test_params_reject_out_of_range_integer(
243        #[case] integer: &str,
244        #[values("direct", "dict", "list", "tuple")] container: &str,
245    ) {
246        Python::initialize();
247        Python::attach(|py| {
248            let value = PyModule::import(py, "builtins")
249                .unwrap()
250                .getattr("int")
251                .unwrap()
252                .call1((integer,))
253                .unwrap();
254
255            let value = match container {
256                "dict" => {
257                    let nested = PyDict::new(py);
258                    nested.set_item("nested", value).unwrap();
259                    nested.into_any()
260                }
261                "list" => PyList::new(py, [value]).unwrap().into_any(),
262                "tuple" => PyTuple::new(py, [value]).unwrap().into_any(),
263                _ => value,
264            };
265
266            let dict = PyDict::new(py);
267            dict.set_item("value", value).unwrap();
268
269            let error = pydict_to_params(py, &dict.unbind()).unwrap_err();
270
271            assert!(error.is_instance_of::<PyValueError>(py));
272            assert_eq!(
273                error.value(py).to_string(),
274                format!(
275                    "Python integer must be in range [{}, {}]",
276                    i64::MIN,
277                    u64::MAX
278                ),
279            );
280        });
281    }
282
283    #[rstest]
284    fn test_params_reject_out_of_range_integer_from_dict_projection() {
285        Python::initialize();
286        Python::attach(|py| {
287            let module = PyModule::from_code(
288                py,
289                c"class ProjectedDict(dict):\n    calls = 0\n    def items(self):\n        self.calls += 1\n        return [('value', 18446744073709551617)]\n",
290                c"",
291                c"",
292            )
293            .unwrap();
294            let projected = module.getattr("ProjectedDict").unwrap().call0().unwrap();
295            projected.set_item("hidden", &projected).unwrap();
296            let dict = PyDict::new(py);
297            dict.set_item("nested", &projected).unwrap();
298
299            let error = crate::params::from_pydict(py, &dict.unbind()).unwrap_err();
300            let calls: usize = projected.getattr("calls").unwrap().extract().unwrap();
301
302            assert!(error.is_instance_of::<PyValueError>(py));
303            assert_eq!(
304                error.value(py).to_string(),
305                format!(
306                    "Python integer must be in range [{}, {}]",
307                    i64::MIN,
308                    u64::MAX
309                ),
310            );
311            assert_eq!(calls, 1);
312        });
313    }
314}