1use pyo3::prelude::*;
8use pyo3::types::PyDict;
9use quarb::{Foreign, Value};
10use std::ffi::CString;
11use std::sync::OnceLock;
12
13const 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
157pub(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| p.parent().and_then(|b| b.parent()).map(|p| p.display().to_string()))
172 .unwrap_or_default();
173 let code = c"
174import os, site, sys
175def _extend(prefix):
176 if not prefix:
177 return
178 v = f'python{sys.version_info[0]}.{sys.version_info[1]}'
179 for d in (os.path.join(prefix, 'lib', v, 'site-packages'),
180 os.path.join(prefix, 'lib64', v, 'site-packages'),
181 os.path.join(prefix, 'Lib', 'site-packages')):
182 if os.path.isdir(d) and d not in sys.path:
183 site.addsitedir(d)
184_extend(os.environ.get('VIRTUAL_ENV'))
185_extend(_EXE_PREFIX)
186";
187 let ns = PyDict::new(py);
190 ns.set_item("_EXE_PREFIX", exe_prefix)?;
191 py.run(code, Some(&ns), Some(&ns))?;
192 Ok(())
193}
194
195static MODULE: OnceLock<Py<PyModule>> = OnceLock::new();
196
197fn module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
198 if let Some(m) = MODULE.get() {
199 return Ok(m.bind(py).clone());
200 }
201 extend_sys_path(py)?;
202 let m = PyModule::from_code(
203 py,
204 &CString::new(GLUE).expect("no NUL"),
205 c"quarb_foreign.py",
206 c"quarb_foreign",
207 )?;
208 py.import("sys")?
210 .getattr("modules")?
211 .set_item("quarb_foreign", &m)?;
212 let _ = MODULE.set(m.clone().unbind());
213 Ok(m)
214}
215
216pub(crate) fn register(py: Python<'_>) -> PyResult<()> {
218 module(py).map(|_| ())
219}
220
221pub(crate) fn is_plain(obj: &Bound<'_, PyAny>) -> bool {
226 let module = obj
227 .get_type()
228 .getattr("__module__")
229 .ok()
230 .and_then(|m| m.extract::<String>().ok())
231 .unwrap_or_default();
232 matches!(
233 module.split('.').next().unwrap_or(""),
234 "builtins" | "collections" | "itertools" | "datetime" | "decimal" | "fractions" | "uuid" | "pathlib" | "quarb"
235 )
236}
237
238pub struct PyForeign {
240 pub obj: Py<PyAny>,
241}
242
243impl PyForeign {
244 pub fn new(obj: Py<PyAny>) -> PyForeign {
245 PyForeign { obj }
246 }
247
248 fn glue<T>(&self, name: &str, f: impl Fn(Bound<'_, PyAny>) -> PyResult<T>) -> Result<T, String> {
249 Python::attach(|py| {
250 let m = module(py).map_err(|e| e.to_string())?;
251 let out = m
252 .getattr(name)
253 .and_then(|g| g.call1((self.obj.bind(py),)))
254 .map_err(|e| {
255 e.value(py).to_string()
257 })?;
258 f(out).map_err(|e| e.to_string())
259 })
260 }
261}
262
263impl Foreign for PyForeign {
264 fn kind(&self) -> String {
265 self.glue("_kind", |o| o.extract::<String>())
266 .unwrap_or_else(|_| "a Python object".to_string())
267 }
268
269 fn display(&self) -> String {
270 Python::attach(|py| {
271 self.obj
272 .bind(py)
273 .str()
274 .map(|s| s.to_string_lossy().into_owned())
275 .unwrap_or_else(|e| e.to_string())
276 })
277 }
278
279 fn rows(&self) -> Result<Value, String> {
280 self.glue("_rows", |o| crate::py_to_value(&o))
281 }
282
283 fn columns(&self) -> Result<Value, String> {
284 self.glue("_columns", |o| crate::py_to_value(&o))
285 }
286
287 fn as_any(&self) -> &dyn std::any::Any {
288 self
289 }
290}
291
292pub(crate) fn as_table(obj: &Bound<'_, PyAny>) -> PyResult<Option<Value>> {
296 let py = obj.py();
297 let m = module(py)?;
298 let d = m.getattr("_table")?.call1((obj,))?;
299 if d.is_none() {
300 return Ok(None);
301 }
302 let d = d.cast::<PyDict>().map_err(|e| PyErr::from(e))?;
303 if let Some(list) = d.get_item("list")? {
304 return Ok(Some(crate::py_to_value(&list)?));
305 }
306 let header: Option<Vec<String>> = match d.get_item("header")? {
307 Some(h) if !h.is_none() => Some(h.extract()?),
308 _ => None,
309 };
310 let mut rows: Vec<Vec<Value>> = Vec::new();
311 if let Some(rs) = d.get_item("rows")? {
312 for r in rs.try_iter()? {
313 let mut row = Vec::new();
314 for c in r?.try_iter()? {
315 row.push(crate::py_to_value(&c?)?);
316 }
317 rows.push(row);
318 }
319 }
320 Ok(Some(Value::table(quarb::Table { header, rows })))
321}