Skip to main content

quarb_pyvalue/
foreign.rs

1//! A Python object as a library value (spec: Command Libraries, "A
2//! library value"): what a Python-wrapping library keeps in its own
3//! form between two commands — a frame, an array, any object that is
4//! not plain data. The engine sees its kind, its text and its two
5//! exports; the libraries take the object back as it is.
6
7use pyo3::prelude::*;
8use pyo3::types::PyDict;
9use quarb::{Foreign, Value};
10use std::ffi::CString;
11use std::sync::OnceLock;
12
13/// The Python side of the two readings and the kind. Numpy and
14/// pandas are recognised by the module their types come from, so
15/// neither is imported for an object that is not theirs.
16const GLUE: &str = r#"
17import math
18
19def _mod(o):
20    return type(o).__module__.split(".")[0]
21
22def _plain(x):
23    """A value out of numpy/pandas as plain Python: scalars by item(),
24    timestamps as datetimes, NaT as None; containers recursively."""
25    m = _mod(x)
26    if m == "numpy":
27        if hasattr(x, "tolist"):
28            return _plain(x.tolist()) if hasattr(x, "shape") and x.shape != () else x.item()
29        return x
30    if m == "pandas":
31        t = type(x).__name__
32        if t == "Timestamp":
33            return x.to_pydatetime()
34        if t == "Timedelta":
35            return x.to_pytimedelta()
36        if t == "NaTType":
37            return None
38        if t in ("Period", "Interval"):
39            return str(x)
40    if isinstance(x, dict):
41        return {str(k): _plain(v) for k, v in x.items()}
42    if isinstance(x, (list, tuple)):
43        return [_plain(v) for v in x]
44    return x
45
46def _kind(o):
47    m, t = _mod(o), type(o).__name__
48    if m == "pandas" and t == "DataFrame":
49        r, c = o.shape
50        return f"DataFrame {r}×{c}"
51    if m == "pandas" and t == "Series":
52        return f"Series {len(o)}"
53    if m == "numpy" and hasattr(o, "shape"):
54        return f"ndarray {o.shape} {o.dtype}"
55    if m == "scipy" and hasattr(o, "toarray"):
56        return f"sparse {t} {o.shape}"
57    return type(o).__qualname__
58
59def _records(df):
60    import pandas as pd
61    if isinstance(df.index, pd.RangeIndex):
62        return _plain(df.to_dict("records"))
63    name = df.index.name
64    cols = [str(c) for c in df.columns]
65    out = []
66    for idx, row in zip(df.index.tolist(), df.itertuples(index=False, name=None)):
67        rec = {name if name is not None else "index": idx}
68        for c, v in zip(cols, row):
69            rec[c] = v
70        out.append(rec)
71    return _plain(out)
72
73def _rows(o):
74    m, t = _mod(o), type(o).__name__
75    if m == "pandas" and t == "DataFrame":
76        return _records(o)
77    if m == "pandas" and t in ("Series", "Index"):
78        return _plain(o.tolist())
79    if m == "numpy" and hasattr(o, "tolist"):
80        return _plain(o.tolist())
81    if m == "scipy" and hasattr(o, "toarray"):
82        return _plain(o.toarray().tolist())
83    if hasattr(o, "_fields"):
84        names = list(o._fields) + list(getattr(o, "_extra_fields", ()))
85        return {n: _plain(getattr(o, n)) for n in names}
86    if hasattr(o, "__iter__") and not hasattr(o, "read"):
87        return [_plain(v) for v in o]
88    raise TypeError(f"{_kind(o)} has no row reading")
89
90def _table(o):
91    """A DataFrame or a 2-d ndarray as a table (header and rows, a
92    null None), a 1-d ndarray or a Series on a RangeIndex as a sample
93    (one column, no header), None for anything else. A RangeIndex is left out; another index
94    is the first column, by its name or `index`."""
95    m, t = _mod(o), type(o).__name__
96    def cell(v):
97        # null is the one missing value: None, NaT and pd.NA come back
98        # as null; a nan is a float and stays one.
99        try:
100            import pandas as pd
101            if v is pd.NA or v is pd.NaT:
102                return None
103        except ImportError:
104            pass
105        v = _plain(v)
106        if v is None:
107            return None
108        try:
109            import pandas as pd
110            if v is pd.NA or v is pd.NaT:
111                return None
112        except ImportError:
113            pass
114        return v
115    if m == "pandas" and t == "DataFrame":
116        import pandas as pd
117        cols = [str(c) for c in o.columns]
118        rows = [[cell(v) for v in r] for r in o.itertuples(index=False, name=None)]
119        if not isinstance(o.index, pd.RangeIndex):
120            name = str(o.index.name) if o.index.name is not None else "index"
121            cols = [name] + cols
122            rows = [[cell(i)] + r for i, r in zip(o.index.tolist(), rows)]
123        return {"header": cols, "rows": rows}
124    if m == "pandas" and t == "Series":
125        import pandas as pd
126        if isinstance(o.index, pd.RangeIndex):
127            return {"header": None, "rows": [[cell(v)] for v in o.tolist()]}
128        name = str(o.name) if o.name is not None else "value"
129        iname = str(o.index.name) if o.index.name is not None else "index"
130        return {"header": [iname, name],
131                "rows": [[cell(i), cell(v)] for i, v in zip(o.index.tolist(), o.tolist())]}
132    if m == "numpy" and hasattr(o, "ndim"):
133        if o.ndim == 2:
134            return {"header": None, "rows": [[cell(v) for v in r] for r in o.tolist()]}
135        if o.ndim == 1:
136            return {"header": None, "rows": [[cell(v)] for v in o.tolist()]}
137    return None
138
139def _columns(o):
140    m, t = _mod(o), type(o).__name__
141    if m == "pandas" and t == "DataFrame":
142        import pandas as pd
143        if isinstance(o.index, pd.RangeIndex):
144            return _plain({str(c): o[c].tolist() for c in o.columns})
145        return _plain({str(c): {str(k): v for k, v in o[c].to_dict().items()} for c in o.columns})
146    if m == "pandas" and t == "Series":
147        return _plain({str(k): v for k, v in o.to_dict().items()})
148    if m == "numpy":
149        raise TypeError(f"{_kind(o)} has no names; '| @' reads its rows, pd: keeps names")
150    if hasattr(o, "_fields"):
151        return _rows(o)
152    if hasattr(o, "items"):
153        return {str(k): _plain(v) for k, v in o.items()}
154    raise TypeError(f"{_kind(o)} has no column reading")
155"#;
156
157/// The site-packages the embedded interpreter would miss: an
158/// active virtualenv's (`VIRTUAL_ENV`), and the prefix the running
159/// binary was installed under (a wheel's `bin/qua` beside its
160/// `lib/pythonX.Y/site-packages`), added once, after the
161/// interpreter's own, through `site.addsitedir` so `.pth` files
162/// count too.
163pub(crate) fn extend_sys_path(py: Python<'_>) -> PyResult<()> {
164    static DONE: OnceLock<()> = OnceLock::new();
165    if DONE.get().is_some() {
166        return Ok(());
167    }
168    DONE.set(()).ok();
169    let exe_prefix = std::env::current_exe()
170        .ok()
171        .and_then(|p| {
172            p.parent()
173                .and_then(|b| b.parent())
174                .map(|p| p.display().to_string())
175        })
176        .unwrap_or_default();
177    let code = c"
178import os, site, sys
179def _extend(prefix):
180    if not prefix:
181        return
182    v = f'python{sys.version_info[0]}.{sys.version_info[1]}'
183    for d in (os.path.join(prefix, 'lib', v, 'site-packages'),
184              os.path.join(prefix, 'lib64', v, 'site-packages'),
185              os.path.join(prefix, 'Lib', 'site-packages')):
186        if os.path.isdir(d) and d not in sys.path:
187            site.addsitedir(d)
188_extend(os.environ.get('VIRTUAL_ENV'))
189_extend(_EXE_PREFIX)
190";
191    // One namespace for the code: the function body resolves its
192    // imports in the globals it was defined under.
193    let ns = PyDict::new(py);
194    ns.set_item("_EXE_PREFIX", exe_prefix)?;
195    py.run(code, Some(&ns), Some(&ns))?;
196    Ok(())
197}
198
199static MODULE: OnceLock<Py<PyModule>> = OnceLock::new();
200
201fn module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
202    if let Some(m) = MODULE.get() {
203        return Ok(m.bind(py).clone());
204    }
205    extend_sys_path(py)?;
206    let m = PyModule::from_code(
207        py,
208        &CString::new(GLUE).expect("no NUL"),
209        c"quarb_foreign.py",
210        c"quarb_foreign",
211    )?;
212    // Importable as `quarb_foreign` from every other glue page.
213    py.import("sys")?
214        .getattr("modules")?
215        .set_item("quarb_foreign", &m)?;
216    let _ = MODULE.set(m.clone().unbind());
217    Ok(m)
218}
219
220/// Compile the helpers (once) so a glue page can import them.
221pub(crate) fn register(py: Python<'_>) -> PyResult<()> {
222    module(py).map(|_| ())
223}
224
225/// Whether a Python object is plain data for the engine (converted
226/// as it crosses) or a library value (kept as it is): the builtins
227/// and the standard containers convert, numpy scalars convert by
228/// their item, everything else is foreign.
229pub(crate) fn is_plain(obj: &Bound<'_, PyAny>) -> bool {
230    let module = obj
231        .get_type()
232        .getattr("__module__")
233        .ok()
234        .and_then(|m| m.extract::<String>().ok())
235        .unwrap_or_default();
236    matches!(
237        module.split('.').next().unwrap_or(""),
238        "builtins"
239            | "collections"
240            | "itertools"
241            | "datetime"
242            | "decimal"
243            | "fractions"
244            | "uuid"
245            | "pathlib"
246            | "quarb"
247    )
248}
249
250/// A Python object held as a library value.
251pub struct PyForeign {
252    pub obj: Py<PyAny>,
253}
254
255impl PyForeign {
256    pub fn new(obj: Py<PyAny>) -> PyForeign {
257        PyForeign { obj }
258    }
259
260    fn glue<T>(
261        &self,
262        name: &str,
263        f: impl Fn(Bound<'_, PyAny>) -> PyResult<T>,
264    ) -> Result<T, String> {
265        Python::attach(|py| {
266            let m = module(py).map_err(|e| e.to_string())?;
267            let out = m
268                .getattr(name)
269                .and_then(|g| g.call1((self.obj.bind(py),)))
270                .map_err(|e| {
271                    // The message without the exception's class.
272                    e.value(py).to_string()
273                })?;
274            f(out).map_err(|e| e.to_string())
275        })
276    }
277}
278
279impl Foreign for PyForeign {
280    fn kind(&self) -> String {
281        self.glue("_kind", |o| o.extract::<String>())
282            .unwrap_or_else(|_| "a Python object".to_string())
283    }
284
285    fn display(&self) -> String {
286        Python::attach(|py| {
287            self.obj
288                .bind(py)
289                .str()
290                .map(|s| s.to_string_lossy().into_owned())
291                .unwrap_or_else(|e| e.to_string())
292        })
293    }
294
295    fn rows(&self) -> Result<Value, String> {
296        self.glue("_rows", |o| crate::py_to_value(&o))
297    }
298
299    fn columns(&self) -> Result<Value, String> {
300        self.glue("_columns", |o| crate::py_to_value(&o))
301    }
302
303    fn as_any(&self) -> &dyn std::any::Any {
304        self
305    }
306}
307
308/// A frame or an array out of the library as the engine's own
309/// table, a one-dimensional one as a list; `None` for an object of
310/// another kind (kept as a library value).
311pub(crate) fn as_table(obj: &Bound<'_, PyAny>) -> PyResult<Option<Value>> {
312    let py = obj.py();
313    let m = module(py)?;
314    let d = m.getattr("_table")?.call1((obj,))?;
315    if d.is_none() {
316        return Ok(None);
317    }
318    let d = d.cast::<PyDict>().map_err(|e| PyErr::from(e))?;
319    if let Some(list) = d.get_item("list")? {
320        return Ok(Some(crate::py_to_value(&list)?));
321    }
322    let header: Option<Vec<String>> = match d.get_item("header")? {
323        Some(h) if !h.is_none() => Some(h.extract()?),
324        _ => None,
325    };
326    let mut rows: Vec<Vec<Value>> = Vec::new();
327    if let Some(rs) = d.get_item("rows")? {
328        for r in rs.try_iter()? {
329            let mut row = Vec::new();
330            for c in r?.try_iter()? {
331                row.push(crate::py_to_value(&c?)?);
332            }
333            rows.push(row);
334        }
335    }
336    // The engine's one rule for an implicit table: flat, it is the
337    // table; a frame whose cells hold structures (an object column
338    // of lists) keeps its shape as a list of records.
339    Ok(Some(Value::table_or_records(quarb::Table::new(
340        header, rows,
341    ))))
342}