Skip to main content

nautilus_core/python/
parsing.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//! JSON / string parsers for Python inputs.
17
18use pyo3::{
19    prelude::*,
20    types::{PyDict, PyList},
21};
22
23use super::{to_pykey_err, to_pyvalue_err};
24
25/// Extracts a required string value from a Python dictionary.
26///
27/// # Returns
28///
29/// Returns the extracted string value or a `PyErr` if the key is missing or extraction fails.
30///
31/// # Errors
32///
33/// Returns an error if the key is missing or value extraction fails.
34pub fn get_required_string(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<String> {
35    dict.get_item(key)?
36        .ok_or_else(|| to_pykey_err(format!("Missing required key: {key}")))?
37        .extract()
38}
39
40/// Extracts a required value from a Python dictionary.
41///
42/// # Returns
43///
44/// Returns the extracted value or a `PyErr` if the key is missing or extraction fails.
45///
46/// # Errors
47///
48/// Returns an error if the key is missing or value extraction fails.
49pub fn get_required<T>(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<T>
50where
51    T: for<'a, 'py> FromPyObject<'a, 'py>,
52    for<'a, 'py> PyErr: From<<T as FromPyObject<'a, 'py>>::Error>,
53{
54    dict.get_item(key)?
55        .ok_or_else(|| to_pykey_err(format!("Missing required key: {key}")))?
56        .extract()
57        .map_err(PyErr::from)
58}
59
60/// Extracts an optional value from a Python dictionary.
61///
62/// # Returns
63///
64/// Returns Some(value) if the key exists and extraction succeeds, None if the key is missing
65/// or if the value is Python None, or a `PyErr` if extraction fails.
66///
67/// # Errors
68///
69/// Returns an error if value extraction fails (but not if the key is missing or value is None).
70#[inline]
71pub fn get_optional<T>(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<T>>
72where
73    T: for<'a, 'py> FromPyObject<'a, 'py>,
74    for<'a, 'py> PyErr: From<<T as FromPyObject<'a, 'py>>::Error>,
75{
76    match dict.get_item(key)? {
77        Some(value) => {
78            if value.is_none() {
79                Ok(None)
80            } else {
81                value.extract().map(Some).map_err(PyErr::from)
82            }
83        }
84        None => Ok(None),
85    }
86}
87
88/// Extracts and parses a required value from a Python dictionary.
89///
90/// # Returns
91///
92/// Returns the parsed value or a `PyErr` if the key is missing, extraction fails, or parsing fails.
93///
94/// # Errors
95///
96/// Returns an error if the key is missing, value extraction fails, or parsing fails.
97pub fn get_required_parsed<T, F>(dict: &Bound<'_, PyDict>, key: &str, parser: F) -> PyResult<T>
98where
99    F: FnOnce(String) -> Result<T, String>,
100{
101    let value_str = get_required_string(dict, key)?;
102    parser(value_str).map_err(|e| to_pyvalue_err(format!("Failed to parse '{key}': {e}")))
103}
104
105/// Extracts and parses an optional value from a Python dictionary.
106///
107/// # Returns
108///
109/// Returns `Some(parsed_value)` if the key exists and parsing succeeds, None if the key is missing
110/// or if the value is Python None, or a `PyErr` if extraction or parsing fails.
111///
112/// # Errors
113///
114/// Returns an error if value extraction or parsing fails (but not if the key is missing or value is
115/// None).
116pub fn get_optional_parsed<T, F>(
117    dict: &Bound<'_, PyDict>,
118    key: &str,
119    parser: F,
120) -> PyResult<Option<T>>
121where
122    F: FnOnce(String) -> Result<T, String>,
123{
124    get_optional::<String>(dict, key)?
125        .map(parser)
126        .transpose()
127        .map_err(|e| to_pyvalue_err(format!("Failed to parse '{key}': {e}")))
128}
129
130/// Extracts a required `PyList` from a Python dictionary.
131///
132/// # Returns
133///
134/// Returns the extracted `PyList` or a `PyErr` if the key is missing or extraction fails.
135///
136/// # Errors
137///
138/// Returns an error if the key is missing or value extraction fails.
139pub fn get_required_list<'py>(
140    dict: &Bound<'py, PyDict>,
141    key: &str,
142) -> PyResult<Bound<'py, PyList>> {
143    dict.get_item(key)?
144        .ok_or_else(|| to_pykey_err(format!("Missing required key: {key}")))?
145        .cast_into()
146        .map_err(Into::into)
147}
148
149#[cfg(test)]
150mod tests {
151    use std::sync::Once;
152
153    use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
154    use rstest::rstest;
155
156    use super::*;
157
158    fn ensure_python_initialized() {
159        static INIT: Once = Once::new();
160        INIT.call_once(Python::initialize);
161    }
162
163    #[rstest]
164    fn test_get_required_string() {
165        ensure_python_initialized();
166
167        Python::attach(|py| {
168            let dict = PyDict::new(py);
169            dict.set_item("name", "nautilus").unwrap();
170
171            let value = get_required_string(&dict, "name").unwrap();
172            let error = get_required_string(&dict, "missing").unwrap_err();
173
174            assert_eq!(value, "nautilus");
175            assert!(error.is_instance_of::<PyKeyError>(py));
176            assert_eq!(
177                error.value(py).to_string(),
178                "'Missing required key: missing'"
179            );
180        });
181    }
182
183    #[rstest]
184    fn test_get_required() {
185        ensure_python_initialized();
186
187        Python::attach(|py| {
188            let dict = PyDict::new(py);
189            dict.set_item("quantity", 125_u64).unwrap();
190
191            let value = get_required::<u64>(&dict, "quantity").unwrap();
192            let missing = get_required::<u64>(&dict, "missing").unwrap_err();
193            dict.set_item("quantity", "invalid").unwrap();
194            let invalid = get_required::<u64>(&dict, "quantity").unwrap_err();
195
196            assert_eq!(value, 125);
197            assert!(missing.is_instance_of::<PyKeyError>(py));
198            assert_eq!(
199                missing.value(py).to_string(),
200                "'Missing required key: missing'"
201            );
202            assert!(invalid.is_instance_of::<PyTypeError>(py));
203        });
204    }
205
206    #[rstest]
207    fn test_get_required_parsed_and_list() {
208        ensure_python_initialized();
209
210        Python::attach(|py| {
211            let dict = PyDict::new(py);
212            dict.set_item("limit", "250").unwrap();
213            dict.set_item("levels", PyList::new(py, [2_u64, 5, 8]).unwrap())
214                .unwrap();
215
216            let parsed = get_required_parsed(&dict, "limit", |value| {
217                value.parse::<u64>().map_err(|e| e.to_string())
218            })
219            .unwrap();
220            let levels = get_required_list(&dict, "levels")
221                .unwrap()
222                .extract::<Vec<u64>>()
223                .unwrap();
224            dict.set_item("limit", "invalid").unwrap();
225            let invalid =
226                get_required_parsed::<u64, _>(&dict, "limit", |_| Err("not a number".to_string()))
227                    .unwrap_err();
228            let missing = get_required_list(&dict, "missing").unwrap_err();
229
230            assert_eq!(parsed, 250);
231            assert_eq!(levels, [2, 5, 8]);
232            assert!(invalid.is_instance_of::<PyValueError>(py));
233            assert_eq!(
234                invalid.value(py).to_string(),
235                "Failed to parse 'limit': not a number"
236            );
237            assert!(missing.is_instance_of::<PyKeyError>(py));
238            assert_eq!(
239                missing.value(py).to_string(),
240                "'Missing required key: missing'"
241            );
242        });
243    }
244
245    #[rstest]
246    fn test_get_optional_parsed() {
247        ensure_python_initialized();
248
249        Python::attach(|py| {
250            let dict = PyDict::new(py);
251            dict.set_item("value", "42").unwrap();
252            let parsed = get_optional_parsed(&dict, "value", |value| {
253                value.parse::<u64>().map_err(|e| e.to_string())
254            })
255            .unwrap();
256            let missing = get_optional_parsed(&dict, "missing", |value| {
257                value.parse::<u64>().map_err(|e| e.to_string())
258            })
259            .unwrap();
260
261            dict.set_item("value", py.None()).unwrap();
262            let none = get_optional_parsed(&dict, "value", |value| {
263                value.parse::<u64>().map_err(|e| e.to_string())
264            })
265            .unwrap();
266
267            dict.set_item("value", "invalid").unwrap();
268            let error =
269                get_optional_parsed::<u64, _>(&dict, "value", |_| Err("not a number".to_string()))
270                    .unwrap_err();
271
272            assert_eq!(parsed, Some(42));
273            assert_eq!(missing, None);
274            assert_eq!(none, None);
275            assert!(error.is_instance_of::<PyValueError>(py));
276            assert_eq!(
277                error.value(py).to_string(),
278                "Failed to parse 'value': not a number"
279            );
280        });
281    }
282}