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| {
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 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 py.import("sys")?
214 .getattr("modules")?
215 .set_item("quarb_foreign", &m)?;
216 let _ = MODULE.set(m.clone().unbind());
217 Ok(m)
218}
219
220pub(crate) fn register(py: Python<'_>) -> PyResult<()> {
222 module(py).map(|_| ())
223}
224
225pub(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
250pub 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 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
308pub(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 Ok(Some(Value::table_or_records(quarb::Table::new(
340 header, rows,
341 ))))
342}