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