use crate::error::DataError;
use crate::store::ColumnDtype;
use crate::{ColumnSummary, DataStore, IngestReport, NumericDescribe, SqliteBackend};
use pyo3::create_exception;
use pyo3::exceptions::PyException;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyModule};
use std::path::PathBuf;
create_exception!(_newt_agent, PyDataError, PyException);
fn data_err_to_py(e: DataError) -> PyErr {
PyDataError::new_err(e.to_string())
}
#[pyclass(
name = "IngestReport",
module = "newt_agent._newt_agent.data",
frozen,
skip_from_py_object
)]
#[derive(Clone)]
pub struct PyIngestReport {
pub inner: IngestReport,
}
#[pymethods]
impl PyIngestReport {
#[getter]
fn table(&self) -> &str {
&self.inner.table
}
#[getter]
fn row_count(&self) -> u64 {
self.inner.row_count
}
#[getter]
fn columns(&self) -> Vec<PyColumnInfo> {
self.inner
.columns
.iter()
.map(|c| PyColumnInfo {
name: c.name.clone(),
dtype: c.dtype,
null_count: c.null_count,
})
.collect()
}
#[getter]
fn source(&self) -> Option<&str> {
self.inner.source.as_deref()
}
fn __repr__(&self) -> String {
format!(
"IngestReport(table='{}', row_count={}, columns={})",
self.inner.table,
self.inner.row_count,
self.inner.columns.len(),
)
}
}
#[pyclass(
name = "ColumnInfo",
module = "newt_agent._newt_agent.data",
frozen,
skip_from_py_object
)]
#[derive(Clone)]
pub struct PyColumnInfo {
name: String,
dtype: ColumnDtype,
null_count: u64,
}
#[pymethods]
impl PyColumnInfo {
#[getter]
fn name(&self) -> &str {
&self.name
}
#[getter]
fn dtype(&self) -> &'static str {
dtype_str(self.dtype)
}
#[getter]
fn null_count(&self) -> u64 {
self.null_count
}
fn __repr__(&self) -> String {
format!(
"ColumnInfo(name='{}', dtype='{}', null_count={})",
self.name,
dtype_str(self.dtype),
self.null_count,
)
}
}
fn dtype_str(dtype: ColumnDtype) -> &'static str {
match dtype {
ColumnDtype::Integer => "integer",
ColumnDtype::Real => "real",
ColumnDtype::Text => "text",
}
}
#[pyfunction]
#[pyo3(name = "load_csv_to_sqlite")]
fn py_load_csv_to_sqlite(
csv_path: PathBuf,
db_path: PathBuf,
table: &str,
) -> PyResult<PyIngestReport> {
let store = SqliteBackend::open(&db_path).map_err(data_err_to_py)?;
let report = store.ingest_csv(&csv_path, table).map_err(data_err_to_py)?;
Ok(PyIngestReport { inner: report })
}
const DEFAULT_ROW_CAP: usize = 100_000;
#[pyfunction]
#[pyo3(name = "query", signature = (db_path, sql, row_cap=None))]
fn py_query<'py>(
py: Python<'py>,
db_path: PathBuf,
sql: &str,
row_cap: Option<usize>,
) -> PyResult<Bound<'py, PyList>> {
let store = SqliteBackend::open(&db_path).map_err(data_err_to_py)?;
let cap = row_cap.unwrap_or(DEFAULT_ROW_CAP);
let result = store.query(sql, cap).map_err(data_err_to_py)?;
let rows = PyList::empty(py);
for row in &result.rows {
let d = PyDict::new(py);
for (col, cell) in result.columns.iter().zip(row.iter()) {
d.set_item(col, json_cell_to_py(py, cell)?)?;
}
rows.append(d)?;
}
Ok(rows)
}
fn json_cell_to_py<'py>(py: Python<'py>, cell: &serde_json::Value) -> PyResult<Bound<'py, PyAny>> {
use serde_json::Value;
let obj = match cell {
Value::Null => py.None().into_bound(py),
Value::Bool(b) => b.into_pyobject(py)?.to_owned().into_any(),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.into_pyobject(py)?.into_any()
} else if let Some(u) = n.as_u64() {
u.into_pyobject(py)?.into_any()
} else if let Some(f) = n.as_f64() {
f.into_pyobject(py)?.into_any()
} else {
py.None().into_bound(py)
}
}
Value::String(s) => s.into_pyobject(py)?.into_any(),
other => other.to_string().into_pyobject(py)?.into_any(),
};
Ok(obj)
}
#[pyfunction]
#[pyo3(name = "summarize")]
fn py_summarize<'py>(
py: Python<'py>,
db_path: PathBuf,
table: &str,
) -> PyResult<Bound<'py, PyDict>> {
let store = SqliteBackend::open(&db_path).map_err(data_err_to_py)?;
let summary = store.summarize(table).map_err(data_err_to_py)?;
let out = PyDict::new(py);
out.set_item("table", &summary.table)?;
out.set_item("row_count", summary.row_count)?;
let cols = PyList::empty(py);
for col in &summary.columns {
cols.append(column_summary_to_py(py, col)?)?;
}
out.set_item("columns", cols)?;
Ok(out)
}
fn column_summary_to_py<'py>(py: Python<'py>, col: &ColumnSummary) -> PyResult<Bound<'py, PyDict>> {
let d = PyDict::new(py);
d.set_item("name", &col.name)?;
d.set_item("dtype", dtype_str(col.dtype))?;
d.set_item("null_count", col.null_count)?;
d.set_item("distinct_count", col.distinct_count)?;
if let Some(n) = col.numeric {
d.set_item("describe", numeric_describe_to_py(py, n)?)?;
}
Ok(d)
}
fn numeric_describe_to_py<'py>(
py: Python<'py>,
n: NumericDescribe,
) -> PyResult<Bound<'py, PyDict>> {
let d = PyDict::new(py);
d.set_item("count", n.count)?;
d.set_item("mean", n.mean)?;
d.set_item("std", n.std)?;
d.set_item("min", n.min)?;
d.set_item("q25", n.q25)?;
d.set_item("q50", n.q50)?;
d.set_item("q75", n.q75)?;
d.set_item("max", n.max)?;
Ok(d)
}
pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> {
let m = PyModule::new(py, "data")?;
m.add_class::<PyIngestReport>()?;
m.add_class::<PyColumnInfo>()?;
m.add_function(wrap_pyfunction!(py_load_csv_to_sqlite, &m)?)?;
m.add_function(wrap_pyfunction!(py_query, &m)?)?;
m.add_function(wrap_pyfunction!(py_summarize, &m)?)?;
m.add("DataError", py.get_type::<PyDataError>())?;
parent.add_submodule(&m)?;
Ok(())
}
#[cfg(all(test, feature = "embed-tests"))]
mod tests {
use super::*;
use std::io::Write;
const FIXTURE_CSV: &str = "id,label,score\n\
1,alpha,1.0\n\
2,bravo,2.0\n\
3,charlie,3.0\n\
4,delta,4.0\n\
5,echo,\n";
fn write_fixture(dir: &std::path::Path) -> PathBuf {
let path = dir.join("metrics.csv");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(FIXTURE_CSV.as_bytes()).unwrap();
f.flush().unwrap();
path
}
#[test]
fn load_query_summarize_round_trip() {
Python::initialize();
Python::attach(|py| {
let dir = tempfile::tempdir().unwrap();
let csv_path = write_fixture(dir.path());
let db_path = dir.path().join("data.db");
let report =
py_load_csv_to_sqlite(csv_path.clone(), db_path.clone(), "metrics").unwrap();
assert_eq!(report.table(), "metrics");
assert_eq!(report.row_count(), 5);
let cols = report.columns();
assert_eq!(cols.len(), 3);
assert_eq!(cols[0].name(), "id");
assert_eq!(cols[0].dtype(), "integer");
assert_eq!(cols[0].null_count(), 0);
assert_eq!(cols[2].dtype(), "real");
assert_eq!(cols[2].null_count(), 1);
assert!(report.source().is_some());
assert!(report.__repr__().contains("metrics"));
assert!(cols[0].__repr__().contains("id"));
let rows = py_query(
py,
db_path.clone(),
"SELECT id, label, score FROM metrics ORDER BY id",
None,
)
.unwrap();
assert_eq!(rows.len(), 5);
let first = rows.get_item(0).unwrap();
let first: &Bound<'_, PyDict> = first.cast().unwrap();
let id_obj = first.get_item("id").unwrap().unwrap();
assert!(id_obj.is_instance_of::<pyo3::types::PyInt>());
let id_val: i64 = id_obj.extract().unwrap();
assert_eq!(id_val, 1);
let score_obj = first.get_item("score").unwrap().unwrap();
assert!(score_obj.is_instance_of::<pyo3::types::PyFloat>());
let score_val: f64 = score_obj.extract().unwrap();
assert!((score_val - 1.0).abs() < 1e-9);
let label_val: String = first.get_item("label").unwrap().unwrap().extract().unwrap();
assert_eq!(label_val, "alpha");
let fifth = rows.get_item(4).unwrap();
let fifth: &Bound<'_, PyDict> = fifth.cast().unwrap();
assert!(fifth.get_item("score").unwrap().unwrap().is_none());
let summary = py_summarize(py, db_path.clone(), "metrics").unwrap();
let row_count: u64 = summary
.get_item("row_count")
.unwrap()
.unwrap()
.extract()
.unwrap();
assert_eq!(row_count, 5);
let cols_any = summary.get_item("columns").unwrap().unwrap();
let cols_list: &Bound<'_, PyList> = cols_any.cast().unwrap();
assert_eq!(cols_list.len(), 3);
let mut saw_score = false;
for item in cols_list.iter() {
let c: &Bound<'_, PyDict> = item.cast().unwrap();
let name: String = c.get_item("name").unwrap().unwrap().extract().unwrap();
if name == "score" {
saw_score = true;
let dtype: String = c.get_item("dtype").unwrap().unwrap().extract().unwrap();
assert_eq!(dtype, "real");
let null_count: u64 = c
.get_item("null_count")
.unwrap()
.unwrap()
.extract()
.unwrap();
assert_eq!(null_count, 1);
let describe = c.get_item("describe").unwrap().unwrap();
let describe: &Bound<'_, PyDict> = describe.cast().unwrap();
let mean: f64 = describe
.get_item("mean")
.unwrap()
.unwrap()
.extract()
.unwrap();
assert!((mean - 2.5).abs() < 1e-9);
let std: f64 = describe
.get_item("std")
.unwrap()
.unwrap()
.extract()
.unwrap();
assert!((std - 1.290_994_448_735_805_6).abs() < 1e-9);
} else if name == "label" {
let c2: &Bound<'_, PyDict> = item.cast().unwrap();
assert!(c2.get_item("describe").unwrap().is_none());
}
}
assert!(saw_score, "summary should include the score column");
});
}
#[test]
fn bad_sql_raises_data_error() {
Python::initialize();
Python::attach(|py| {
let dir = tempfile::tempdir().unwrap();
let csv_path = write_fixture(dir.path());
let db_path = dir.path().join("data.db");
py_load_csv_to_sqlite(csv_path, db_path.clone(), "metrics").unwrap();
let err = py_query(py, db_path, "SELECT * FROM no_such_table", None).unwrap_err();
assert!(err.is_instance_of::<PyDataError>(py));
});
}
#[test]
fn summarize_missing_table_raises() {
Python::initialize();
Python::attach(|py| {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("data.db");
let err = py_summarize(py, db_path, "nope").unwrap_err();
assert!(err.is_instance_of::<PyDataError>(py));
});
}
}