Skip to main content

fidius_python/
value_bridge.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
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//! Bridge between `serde_json::Value` and Python objects.
16//!
17//! For typed plugin calls, the host serialises arguments through
18//! `serde_json::to_value` (any `Serialize` type works) and we then convert
19//! the JSON tree into Python primitives. The reverse is symmetric: take a
20//! Python return value and turn it back into a `serde_json::Value` the host
21//! can `serde_json::from_value::<O>` into the expected return type.
22//!
23//! JSON is the wire format because it's the common ground between
24//! self-describing-serde-on-the-Rust-side and natural-Python-types-on-the-
25//! Python-side. The hot path for bulk data uses `#[wire(raw)]` (T-0082)
26//! which bypasses this layer entirely.
27
28use pyo3::prelude::*;
29use pyo3::types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
30use serde_json::{Map, Value};
31
32/// Convert a `serde_json::Value` into a Python object owned by `py`.
33pub fn value_to_pyobject<'py>(py: Python<'py>, value: &Value) -> PyResult<Bound<'py, PyAny>> {
34    match value {
35        Value::Null => Ok(py.None().into_bound(py)),
36        Value::Bool(b) => Ok(PyBool::new(py, *b).to_owned().into_any()),
37        Value::Number(n) => {
38            if let Some(i) = n.as_i64() {
39                Ok(i.into_pyobject(py)?.into_any())
40            } else if let Some(u) = n.as_u64() {
41                Ok(u.into_pyobject(py)?.into_any())
42            } else {
43                let f = n.as_f64().ok_or_else(|| {
44                    pyo3::exceptions::PyValueError::new_err("non-finite number in JSON value")
45                })?;
46                Ok(f.into_pyobject(py)?.into_any())
47            }
48        }
49        Value::String(s) => Ok(PyString::new(py, s).into_any()),
50        Value::Array(items) => {
51            let list = PyList::empty(py);
52            for item in items {
53                list.append(value_to_pyobject(py, item)?)?;
54            }
55            Ok(list.into_any())
56        }
57        Value::Object(map) => {
58            let dict = PyDict::new(py);
59            for (k, v) in map {
60                dict.set_item(k, value_to_pyobject(py, v)?)?;
61            }
62            Ok(dict.into_any())
63        }
64    }
65}
66
67/// Convert a Python object back into a `serde_json::Value`.
68///
69/// `bytes` are encoded as a JSON object `{ "__bytes__": [...] }` so the
70/// host can recover them via a custom serde adapter (today only used by
71/// internal error paths — typed bytes go through `#[wire(raw)]` instead).
72pub fn pyobject_to_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
73    if obj.is_none() {
74        return Ok(Value::Null);
75    }
76    if let Ok(b) = obj.downcast::<PyBool>() {
77        return Ok(Value::Bool(b.is_true()));
78    }
79    if let Ok(s) = obj.downcast::<PyString>() {
80        return Ok(Value::String(s.extract()?));
81    }
82    // PyBytes: encode as a JSON array of byte values; host-side adapters
83    // can recover Vec<u8>. Plugins that want zero-copy bulk bytes should
84    // use #[wire(raw)] instead.
85    if let Ok(b) = obj.downcast::<PyBytes>() {
86        let bytes: &[u8] = b.as_bytes();
87        let arr: Vec<Value> = bytes
88            .iter()
89            .map(|x| Value::Number((*x as i64).into()))
90            .collect();
91        return Ok(Value::Array(arr));
92    }
93    if let Ok(_f) = obj.downcast::<PyFloat>() {
94        let f: f64 = obj.extract()?;
95        let n = serde_json::Number::from_f64(f).ok_or_else(|| {
96            pyo3::exceptions::PyValueError::new_err("non-finite float cannot be encoded to JSON")
97        })?;
98        return Ok(Value::Number(n));
99    }
100    if let Ok(_i) = obj.downcast::<PyInt>() {
101        // Try i64, then u64, then fall back to f64.
102        if let Ok(i) = obj.extract::<i64>() {
103            return Ok(Value::Number(i.into()));
104        }
105        if let Ok(u) = obj.extract::<u64>() {
106            return Ok(Value::Number(u.into()));
107        }
108        let f: f64 = obj.extract()?;
109        let n = serde_json::Number::from_f64(f)
110            .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("integer too large for JSON"))?;
111        return Ok(Value::Number(n));
112    }
113    if let Ok(list) = obj.downcast::<PyList>() {
114        let mut out = Vec::with_capacity(list.len());
115        for item in list.iter() {
116            out.push(pyobject_to_value(&item)?);
117        }
118        return Ok(Value::Array(out));
119    }
120    if let Ok(tuple) = obj.downcast::<PyTuple>() {
121        let mut out = Vec::with_capacity(tuple.len());
122        for item in tuple.iter() {
123            out.push(pyobject_to_value(&item)?);
124        }
125        return Ok(Value::Array(out));
126    }
127    if let Ok(dict) = obj.downcast::<PyDict>() {
128        let mut map = Map::new();
129        for (k, v) in dict.iter() {
130            let key: String = k
131                .extract()
132                .or_else(|_| -> PyResult<String> { k.str()?.extract() })?;
133            map.insert(key, pyobject_to_value(&v)?);
134        }
135        return Ok(Value::Object(map));
136    }
137
138    // Fallback: try str(obj) so we don't lose the value entirely.
139    let s: String = obj.str()?.extract()?;
140    Ok(Value::String(s))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use serde_json::json;
147
148    #[test]
149    fn roundtrip_primitives() {
150        crate::ensure_initialized();
151        Python::with_gil(|py| {
152            for v in [
153                json!(null),
154                json!(true),
155                json!(42i64),
156                json!(3.5f64),
157                json!("hello"),
158                json!([1, "two", false]),
159                json!({"a": 1, "b": [2, 3]}),
160            ] {
161                let py_obj = value_to_pyobject(py, &v).unwrap();
162                let back = pyobject_to_value(&py_obj).unwrap();
163                assert_eq!(back, v, "round-trip failed for {v:?}");
164            }
165        });
166    }
167}