1use 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
30pub 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
83pub 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 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
132pub 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, ¶ms).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}