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 `PyErr` 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 `PyErr` 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 `PyErr` 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 `PyErr` 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 `PyErr` if value extraction or parsing fails (but not if the key is missing or value is None).
115pub fn get_optional_parsed<T, F>(
116    dict: &Bound<'_, PyDict>,
117    key: &str,
118    parser: F,
119) -> PyResult<Option<T>>
120where
121    F: FnOnce(String) -> Result<T, String>,
122{
123    get_optional::<String>(dict, key)?
124        .map(parser)
125        .transpose()
126        .map_err(|e| to_pyvalue_err(format!("Failed to parse '{key}': {e}")))
127}
128
129/// Extracts a required `PyList` from a Python dictionary.
130///
131/// # Returns
132///
133/// Returns the extracted `PyList` or a `PyErr` if the key is missing or extraction fails.
134///
135/// # Errors
136///
137/// Returns `PyErr` if the key is missing or value extraction fails.
138pub fn get_required_list<'py>(
139    dict: &Bound<'py, PyDict>,
140    key: &str,
141) -> PyResult<Bound<'py, PyList>> {
142    dict.get_item(key)?
143        .ok_or_else(|| to_pykey_err(format!("Missing required key: {key}")))?
144        .cast_into()
145        .map_err(Into::into)
146}
147
148#[cfg(test)]
149mod tests {
150    use std::sync::Once;
151
152    use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
153    use rstest::rstest;
154
155    use super::*;
156
157    fn ensure_python_initialized() {
158        static INIT: Once = Once::new();
159        INIT.call_once(Python::initialize);
160    }
161
162    #[rstest]
163    fn test_get_required_string() {
164        ensure_python_initialized();
165
166        Python::attach(|py| {
167            let dict = PyDict::new(py);
168            dict.set_item("name", "nautilus").unwrap();
169
170            let value = get_required_string(&dict, "name").unwrap();
171            let error = get_required_string(&dict, "missing").unwrap_err();
172
173            assert_eq!(value, "nautilus");
174            assert!(error.is_instance_of::<PyKeyError>(py));
175            assert_eq!(
176                error.value(py).to_string(),
177                "'Missing required key: missing'"
178            );
179        });
180    }
181
182    #[rstest]
183    fn test_get_required() {
184        ensure_python_initialized();
185
186        Python::attach(|py| {
187            let dict = PyDict::new(py);
188            dict.set_item("quantity", 125_u64).unwrap();
189
190            let value = get_required::<u64>(&dict, "quantity").unwrap();
191            let missing = get_required::<u64>(&dict, "missing").unwrap_err();
192            dict.set_item("quantity", "invalid").unwrap();
193            let invalid = get_required::<u64>(&dict, "quantity").unwrap_err();
194
195            assert_eq!(value, 125);
196            assert!(missing.is_instance_of::<PyKeyError>(py));
197            assert_eq!(
198                missing.value(py).to_string(),
199                "'Missing required key: missing'"
200            );
201            assert!(invalid.is_instance_of::<PyTypeError>(py));
202        });
203    }
204
205    #[rstest]
206    fn test_get_required_parsed_and_list() {
207        ensure_python_initialized();
208
209        Python::attach(|py| {
210            let dict = PyDict::new(py);
211            dict.set_item("limit", "250").unwrap();
212            dict.set_item("levels", PyList::new(py, [2_u64, 5, 8]).unwrap())
213                .unwrap();
214
215            let parsed = get_required_parsed(&dict, "limit", |value| {
216                value.parse::<u64>().map_err(|e| e.to_string())
217            })
218            .unwrap();
219            let levels = get_required_list(&dict, "levels")
220                .unwrap()
221                .extract::<Vec<u64>>()
222                .unwrap();
223            dict.set_item("limit", "invalid").unwrap();
224            let invalid =
225                get_required_parsed::<u64, _>(&dict, "limit", |_| Err("not a number".to_string()))
226                    .unwrap_err();
227            let missing = get_required_list(&dict, "missing").unwrap_err();
228
229            assert_eq!(parsed, 250);
230            assert_eq!(levels, [2, 5, 8]);
231            assert!(invalid.is_instance_of::<PyValueError>(py));
232            assert_eq!(
233                invalid.value(py).to_string(),
234                "Failed to parse 'limit': not a number"
235            );
236            assert!(missing.is_instance_of::<PyKeyError>(py));
237            assert_eq!(
238                missing.value(py).to_string(),
239                "'Missing required key: missing'"
240            );
241        });
242    }
243
244    #[rstest]
245    fn test_get_optional_parsed() {
246        ensure_python_initialized();
247
248        Python::attach(|py| {
249            let dict = PyDict::new(py);
250            dict.set_item("value", "42").unwrap();
251            let parsed = get_optional_parsed(&dict, "value", |value| {
252                value.parse::<u64>().map_err(|e| e.to_string())
253            })
254            .unwrap();
255            let missing = get_optional_parsed(&dict, "missing", |value| {
256                value.parse::<u64>().map_err(|e| e.to_string())
257            })
258            .unwrap();
259
260            dict.set_item("value", py.None()).unwrap();
261            let none = get_optional_parsed(&dict, "value", |value| {
262                value.parse::<u64>().map_err(|e| e.to_string())
263            })
264            .unwrap();
265
266            dict.set_item("value", "invalid").unwrap();
267            let error =
268                get_optional_parsed::<u64, _>(&dict, "value", |_| Err("not a number".to_string()))
269                    .unwrap_err();
270
271            assert_eq!(parsed, Some(42));
272            assert_eq!(missing, None);
273            assert_eq!(none, None);
274            assert!(error.is_instance_of::<PyValueError>(py));
275            assert_eq!(
276                error.value(py).to_string(),
277                "Failed to parse 'value': not a number"
278            );
279        });
280    }
281}