Skip to main content

quarb_pyvalue/
lib.rs

1//! Quarb values as Python objects and back — the one pyo3 mapping,
2//! used in both directions: the Python bindings (`quarb-py`) hand
3//! query results to Python with it, and a command library that
4//! embeds Python (`quarb-lib-morph`, `quarb-lib-scipy`,
5//! `quarb-lib-sklearn`) hands operands in and takes results out with
6//! it; [`glue`] holds the call plumbing those libraries share.
7//!
8//! null ↔ None; booleans, integers, floats and strings to their
9//! Python selves; a list ↔ `list`; a record ↔ `dict` (insertion
10//! order kept); an instant ↔ a tz-aware `datetime`; a duration ↔
11//! `timedelta`; a quantity ↔ [`Quantity`]; a period crosses as its
12//! ISO text. Coming back, a tuple is a list too, a named tuple (and
13//! scipy's result objects) a record, a numpy scalar its item, and
14//! any other object a *library value* ([`PyForeign`]; spec: Command
15//! Libraries, "A library value"), kept as it is for the next
16//! command and handed back whole when it crosses into Python
17//! again.
18
19use pyo3::IntoPyObjectExt;
20use pyo3::prelude::*;
21use pyo3::types::{IntoPyDict, PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
22use quarb::Value;
23
24/// A dimensioned value: the magnitude in the engine's SI-base
25/// expansion (`unit`, e.g. `m`, `kg*m^2/s^3`), with the authored
26/// form kept for display (`written`, e.g. `42 km`) when the source
27/// carried one.
28#[pyclass(frozen, module = "quarb", from_py_object)]
29#[derive(Clone, PartialEq)]
30pub struct Quantity {
31    #[pyo3(get)]
32    pub magnitude: f64,
33    #[pyo3(get)]
34    pub unit: String,
35    #[pyo3(get)]
36    pub written: Option<String>,
37}
38
39#[pymethods]
40impl Quantity {
41    #[new]
42    #[pyo3(signature = (magnitude, unit, written=None))]
43    fn new(magnitude: f64, unit: String, written: Option<String>) -> Quantity {
44        Quantity {
45            magnitude,
46            unit,
47            written,
48        }
49    }
50
51    fn __repr__(&self) -> String {
52        match &self.written {
53            Some(w) => format!(
54                "Quantity(magnitude={}, unit={:?}, written={:?})",
55                self.magnitude, self.unit, w
56            ),
57            None => format!(
58                "Quantity(magnitude={}, unit={:?})",
59                self.magnitude, self.unit
60            ),
61        }
62    }
63
64    fn __str__(&self) -> String {
65        match &self.written {
66            Some(w) => w.clone(),
67            None => format!("{} {}", self.magnitude, self.unit),
68        }
69    }
70
71    fn __eq__(&self, other: &Quantity) -> bool {
72        self == other
73    }
74}
75
76/// An engine [`Value`] as its natural Python object.
77pub fn value_to_py(py: Python<'_>, v: &Value) -> PyResult<Py<PyAny>> {
78    Ok(match v {
79        Value::Null => py.None(),
80        Value::Bool(b) => b.into_py_any(py)?,
81        Value::Int(i) => i.into_py_any(py)?,
82        Value::Float(f) => f.into_py_any(py)?,
83        Value::Str(s) => s.into_py_any(py)?,
84        Value::List(items) => {
85            let list = PyList::empty(py);
86            for item in items.iter() {
87                list.append(value_to_py(py, item)?)?;
88            }
89            list.unbind().into()
90        }
91        Value::Record(fields) => {
92            let dict = PyDict::new(py);
93            for (k, val) in fields {
94                dict.set_item(k, value_to_py(py, val)?)?;
95            }
96            dict.unbind().into()
97        }
98        // A table crosses as its rows (records for a frame, lists
99        // for an array): the construction then makes the frame or
100        // the matrix of it.
101        Value::Table(t) => value_to_py(py, &t.rows_value())?,
102        Value::Instant {
103            secs,
104            nanos,
105            offset_min,
106        } => {
107            // datetime is microsecond-precision; nanos round down.
108            // No source offset means the UTC timeline — the result
109            // is tz-aware either way.
110            let datetime = py.import("datetime")?;
111            let tz = match offset_min {
112                Some(m) => {
113                    let delta = datetime
114                        .getattr("timedelta")?
115                        .call1((0, i64::from(*m) * 60))?;
116                    datetime.getattr("timezone")?.call1((delta,))?
117                }
118                None => datetime.getattr("timezone")?.getattr("utc")?,
119            };
120            datetime
121                .getattr("datetime")?
122                .call_method1("fromtimestamp", (*secs, &tz))?
123                .call_method(
124                    "replace",
125                    (),
126                    Some(&[("microsecond", nanos / 1000)].into_py_dict(py)?),
127                )?
128                .unbind()
129        }
130        Value::Duration { secs, nanos } => py
131            .import("datetime")?
132            .getattr("timedelta")?
133            .call1((0, *secs, nanos / 1000))?
134            .unbind(),
135        // Python has no value for a stretch of the calendar at a
136        // grain: a period arrives as its ISO-8601 text at reduced
137        // precision (`1899-10`, `1899-10-06T14:30`).
138        Value::Period { .. } => v.to_string().into_py_any(py)?,
139        Value::Quantity {
140            value,
141            base,
142            written,
143        } => Quantity {
144            magnitude: *value,
145            unit: base.clone(),
146            written: written.as_ref().map(|(m, u)| format!("{m} {u}")),
147        }
148        .into_pyobject(py)?
149        .into_any()
150        .unbind(),
151        // A library value of a Python library is its object, back as
152        // it is; one of another library has no Python form.
153        Value::Foreign(f) => match f.as_any().downcast_ref::<PyForeign>() {
154            Some(p) => p.obj.clone_ref(py),
155            None => {
156                return Err(pyo3::exceptions::PyTypeError::new_err(format!(
157                    "{} is a library value of another library",
158                    f.kind()
159                )));
160            }
161        },
162    })
163}
164
165/// A Python object as an engine [`Value`]: the mapping above read
166/// the other way. A `datetime` crosses as an instant on its own
167/// timeline (naive ones as UTC), a `timedelta` as a duration, a
168/// tuple as a list, a named tuple (scipy's result objects among
169/// them, their extra fields included) as a record, a `set` as a
170/// list in iteration order, a numpy scalar as its item, and any
171/// other object as a library value.
172pub fn py_to_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
173    let py = obj.py();
174    if obj.is_none() {
175        return Ok(Value::Null);
176    }
177    if let Ok(b) = obj.cast::<PyBool>() {
178        return Ok(Value::Bool(b.is_true()));
179    }
180    // A numpy scalar (float64, int64, bool_, datetime64) by its
181    // item, a Python builtin; recognised by its type's module, so
182    // numpy is never imported here.
183    if !foreign::is_plain(obj) {
184        let module = obj
185            .get_type()
186            .getattr("__module__")
187            .ok()
188            .and_then(|m| m.extract::<String>().ok())
189            .unwrap_or_default();
190        if module == "numpy" && !obj.hasattr("shape").unwrap_or(false) || module == "numpy" && obj.getattr("shape").ok().is_some_and(|s| s.len().unwrap_or(1) == 0) {
191            if let Ok(item) = obj.call_method0("item") {
192                return py_to_value(&item);
193            }
194        }
195    }
196    // A named tuple, or scipy's result bunch (a tuple with extra
197    // fields): a record of every field.
198    if obj.hasattr("_fields").unwrap_or(false) {
199        let mut fields = Vec::new();
200        let mut names: Vec<String> = obj.getattr("_fields")?.extract()?;
201        if let Ok(extra) = obj.getattr("_extra_fields") {
202            names.extend(extra.extract::<Vec<String>>().unwrap_or_default());
203        }
204        for n in names {
205            fields.push((n.clone(), py_to_value(&obj.getattr(n.as_str())?)?));
206        }
207        return Ok(Value::Record(fields));
208    }
209    if obj.cast::<PyInt>().is_ok() {
210        if let Ok(i) = obj.extract::<i64>() {
211            return Ok(Value::Int(i));
212        }
213        return Ok(Value::Float(obj.extract::<f64>()?));
214    }
215    if obj.cast::<PyFloat>().is_ok() {
216        return Ok(Value::Float(obj.extract::<f64>()?));
217    }
218    if let Ok(s) = obj.cast::<PyString>() {
219        return Ok(Value::Str(s.to_string_lossy().into_owned()));
220    }
221    if let Ok(q) = obj.extract::<Quantity>() {
222        let written = q.written.as_ref().and_then(|w| {
223            let (m, u) = w.split_once(' ')?;
224            Some((m.parse::<f64>().ok()?, u.to_string()))
225        });
226        return Ok(Value::Quantity {
227            value: q.magnitude,
228            base: q.unit,
229            written,
230        });
231    }
232    if obj.cast::<PyList>().is_ok() || obj.cast::<PyTuple>().is_ok() {
233        let mut items = Vec::new();
234        for item in obj.try_iter()? {
235            items.push(py_to_value(&item?)?);
236        }
237        return Ok(Value::list(items));
238    }
239    if let Ok(d) = obj.cast::<PyDict>() {
240        let mut fields = Vec::new();
241        for (k, v) in d.iter() {
242            fields.push((k.str()?.to_string_lossy().into_owned(), py_to_value(&v)?));
243        }
244        return Ok(Value::Record(fields));
245    }
246    let datetime = py.import("datetime")?;
247    if obj.is_instance(&datetime.getattr("datetime")?)? {
248        let ts: f64 = obj.call_method0("timestamp")?.extract()?;
249        let secs = ts.floor() as i64;
250        let nanos = ((ts - ts.floor()) * 1e9).round() as u32;
251        let offset_min = match obj.call_method0("utcoffset")? {
252            o if o.is_none() => None,
253            o => Some((o.call_method0("total_seconds")?.extract::<f64>()? / 60.0) as i16),
254        };
255        return Ok(Value::Instant {
256            secs,
257            nanos,
258            offset_min,
259        });
260    }
261    if obj.is_instance(&datetime.getattr("timedelta")?)? {
262        let total: f64 = obj.call_method0("total_seconds")?.extract()?;
263        return Ok(Value::Duration {
264            secs: total.floor() as i64,
265            nanos: ((total - total.floor()) * 1e9).round() as u32,
266        });
267    }
268    if foreign::is_plain(obj) {
269        if obj.hasattr("__iter__")? && !obj.hasattr("read")? {
270            // A set, a generator, a dict view: whatever builtin
271            // iterates and is no mapping or text crosses as a list.
272            let mut items = Vec::new();
273            for item in obj.try_iter()? {
274                items.push(py_to_value(&item?)?);
275            }
276            return Ok(Value::list(items));
277        }
278        return Ok(Value::Str(obj.str()?.to_string_lossy().into_owned()));
279    }
280    // A frame or a two-dimensional array comes back as a table, a
281    // one-dimensional array or a positional Series as a list (spec:
282    // The Table Kind): the engine's own kinds, not the library's.
283    if let Some(v) = foreign::as_table(obj)? {
284        return Ok(v);
285    }
286    // Anything else — a fitted model, a distribution — is a library
287    // value, kept as it is.
288    Ok(Value::Foreign(quarb::ForeignValue::new(PyForeign::new(
289        obj.clone().unbind(),
290    ))))
291}
292
293pub mod foreign;
294pub mod glue;
295pub mod reflect;
296pub use foreign::PyForeign;
297pub use glue::{PyGlue, importable};
298pub use reflect::Reflective;