Skip to content

Commit

Permalink
Merge pull request #2850 from finos/python-schema-relax
Browse files Browse the repository at this point in the history
Relax `Table` schema constructor
  • Loading branch information
texodus authored Nov 15, 2024
2 parents e9b6cfa + 370548d commit d630f0f
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 139 deletions.
1 change: 0 additions & 1 deletion rust/perspective-python/perspective/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"Server",
"Client",
"PerspectiveError",
"PerspectiveWidget",
"ProxySession",
]

Expand Down
20 changes: 20 additions & 0 deletions rust/perspective-python/perspective/tests/table/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions rust/perspective-python/src/client/client_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use pyo3::prelude::*;
use pyo3::types::*;

use super::python::*;
use crate::py_err::ResultTClientErrorExt;
use crate::server::PySyncServer;

#[pyclass]
Expand Down
2 changes: 2 additions & 0 deletions rust/perspective-python/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ pub mod client_sync;
mod pandas;
mod pyarrow;
pub mod python;
pub mod table_data;
pub mod update_data;
143 changes: 6 additions & 137 deletions rust/perspective-python/src/client/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,27 @@
// ┃ 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;

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 {
Expand All @@ -38,138 +39,6 @@ pub struct PyClient {
close_cb: Option<Py<PyAny>>,
}

#[extend::ext]
pub impl<T> Result<T, ClientError> {
fn into_pyerr(self) -> PyResult<T> {
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<PyAny>,
format: Option<TableReadFormat>,
) -> Result<Option<UpdateData>, PyErr> {
if let Ok(pybytes) = input.downcast_bound::<PyBytes>(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::<PyString>(py) {
let string = pystring.extract::<String>()?;
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::<PyList>(py) {
let json_module = PyModule::import_bound(py, "json")?;
let string = json_module.call_method("dumps", (pylist,), None)?;
Ok(Some(UpdateData::JsonRows(string.extract::<String>()?)))
} else if let Ok(pydict) = input.downcast_bound::<PyDict>(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::<PyList>().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::<String>()?)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}

fn from_py(
py: Python<'_>,
input: &Py<PyAny>,
format: Option<TableReadFormat>,
) -> Result<UpdateData, PyErr> {
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<PyAny>,
format: Option<TableReadFormat>,
) -> Result<TableData, PyErr> {
if let Some(update) = UpdateData::from_py_partial(py, &input, format)? {
Ok(TableData::Update(update))
} else if let Ok(pylist) = input.downcast_bound::<PyList>(py) {
let json_module = PyModule::import_bound(py, "json")?;
let string = json_module.call_method("dumps", (pylist,), None)?;
Ok(UpdateData::JsonRows(string.extract::<String>()?).into())
} else if let Ok(pydict) = input.downcast_bound::<PyDict>(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::<PyList>().is_ok() {
let json_module = PyModule::import_bound(py, "json")?;
let string = json_module.call_method("dumps", (pydict,), None)?;
Ok(UpdateData::JsonColumns(string.extract::<String>()?).into())
} else {
let mut schema = vec![];
for (key, val) in pydict.into_iter() {
schema.push((
key.extract::<String>()?,
val.extract::<String>()?.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<PyAny>, handle_close: Option<Py<PyAny>>) -> Self {
let client = Client::new_with_callback({
Expand Down
84 changes: 84 additions & 0 deletions rust/perspective-python/src/client/table_data.rs
Original file line number Diff line number Diff line change
@@ -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<ColumnType> {
if val.is_instance_of::<PyString>() {
val.extract::<String>()?.as_str().try_into().into_pyerr()
} else if let Ok(val) = val.downcast::<PyType>() {
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<TableData, PyErr> {
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::<PyList>().is_ok() {
let json_module = PyModule::import_bound(py, "json")?;
let string = json_module.call_method("dumps", (pydict,), None)?;
Ok(UpdateData::JsonColumns(string.extract::<String>()?).into())
} else {
let mut schema = vec![];
for (key, val) in pydict.into_iter() {
schema.push((key.extract::<String>()?, psp_type_from_py_type(py, val)?));
}

Ok(TableData::Schema(schema))
}
}

#[extend::ext]
pub impl TableData {
fn from_py(
py: Python<'_>,
input: Py<PyAny>,
format: Option<TableReadFormat>,
) -> Result<TableData, PyErr> {
if let Some(update) = UpdateData::from_py_partial(py, &input, format)? {
Ok(TableData::Update(update))
} else if let Ok(pylist) = input.downcast_bound::<PyList>(py) {
let json_module = PyModule::import_bound(py, "json")?;
let string = json_module.call_method("dumps", (pylist,), None)?;
Ok(UpdateData::JsonRows(string.extract::<String>()?).into())
} else if let Ok(pydict) = input.downcast_bound::<PyDict>(py) {
from_dict(py, pydict)
} else {
Err(PyTypeError::new_err(format!(
"Unknown input type {:?}",
input.bind(py).get_type().name()?
)))
}
}
}
Loading

0 comments on commit d630f0f

Please sign in to comment.