Skip to main content

_etoon/
lib.rs

1pub mod toon;
2
3#[cfg(feature = "python")]
4mod py_binding {
5    //! The options seam. `Config` holds the defaults; this adapter maps the
6    //! Python keyword surface onto it and nothing more.
7    use crate::toon::{encode_with, Config};
8    use pyo3::exceptions::{PyTypeError, PyValueError};
9    use pyo3::prelude::*;
10    use pyo3::types::{PyBytes, PyDict};
11
12    /// Keyword names accepted by [`dumps_bytes`]; anything else is rejected
13    /// the way flat parameters would reject it.
14    const OPTION_NAMES: [&str; 7] = [
15        "delimiter",
16        "key_folding",
17        "flatten_depth",
18        "empty_array_bare",
19        "escape_controls",
20        "max_depth",
21        "max_input_bytes",
22    ];
23
24    /// Read one optional keyword into `dest`; a missing name or an explicit
25    /// `None` leaves the Config default in place.
26    macro_rules! opt_kw {
27        ($kwargs:expr, $name:literal, $dest:expr, $ty:ty) => {
28            if let Some(v) = $kwargs.get_item($name)? {
29                if !v.is_none() {
30                    $dest = v.extract::<$ty>()?;
31                }
32            }
33        };
34    }
35
36    /// Map caller kwargs onto a `Config`. Defaults come solely from
37    /// `Config::default`; this function is the only place that knows the
38    /// Python-side names.
39    fn config_from_kwargs(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<Config> {
40        let mut cfg = Config::default();
41        let Some(kwargs) = kwargs else {
42            return Ok(cfg);
43        };
44        for k in kwargs.keys().iter() {
45            let name: String = k.extract()?;
46            if !OPTION_NAMES.contains(&name.as_str()) {
47                return Err(PyTypeError::new_err(format!(
48                    "unexpected keyword argument '{name}'"
49                )));
50            }
51        }
52        if let Some(v) = kwargs.get_item("delimiter")? {
53            if !v.is_none() {
54                let d: String = v.extract()?;
55                // Exact match: an empty or multi-character delimiter must
56                // raise instead of silently falling back to its first byte.
57                cfg.delimiter = match d.as_bytes() {
58                    b"," => b',',
59                    b"\t" => b'\t',
60                    b"|" => b'|',
61                    _ => {
62                        return Err(PyValueError::new_err(
63                            "delimiter must be ',', '\\t', or '|'",
64                        ))
65                    }
66                };
67            }
68        }
69        opt_kw!(kwargs, "key_folding", cfg.key_folding, bool);
70        opt_kw!(kwargs, "flatten_depth", cfg.flatten_depth, Option<usize>);
71        opt_kw!(kwargs, "empty_array_bare", cfg.empty_array_bare, bool);
72        opt_kw!(kwargs, "escape_controls", cfg.escape_controls, bool);
73        opt_kw!(kwargs, "max_depth", cfg.max_depth, usize);
74        opt_kw!(kwargs, "max_input_bytes", cfg.max_input_bytes, usize);
75        Ok(cfg)
76    }
77
78    #[pyfunction(signature = (json_bytes, **kwargs))]
79    fn dumps_bytes<'py>(
80        py: Python<'py>,
81        json_bytes: &Bound<'py, PyBytes>,
82        kwargs: Option<&Bound<'py, PyDict>>,
83    ) -> PyResult<String> {
84        let cfg = config_from_kwargs(kwargs)?;
85        let bytes = json_bytes.as_bytes();
86        py.detach(|| encode_with(bytes, &cfg))
87            .map_err(|e| PyValueError::new_err(e.to_string()))
88    }
89
90    #[pymodule]
91    fn _etoon(m: &Bound<'_, PyModule>) -> PyResult<()> {
92        m.add_function(wrap_pyfunction!(dumps_bytes, m)?)?;
93        Ok(())
94    }
95}