diff --git a/rust/perspective-python/perspective/__init__.py b/rust/perspective-python/perspective/__init__.py index 1bc3f6b83b..8651df9d37 100644 --- a/rust/perspective-python/perspective/__init__.py +++ b/rust/perspective-python/perspective/__init__.py @@ -16,7 +16,6 @@ "Server", "Client", "PerspectiveError", - "PerspectiveWidget", "ProxySession", ] diff --git a/rust/perspective-python/perspective/tests/table/test_table.py b/rust/perspective-python/perspective/tests/table/test_table.py index 74bec9c3f6..51e86dd5a5 100644 --- a/rust/perspective-python/perspective/tests/table/test_table.py +++ b/rust/perspective-python/perspective/tests/table/test_table.py @@ -348,6 +348,26 @@ def test_table_symmetric_string_schema(self): assert tbl2.schema() == schema + def test_table_python_schema(self): + data = { + "a": int, + "b": float, + "c": str, + "d": bool, + "e": date, + "f": datetime, + } + + tbl = Table(data) + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + # is_valid_filter # def test_table_is_valid_filter_str(self): diff --git a/rust/perspective-python/perspective/tests/test_dependencies.py b/rust/perspective-python/perspective/tests/test_dependencies.py index 06592de686..1a54d5fa34 100644 --- a/rust/perspective-python/perspective/tests/test_dependencies.py +++ b/rust/perspective-python/perspective/tests/test_dependencies.py @@ -44,3 +44,10 @@ def test_lazy_modules(): for k, v in cache.items(): sys.modules[k] = v + + +def test_all(): + import perspective + + for key in perspective.__all__: + assert hasattr(perspective, key) diff --git a/rust/perspective-python/src/client/client_sync.rs b/rust/perspective-python/src/client/client_sync.rs index 5bb71b5ece..3ee3ee4c51 100644 --- a/rust/perspective-python/src/client/client_sync.rs +++ b/rust/perspective-python/src/client/client_sync.rs @@ -23,6 +23,7 @@ use pyo3::prelude::*; use pyo3::types::*; use super::python::*; +use crate::py_err::ResultTClientErrorExt; use crate::server::PySyncServer; #[pyclass] diff --git a/rust/perspective-python/src/client/mod.rs b/rust/perspective-python/src/client/mod.rs index 2ad82c9f3c..30fc70f324 100644 --- a/rust/perspective-python/src/client/mod.rs +++ b/rust/perspective-python/src/client/mod.rs @@ -14,3 +14,5 @@ pub mod client_sync; mod pandas; mod pyarrow; pub mod python; +pub mod table_data; +pub mod update_data; diff --git a/rust/perspective-python/src/client/python.rs b/rust/perspective-python/src/client/python.rs index 623911f171..06380f63c3 100644 --- a/rust/perspective-python/src/client/python.rs +++ b/rust/perspective-python/src/client/python.rs @@ -10,7 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use std::any::Any; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -18,18 +17,20 @@ use std::sync::Arc; use async_lock::RwLock; use futures::FutureExt; use perspective_client::{ - assert_table_api, assert_view_api, clone, Client, ClientError, OnUpdateMode, OnUpdateOptions, - Table, TableData, TableInitOptions, TableReadFormat, UpdateData, UpdateOptions, View, + assert_table_api, assert_view_api, clone, Client, OnUpdateMode, OnUpdateOptions, Table, + TableData, TableInitOptions, TableReadFormat, UpdateData, UpdateOptions, View, ViewOnUpdateResp, ViewWindow, }; -use pyo3::create_exception; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyString}; +use pyo3::types::{PyAny, PyBytes, PyDict, PyString}; use pythonize::depythonize_bound; use super::pandas::arrow_to_pandas; +use super::table_data::TableDataExt; +use super::update_data::UpdateDataExt; use super::{pandas, pyarrow}; +use crate::py_err::{PyPerspectiveError, ResultTClientErrorExt}; #[derive(Clone)] pub struct PyClient { @@ -38,138 +39,6 @@ pub struct PyClient { close_cb: Option>, } -#[extend::ext] -pub impl Result { - fn into_pyerr(self) -> PyResult { - match self { - Ok(x) => Ok(x), - Err(x) => Err(PyPerspectiveError::new_err(format!("{}", x))), - } - } -} - -create_exception!( - perspective, - PyPerspectiveError, - pyo3::exceptions::PyException -); - -#[extend::ext] -impl UpdateData { - fn from_py_partial( - py: Python<'_>, - input: &Py, - format: Option, - ) -> Result, PyErr> { - if let Ok(pybytes) = input.downcast_bound::(py) { - // TODO need to explicitly qualify this b/c bug in - // rust-analyzer - should be: just `pybytes.as_bytes()`. - let vec = pyo3::prelude::PyBytesMethods::as_bytes(pybytes).to_vec(); - - match format { - Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(vec)?))), - Some(TableReadFormat::JsonString) => { - Ok(Some(UpdateData::JsonRows(String::from_utf8(vec)?))) - }, - Some(TableReadFormat::ColumnsString) => { - Ok(Some(UpdateData::JsonColumns(String::from_utf8(vec)?))) - }, - None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(vec.into()))), - } - } else if let Ok(pystring) = input.downcast_bound::(py) { - let string = pystring.extract::()?; - match format { - None | Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(string))), - Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows(string))), - Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns(string))), - Some(TableReadFormat::Arrow) => { - Ok(Some(UpdateData::Arrow(string.into_bytes().into()))) - }, - } - } else if let Ok(pylist) = input.downcast_bound::(py) { - let json_module = PyModule::import_bound(py, "json")?; - let string = json_module.call_method("dumps", (pylist,), None)?; - Ok(Some(UpdateData::JsonRows(string.extract::()?))) - } else if let Ok(pydict) = input.downcast_bound::(py) { - if pydict.keys().is_empty() { - return Err(PyValueError::new_err("Cannot infer type of empty dict")); - } - - let first_key = pydict.keys().get_item(0)?; - let first_item = pydict - .get_item(first_key)? - .ok_or_else(|| PyValueError::new_err("Bad Input"))?; - - if first_item.downcast::().is_ok() { - let json_module = PyModule::import_bound(py, "json")?; - let string = json_module.call_method("dumps", (pydict,), None)?; - Ok(Some(UpdateData::JsonColumns(string.extract::()?))) - } else { - Ok(None) - } - } else { - Ok(None) - } - } - - fn from_py( - py: Python<'_>, - input: &Py, - format: Option, - ) -> Result { - if let Some(x) = Self::from_py_partial(py, input, format)? { - Ok(x) - } else { - Err(PyValueError::new_err(format!( - "Unknown input type {:?}", - input.type_id() - ))) - } - } -} - -#[extend::ext] -impl TableData { - fn from_py( - py: Python<'_>, - input: Py, - format: Option, - ) -> Result { - if let Some(update) = UpdateData::from_py_partial(py, &input, format)? { - Ok(TableData::Update(update)) - } else if let Ok(pylist) = input.downcast_bound::(py) { - let json_module = PyModule::import_bound(py, "json")?; - let string = json_module.call_method("dumps", (pylist,), None)?; - Ok(UpdateData::JsonRows(string.extract::()?).into()) - } else if let Ok(pydict) = input.downcast_bound::(py) { - let first_key = pydict.keys().get_item(0)?; - let first_item = pydict - .get_item(first_key)? - .ok_or_else(|| PyValueError::new_err("Bad Input"))?; - if first_item.downcast::().is_ok() { - let json_module = PyModule::import_bound(py, "json")?; - let string = json_module.call_method("dumps", (pydict,), None)?; - Ok(UpdateData::JsonColumns(string.extract::()?).into()) - } else { - let mut schema = vec![]; - for (key, val) in pydict.into_iter() { - schema.push(( - key.extract::()?, - val.extract::()?.as_str().try_into().into_pyerr()?, - )); - } - - Ok(TableData::Schema(schema)) - } - } else { - Err(PyValueError::new_err(format!( - "Unknown input type {:?}", - input.type_id() - ))) - } - } -} - impl PyClient { pub fn new(handle_request: Py, handle_close: Option>) -> Self { let client = Client::new_with_callback({ diff --git a/rust/perspective-python/src/client/table_data.rs b/rust/perspective-python/src/client/table_data.rs new file mode 100644 index 0000000000..c7838c9e29 --- /dev/null +++ b/rust/perspective-python/src/client/table_data.rs @@ -0,0 +1,84 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use perspective_client::{ColumnType, TableData, TableReadFormat, UpdateData}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyAnyMethods, PyDict, PyList, PyString, PyType}; + +use super::update_data::UpdateDataExt; +use crate::py_err::ResultTClientErrorExt; + +fn psp_type_from_py_type(_py: Python<'_>, val: Bound<'_, PyAny>) -> PyResult { + if val.is_instance_of::() { + val.extract::()?.as_str().try_into().into_pyerr() + } else if let Ok(val) = val.downcast::() { + match val.name()?.as_ref() { + "builtins.int" | "int" => Ok(ColumnType::Integer), + "builtins.float" | "float" => Ok(ColumnType::Float), + "builtins.str" | "str" => Ok(ColumnType::String), + "builtins.bool" | "bool" => Ok(ColumnType::Boolean), + "datetime.date" => Ok(ColumnType::Date), + "datetime.datetime" => Ok(ColumnType::Datetime), + type_name => Err(PyTypeError::new_err(type_name.to_string())), + } + } else { + Err(PyTypeError::new_err(format!( + "Unknown schema type {:?}", + val.get_type().name()? + ))) + } +} + +fn from_dict(py: Python<'_>, pydict: &Bound<'_, PyDict>) -> Result { + let first_key = pydict.keys().get_item(0)?; + let first_item = pydict + .get_item(first_key)? + .ok_or_else(|| PyValueError::new_err("Schema has no columns"))?; + + if first_item.downcast::().is_ok() { + let json_module = PyModule::import_bound(py, "json")?; + let string = json_module.call_method("dumps", (pydict,), None)?; + Ok(UpdateData::JsonColumns(string.extract::()?).into()) + } else { + let mut schema = vec![]; + for (key, val) in pydict.into_iter() { + schema.push((key.extract::()?, psp_type_from_py_type(py, val)?)); + } + + Ok(TableData::Schema(schema)) + } +} + +#[extend::ext] +pub impl TableData { + fn from_py( + py: Python<'_>, + input: Py, + format: Option, + ) -> Result { + if let Some(update) = UpdateData::from_py_partial(py, &input, format)? { + Ok(TableData::Update(update)) + } else if let Ok(pylist) = input.downcast_bound::(py) { + let json_module = PyModule::import_bound(py, "json")?; + let string = json_module.call_method("dumps", (pylist,), None)?; + Ok(UpdateData::JsonRows(string.extract::()?).into()) + } else if let Ok(pydict) = input.downcast_bound::(py) { + from_dict(py, pydict) + } else { + Err(PyTypeError::new_err(format!( + "Unknown input type {:?}", + input.bind(py).get_type().name()? + ))) + } + } +} diff --git a/rust/perspective-python/src/client/update_data.rs b/rust/perspective-python/src/client/update_data.rs new file mode 100644 index 0000000000..536102c857 --- /dev/null +++ b/rust/perspective-python/src/client/update_data.rs @@ -0,0 +1,112 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::any::Any; + +use perspective_client::{TableReadFormat, UpdateData}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyString}; + +fn from_arrow( + pybytes: &Bound<'_, PyBytes>, + format: Option, +) -> Result, PyErr> { + // TODO need to explicitly qualify this b/c bug in + // rust-analyzer - should be: just `pybytes.as_bytes()`. + let vec = pyo3::prelude::PyBytesMethods::as_bytes(pybytes).to_vec(); + + match format { + Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(vec)?))), + Some(TableReadFormat::JsonString) => { + Ok(Some(UpdateData::JsonRows(String::from_utf8(vec)?))) + }, + Some(TableReadFormat::ColumnsString) => { + Ok(Some(UpdateData::JsonColumns(String::from_utf8(vec)?))) + }, + None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(vec.into()))), + } +} + +fn from_string( + pystring: &Bound<'_, PyString>, + format: Option, +) -> Result, PyErr> { + let string = pystring.extract::()?; + match format { + None | Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(string))), + Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows(string))), + Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns(string))), + Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(string.into_bytes().into()))), + } +} + +fn from_list(py: Python<'_>, pylist: &Bound<'_, PyList>) -> Result, PyErr> { + let json_module = PyModule::import_bound(py, "json")?; + let string = json_module.call_method("dumps", (pylist,), None)?; + Ok(Some(UpdateData::JsonRows(string.extract::()?))) +} + +fn from_dict(py: Python<'_>, pydict: &Bound<'_, PyDict>) -> Result, PyErr> { + if pydict.keys().is_empty() { + return Err(PyValueError::new_err("Cannot infer type of empty dict")); + } + + let first_key = pydict.keys().get_item(0)?; + let first_item = pydict + .get_item(first_key)? + .ok_or_else(|| PyValueError::new_err("Bad Input"))?; + + if first_item.downcast::().is_ok() { + let json_module = PyModule::import_bound(py, "json")?; + let string = json_module.call_method("dumps", (pydict,), None)?; + Ok(Some(UpdateData::JsonColumns(string.extract::()?))) + } else { + Ok(None) + } +} + +#[extend::ext] +pub impl UpdateData { + fn from_py_partial( + py: Python<'_>, + input: &Py, + format: Option, + ) -> Result, PyErr> { + if let Ok(pybytes) = input.downcast_bound::(py) { + from_arrow(pybytes, format) + } else if let Ok(pystring) = input.downcast_bound::(py) { + from_string(pystring, format) + } else if let Ok(pylist) = input.downcast_bound::(py) { + from_list(py, pylist) + } else if let Ok(pydict) = input.downcast_bound::(py) { + from_dict(py, pydict) + } else { + Ok(None) + } + } + + fn from_py( + py: Python<'_>, + input: &Py, + format: Option, + ) -> Result { + if let Some(x) = Self::from_py_partial(py, input, format)? { + Ok(x) + } else { + Err(PyValueError::new_err(format!( + "Unknown input type {:?}", + input.type_id() + ))) + } + } +} diff --git a/rust/perspective-python/src/lib.rs b/rust/perspective-python/src/lib.rs index 24c128ab2d..c5b1a4d81e 100644 --- a/rust/perspective-python/src/lib.rs +++ b/rust/perspective-python/src/lib.rs @@ -14,10 +14,11 @@ #![warn(unstable_features)] mod client; +mod py_err; mod server; pub use client::client_sync::{Client, ProxySession, Table, View}; -use client::python::PyPerspectiveError; +use py_err::PyPerspectiveError; use pyo3::prelude::*; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; diff --git a/rust/perspective-python/src/py_err.rs b/rust/perspective-python/src/py_err.rs new file mode 100644 index 0000000000..d521a3ff19 --- /dev/null +++ b/rust/perspective-python/src/py_err.rs @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use perspective_client::ClientError; +use pyo3::create_exception; +use pyo3::prelude::*; + +#[extend::ext] +pub impl Result { + fn into_pyerr(self) -> PyResult { + match self { + Ok(x) => Ok(x), + Err(x) => Err(PyPerspectiveError::new_err(format!("{}", x))), + } + } +} + +create_exception!( + perspective, + PyPerspectiveError, + pyo3::exceptions::PyException +);