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 {
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, ¶ms).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}