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        // a projection coerces to text as its bare key
85        Value::Projection { key, .. } => key.clone().into_py_any(py)?,
86        // a quarq is its source text
87        Value::Quarq(q) => q.source.clone().into_py_any(py)?,
88        Value::List(items) => {
89            let list = PyList::empty(py);
90            for item in items.iter() {
91                list.append(value_to_py(py, item)?)?;
92            }
93            list.unbind().into()
94        }
95        Value::Record(fields) => {
96            let dict = PyDict::new(py);
97            for (k, val) in fields {
98                dict.set_item(k, value_to_py(py, val)?)?;
99            }
100            dict.unbind().into()
101        }
102        // A table crosses as its rows (records for a frame, lists
103        // for an array): the construction then makes the frame or
104        // the matrix of it.
105        Value::Table(t) => value_to_py(py, &t.rows_value())?,
106        Value::Instant {
107            secs,
108            nanos,
109            offset_min,
110            ..
111        } => {
112            // datetime is microsecond-precision; nanos round down.
113            // No source offset means the UTC timeline — the result
114            // is tz-aware either way.
115            let datetime = py.import("datetime")?;
116            let tz = match offset_min {
117                Some(m) => {
118                    let delta = datetime
119                        .getattr("timedelta")?
120                        .call1((0, i64::from(*m) * 60))?;
121                    datetime.getattr("timezone")?.call1((delta,))?
122                }
123                None => datetime.getattr("timezone")?.getattr("utc")?,
124            };
125            datetime
126                .getattr("datetime")?
127                .call_method1("fromtimestamp", (*secs, &tz))?
128                .call_method(
129                    "replace",
130                    (),
131                    Some(&[("microsecond", nanos / 1000)].into_py_dict(py)?),
132                )?
133                .unbind()
134        }
135        // a span of time, the quantity of dimension T, is a timedelta
136        Value::Quantity { base, value, .. } if base == "s" => {
137            let (secs, nanos) = quarb::split_seconds_pub(*value);
138            py.import("datetime")?
139                .getattr("timedelta")?
140                .call1((0, secs, nanos / 1000))?
141                .unbind()
142        }
143        // Python has no value for a stretch of the calendar at a
144        // grain: a period arrives as its ISO-8601 text at reduced
145        // precision (`1899-10`, `1899-10-06T14:30`).
146        Value::Period { .. } => v.to_string().into_py_any(py)?,
147        Value::Quantity {
148            value,
149            base,
150            written,
151        } => Quantity {
152            magnitude: *value,
153            unit: base.clone(),
154            written: written.as_ref().map(|(m, u)| format!("{m} {u}")),
155        }
156        .into_pyobject(py)?
157        .into_any()
158        .unbind(),
159        // A node reference crosses as its locator text: no Python
160        // form keeps nodeness.
161        Value::Node(n) => n.locator.as_ref().into_py_any(py)?,
162        // A library value of a Python library is its object, back as
163        // it is; one of another library has no Python form.
164        Value::Foreign(f) => match f.as_any().downcast_ref::<PyForeign>() {
165            Some(p) => p.obj.clone_ref(py),
166            None => {
167                return Err(pyo3::exceptions::PyTypeError::new_err(format!(
168                    "{} is a library value of another library",
169                    f.kind()
170                )));
171            }
172        },
173    })
174}
175
176/// A Python object as an engine [`Value`]: the mapping above read
177/// the other way. A `datetime` crosses as an instant on its own
178/// timeline (naive ones as UTC), a `timedelta` as a duration, a
179/// tuple as a list, a named tuple (scipy's result objects among
180/// them, their extra fields included) as a record, a `set` as a
181/// list in iteration order, a numpy scalar as its item, and any
182/// other object as a library value.
183pub fn py_to_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
184    let py = obj.py();
185    if obj.is_none() {
186        return Ok(Value::Null);
187    }
188    if let Ok(b) = obj.cast::<PyBool>() {
189        return Ok(Value::Bool(b.is_true()));
190    }
191    // A numpy scalar (float64, int64, bool_, datetime64) by its
192    // item, a Python builtin; recognised by its type's module, so
193    // numpy is never imported here.
194    if !foreign::is_plain(obj) {
195        let module = obj
196            .get_type()
197            .getattr("__module__")
198            .ok()
199            .and_then(|m| m.extract::<String>().ok())
200            .unwrap_or_default();
201        if module == "numpy" && !obj.hasattr("shape").unwrap_or(false)
202            || module == "numpy"
203                && obj
204                    .getattr("shape")
205                    .ok()
206                    .is_some_and(|s| s.len().unwrap_or(1) == 0)
207        {
208            if let Ok(item) = obj.call_method0("item") {
209                return py_to_value(&item);
210            }
211        }
212    }
213    // A named tuple, or scipy's result bunch (a tuple with extra
214    // fields): a record of every field.
215    if obj.hasattr("_fields").unwrap_or(false) {
216        let mut fields = Vec::new();
217        let mut names: Vec<String> = obj.getattr("_fields")?.extract()?;
218        if let Ok(extra) = obj.getattr("_extra_fields") {
219            names.extend(extra.extract::<Vec<String>>().unwrap_or_default());
220        }
221        for n in names {
222            fields.push((n.clone(), py_to_value(&obj.getattr(n.as_str())?)?));
223        }
224        return Ok(Value::Record(fields.into()));
225    }
226    if obj.cast::<PyInt>().is_ok() {
227        if let Ok(i) = obj.extract::<i64>() {
228            return Ok(Value::Int(i));
229        }
230        return Ok(Value::Float(obj.extract::<f64>()?));
231    }
232    if obj.cast::<PyFloat>().is_ok() {
233        return Ok(Value::Float(obj.extract::<f64>()?));
234    }
235    if let Ok(s) = obj.cast::<PyString>() {
236        return Ok(Value::Str(s.to_string_lossy().into_owned()));
237    }
238    if let Ok(q) = obj.extract::<Quantity>() {
239        let written = q.written.as_ref().and_then(|w| {
240            let (m, u) = w.split_once(' ')?;
241            Some((m.parse::<f64>().ok()?, u.to_string()))
242        });
243        return Ok(Value::Quantity {
244            value: q.magnitude,
245            base: q.unit,
246            written,
247        });
248    }
249    if obj.cast::<PyList>().is_ok() || obj.cast::<PyTuple>().is_ok() {
250        let mut items = Vec::new();
251        for item in obj.try_iter()? {
252            items.push(py_to_value(&item?)?);
253        }
254        return Ok(Value::list(items));
255    }
256    if let Ok(d) = obj.cast::<PyDict>() {
257        let mut fields = Vec::new();
258        for (k, v) in d.iter() {
259            fields.push((k.str()?.to_string_lossy().into_owned(), py_to_value(&v)?));
260        }
261        return Ok(Value::Record(fields.into()));
262    }
263    let datetime = py.import("datetime")?;
264    if obj.is_instance(&datetime.getattr("datetime")?)? {
265        let ts: f64 = obj.call_method0("timestamp")?.extract()?;
266        let secs = ts.floor() as i64;
267        let nanos = ((ts - ts.floor()) * 1e9).round() as u32;
268        let offset_min = match obj.call_method0("utcoffset")? {
269            o if o.is_none() => None,
270            o => Some((o.call_method0("total_seconds")?.extract::<f64>()? / 60.0) as i16),
271        };
272        return Ok(Value::Instant {
273            secs,
274            nanos,
275            offset_min,
276            calendar: Default::default(),
277        });
278    }
279    if obj.is_instance(&datetime.getattr("timedelta")?)? {
280        let total: f64 = obj.call_method0("total_seconds")?.extract()?;
281        return Ok(Value::seconds(
282            total.floor() as i64,
283            ((total - total.floor()) * 1e9).round() as u32,
284        ));
285    }
286    if foreign::is_plain(obj) {
287        if obj.hasattr("__iter__")? && !obj.hasattr("read")? {
288            // A set, a generator, a dict view: whatever builtin
289            // iterates and is no mapping or text crosses as a list.
290            let mut items = Vec::new();
291            for item in obj.try_iter()? {
292                items.push(py_to_value(&item?)?);
293            }
294            return Ok(Value::list(items));
295        }
296        return Ok(Value::Str(obj.str()?.to_string_lossy().into_owned()));
297    }
298    // A frame or a two-dimensional array comes back as a table, a
299    // one-dimensional array or a positional Series as a list (spec:
300    // The Table Kind): the engine's own kinds, not the library's.
301    if let Some(v) = foreign::as_table(obj)? {
302        return Ok(v);
303    }
304    // Anything else — a fitted model, a distribution — is a library
305    // value, kept as it is.
306    Ok(Value::Foreign(quarb::ForeignValue::new(PyForeign::new(
307        obj.clone().unbind(),
308    ))))
309}
310
311pub mod foreign;
312pub mod glue;
313pub mod reflect;
314pub use foreign::PyForeign;
315pub use glue::{PyGlue, importable};
316pub use reflect::Reflective;