// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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::collections::HashMap; use std::pin::pin; use std::str::FromStr; use std::sync::Arc; use futures::FutureExt; use perspective_client::{ Client, ColumnWindow, DeleteOptions, OnUpdateData, OnUpdateMode, OnUpdateOptions, Table, TableData, TableInitOptions, TableReadFormat, TableRef, UpdateData, UpdateOptions, View, ViewWindow, assert_table_api, assert_view_api, asyncfn, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::{PyAny, PyBytes, PyDict, PyString}; use pythonize::depythonize; use super::pandas::arrow_to_pandas; use super::polars::arrow_to_polars; use super::table_data::TableDataExt; use super::update_data::UpdateDataExt; use super::{pandas, polars, pyarrow}; use crate::py_async::{self, AllowThreads}; use crate::py_err::{PyPerspectiveError, ResultTClientErrorExt}; fn py_to_table_ref_async(val: &Bound<'_, PyAny>) -> PyResult { if let Ok(t) = val.extract::() { Ok(TableRef::from(t.table.as_ref())) } else if let Ok(name) = val.extract::() { Ok(TableRef::from(name)) } else { Err(pyo3::exceptions::PyTypeError::new_err( "Expected a Table or string table name", )) } } fn py_to_table_ref_from_owned(py: Python<'_>, val: &Py) -> PyResult { let bound = val.bind(py); py_to_table_ref_async(bound) } /// An instance of a [`Client`] is a connection to a single /// `perspective_server::Server`, whether locally in-memory or remote over some /// transport like a WebSocket. /// /// `AsyncClient` and Perspective objects derived from it have _async_ APIs, /// suitable for integration with a Python event loop like `asyncio`. /// @private #[pyclass(module = "perspective")] #[derive(Clone)] pub struct AsyncClient { pub(crate) client: Client, close_cb: Arc>>, } impl AsyncClient { pub fn new_from_client(client: Client) -> Self { AsyncClient { client, close_cb: Arc::default(), } } } #[pymethods] impl AsyncClient { #[new] #[pyo3(signature=(handle_request, handle_close=None, name=None))] pub fn new( handle_request: Py, handle_close: Option>, name: Option, ) -> PyResult { let handle_request = Arc::new(handle_request); let client = Client::new_with_callback( name.as_deref(), asyncfn!(handle_request, async move |msg| { if let Some(fut) = Python::with_gil(move |py| -> PyResult<_> { let ret = handle_request.call1(py, (PyBytes::new(py, &msg),))?; if isawaitable(ret.bind(py)).unwrap_or(false) { Ok(Some(py_async::py_into_future(ret.into_bound(py))?)) } else { Ok(None) } })? { let result = fut.await; Python::with_gil(|_| { result .map(|_| ()) .map_err(perspective_server::ServerError::from) })? } Ok(()) }), ); Ok(AsyncClient { client: client.into_pyerr()?, close_cb: handle_close.into(), }) } /// Handle a message from the external message queue. /// [`Client::handle_response`] is part of the low-level message-handling /// API necessary to implement new transports for a [`Client`] /// connection to a local-or-remote `perspective_server::Server`, and /// doesn't generally need to be called directly by "users" of a /// [`Client`] once connected. pub async fn handle_response(&self, bytes: Py) -> PyResult { self.client .handle_response(Python::with_gil(|py| bytes.as_bytes(py))) .await .into_pyerr() } /// Creates a new [`Table`] from either a _schema_ or _data_. /// /// The [`Client::table`] factory function can be initialized with either a /// _schema_ (see [`Table::schema`]), or data in one of these formats: /// /// - Apache Arrow /// - CSV /// - JSON row-oriented /// - JSON column-oriented /// /// When instantiated with _data_, the schema is inferred from this data. /// While this is convenient, inferrence is sometimes imperfect e.g. /// when the input is empty, null or ambiguous. For these cases, /// [`Client::table`] can first be instantiated with a explicit schema. /// /// When instantiated with a _schema_, the resulting [`Table`] is empty but /// with known column names and column types. When subsqeuently /// populated with [`Table::update`], these columns will be _coerced_ to /// the schema's type. This behavior can be useful when /// [`Client::table`]'s column type inferences doesn't work. /// /// The resulting [`Table`] is _virtual_, and invoking its methods /// dispatches events to the `perspective_server::Server` this /// [`Client`] connects to, where the data is stored and all calculation /// occurs. /// /// # Arguments /// /// - `arg` - Either _schema_ or initialization _data_. /// - `options` - Optional configuration which provides one of: /// - `limit` - The max number of rows the resulting [`Table`] can /// store. /// - `index` - The column name to use as an _index_ column. If this /// `Table` is being instantiated by _data_, this column name must be /// present in the data. /// - `name` - The name of the table. This will be generated if it is /// not provided. /// - `format` - The explicit format of the input data, can be one of /// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides /// language-specific type dispatch behavior, which allows stringified /// and byte array alternative inputs. /// - `page_to_disk` - Back this [`Table`]'s canonical data with the /// on-disk (memory-mapped) storage backend instead of memory. /// /// # Python Examples /// /// Load a CSV from a `str`: /// /// ```python /// table = await client.table("x,y\n1,2\n3,4") /// ``` #[pyo3(signature=(input, limit=None, index=None, name=None, format=None, page_to_disk=None))] pub async fn table( &self, input: Py, limit: Option, index: Option>, name: Option>, format: Option>, page_to_disk: Option, ) -> PyResult { let client = self.client.clone(); let py_client = Python::with_gil(|_| self.clone()); let table = Python::with_gil(|py| { let mut options = TableInitOptions { name: name.map(|x| x.extract::(py)).transpose()?, page_to_disk, ..TableInitOptions::default() }; let format = TableReadFormat::parse(format.map(|x| x.to_string())) .map_err(PyPerspectiveError::new_err)?; match (limit, index) { (None, None) => {}, (None, Some(index)) => { options.index = Some(index.extract::(py)?); }, (Some(limit), None) => options.limit = Some(limit), (Some(_), Some(_)) => { Err(PyValueError::new_err("Cannot set both `limit` and `index`"))? }, }; let input = input.into_bound(py); let input_data = if pyarrow::is_arrow_table(py, &input)? { pyarrow::to_arrow_bytes(py, &input)?.into_any() } else if pandas::is_pandas_df(py, &input)? { pandas::pandas_to_arrow_bytes(py, &input)?.into_any() } else if polars::is_polars_df(py, &input)? || polars::is_polars_lf(py, &input)? { polars::polars_to_arrow_bytes(py, &input)?.into_any() } else { input }; let table_data = TableData::from_py(input_data, format)?; let table = client.table(table_data, options); Ok::<_, PyErr>(table) })?; let table = table.await.into_pyerr()?; Ok(AsyncTable { table: Arc::new(table), client: py_client, }) } /// Opens a [`Table`] that is hosted on the `perspective_server::Server` /// that is connected to this [`Client`]. /// /// The `name` property of [`TableInitOptions`] is used to identify each /// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`] /// via [`Client::get_hosted_table_names`]. /// /// # Python Examples /// /// ```python /// table = await client.open_table("table_one"); /// ``` pub async fn open_table(&self, name: String) -> PyResult { let client = self.client.clone(); let py_client = self.clone(); let table = client.open_table(name).await.into_pyerr()?; Ok(AsyncTable { table: Arc::new(table), client: py_client, }) } /// Creates a new read-only [`Table`] by performing an INNER JOIN on two /// source tables. The resulting table is reactive: when either source /// table is updated, the join is automatically recomputed. /// /// # Python Examples /// /// ```python /// joined = await client.join(orders_table, products_table, "Product ID", "left") /// ``` #[pyo3(signature = (left, right, on, join_type=None, name=None, right_on=None))] pub async fn join( &self, left: Py, right: Py, on: String, join_type: Option, name: Option, right_on: Option, ) -> PyResult { let (left_ref, right_ref) = Python::with_gil(|py| { let left_ref = py_to_table_ref_from_owned(py, &left)?; let right_ref = py_to_table_ref_from_owned(py, &right)?; Ok::<_, PyErr>((left_ref, right_ref)) })?; let jt = super::client_sync::parse_join_type(join_type.as_deref())?; let options = perspective_client::JoinOptions { join_type: Some(jt), name, right_on, }; let py_client = self.clone(); let table = self .client .join(left_ref, right_ref, &on, options) .await .into_pyerr()?; Ok(AsyncTable { table: Arc::new(table), client: py_client, }) } /// Retrieves the names of all tables that this client has access to. /// /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), /// which can be used in conjunction with [`Client::open_table`] to get /// a [`Table`] instance without the use of [`Client::table`] /// constructor directly (e.g., one created by another [`Client`]). /// /// # Python Examples /// /// ```python /// tables = await client.get_hosted_table_names(); /// ``` pub async fn get_hosted_table_names(&self) -> PyResult> { self.client.get_hosted_table_names().await.into_pyerr() } /// Register a callback which is invoked whenever [`Client::table`] (on this /// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this /// [`Client`]) are called. pub async fn on_hosted_tables_update(&self, callback_py: Py) -> PyResult { let callback = Box::new(move || { let callback = Python::with_gil(|py| Py::clone_ref(&callback_py, py)); async move { let aggregate_errors: PyResult<()> = { let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); Python::with_gil(|py| { callback.call0(py)?; Ok(()) }) }; // TODO These are unrecoverable errors - we should mark them as such if let Err(err) = aggregate_errors { tracing::warn!("Error in on_hosted_tables_update callback: {:?}", err); } } .boxed() }); let callback_id = self .client .on_hosted_tables_update(callback) .await .into_pyerr()?; Ok(callback_id) } /// Remove a callback previously registered via /// [`Client::on_hosted_tables_update`]. pub async fn remove_hosted_tables_update(&self, id: u32) -> PyResult<()> { self.client .remove_hosted_tables_update(id) .await .into_pyerr() } /// Provides the [`SystemInfo`] struct, implementation-specific metadata /// about the [`perspective_server::Server`] runtime such as Memory and /// CPU usage. pub async fn system_info(&self) -> PyResult> { let sysinfo = self.client.system_info().await.into_pyerr()?; Python::with_gil(|py| Ok(pythonize::pythonize(py, &sysinfo)?.unbind())) } /// Terminates this [`Client`], cleaning up any [`crate::View`] handles the /// [`Client`] has open as well as its callbacks. pub fn terminate(&self, py: Python<'_>) -> PyResult<()> { if let Some(cb) = &*self.close_cb { cb.call0(py)?; } Ok(()) } } /// [`AsyncTable`] is Perspective's columnar data frame, analogous to a /// Pandas/Polars `DataFrame` or Apache Arrow, supporting append & in-place /// updates, removal by index, and update notifications. /// /// A [`AsyncTable`] contains columns, each of which have a unique name, are /// strongly and consistently typed, and contains rows of data conforming to the /// column's type. Each column in a [`AsyncTable`] must have the same number of /// rows, though not every row must contain data; null-values are used to /// indicate missing values in the dataset. The schema of a [`AsyncTable`] is /// _immutable after creation_, which means the column names and data types /// cannot be changed after the [`AsyncTable`] has been created. Columns cannot /// be added or deleted after creation either, but a [`AsyncView`] can be used /// to select an arbitrary set of columns from the [`AsyncTable`]. #[pyclass] #[derive(Clone)] pub struct AsyncTable { pub(super) table: Arc, pub(super) client: AsyncClient, } assert_table_api!(AsyncTable); #[pymethods] impl AsyncTable { pub fn get_index(&self) -> Option { self.table.get_index() } /// Get a copy of the [`Client`] this [`Table`] came from. pub async fn get_client(&self) -> AsyncClient { AsyncClient { client: self.table.get_client(), close_cb: self.client.close_cb.clone(), } } /// Returns the user-specified row limit for this table. pub fn get_limit(&self) -> Option { self.table.get_limit() } /// Returns the user-specified name for this table, or the auto-generated /// name if a name was not specified when the table was created. pub fn get_name(&self) -> String { self.table.get_name().into() } /// Returns the number of rows in a [`Table`]. pub async fn size(&self) -> PyResult { self.table.size().await.into_pyerr() } /// Returns the column names of this [`Table`] in "natural" order (the /// ordering implied by the input format). /// /// # Python Examples /// /// ```python /// columns = table.columns() /// ``` pub async fn columns(&self) -> PyResult> { self.table.columns().await.into_pyerr() } /// Removes all the rows in the [`Table`], but preserves everything else /// including the schema, index, and any callbacks or registered /// [`View`] instances. /// /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], /// will trigger an update event to any registered listeners via /// [`View::on_update`]. pub async fn clear(&self) -> PyResult<()> { self.table.clear().await.into_pyerr() } /// Delete this [`Table`] and cleans up associated resources. /// /// [`Table`]s do not stop consuming resources or processing updates when /// they are garbage collected in their host language - you must call /// this method to reclaim these. /// /// # Arguments /// /// - `options` An options dictionary. /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the /// default), the delete will occur immediately, assuming it has no /// [`View`] instances registered to it (which must be deleted first, /// otherwise this method will throw an error). When true, the /// [`Table`] will only be marked for deltion once its [`View`] /// dependency count reaches 0. /// /// # Python Examples /// /// ```python /// table = await client.table("x,y\n1,2\n3,4") /// /// # ... /// /// table.delete(lazy=True) /// ``` #[pyo3(signature=(lazy=false))] pub async fn delete(&self, lazy: bool) -> PyResult<()> { self.table.delete(DeleteOptions { lazy }).await.into_pyerr() } /// Create a unique channel ID on this [`Table`], which allows /// `View::on_update` callback calls to be associated with the /// `Table::update` which caused them. pub async fn make_port(&self) -> PyResult { self.table.make_port().await.into_pyerr() } /// Register a callback which is called exactly once, when this [`Table`] is /// deleted with the [`Table::delete`] method. /// /// [`Table::on_delete`] resolves when the subscription message is sent, not /// when the _delete_ event occurs. pub async fn on_delete(&self, callback_py: Py) -> PyResult { let callback = { let callback_py = Python::with_gil(|py| Py::clone_ref(&callback_py, py)); Box::new(move || { Python::with_gil(|py| callback_py.call0(py)) .expect("`on_delete()` callback failed"); }) }; let callback_id = self.table.on_delete(callback).await.into_pyerr()?; Ok(callback_id) } /// Removes a listener with a given ID, as returned by a previous call to /// [`Table::on_delete`]. pub async fn remove_delete(&self, callback_id: u32) -> PyResult<()> { self.table.remove_delete(callback_id).await.into_pyerr() } /// Removes rows from this [`Table`] with the `index` column values /// supplied. /// /// # Arguments /// /// - `indices` - A list of `index` column values for rows that should be /// removed. /// /// # Python Examples /// /// ```python /// tbl = await client.table({"a": [1, 2, 3]}, index="a") /// await tbl.remove([2, 3]) /// ``` #[pyo3(signature=(input, format=None))] pub async fn remove(&self, input: Py, format: Option) -> PyResult<()> { let table = &self.table; let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; let table_data = Python::with_gil(|py| UpdateData::from_py(input.into_bound(py), format))?; table.remove(table_data).await.into_pyerr() } /// Replace all rows in this [`Table`] with the input data, coerced to this /// [`Table`]'s existing [`Schema`], notifying any derived [`View`] and /// [`View::on_update`] callbacks. /// /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a /// [`Table`] without losing any derived [`View`] instances or /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer /// data types like [`Client::table`] does, rather it _coerces_ input /// data to the `Schema` like [`Table::update`]. If you need a [`Table`] /// with a different `Schema`, you must create a new one. /// /// # Python Examples /// /// ```python /// await table.replace("x,y\n1,2") /// ``` #[pyo3(signature=(input, format=None))] pub async fn replace(&self, input: Py, format: Option) -> PyResult<()> { let table = &self.table; let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; let table_data = Python::with_gil(|py| UpdateData::from_py(input.into_bound(py), format))?; table.replace(table_data).await.into_pyerr() } /// Updates the rows of this table and any derived [`View`] instances. /// /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks /// register to derived [`View`], and the call itself will not resolve until /// _all_ derived [`View`]'s are notified. /// /// When updating a [`Table`] with an `index`, [`Table::update`] supports /// partial updates, by omitting columns from the update data. /// /// # Arguments /// /// - `input` - The input data for this [`Table`]. The schema of a [`Table`] /// is immutable after creation, so this method cannot be called with a /// schema. /// - `options` - Options for this update step - see [`UpdateOptions`]. /// /// # Python Examples /// /// ```python /// await table.update("x,y\n1,2") /// ``` #[pyo3(signature=(input, port_id=None, format=None))] pub async fn update( &self, input: Py, port_id: Option, format: Option, ) -> PyResult<()> { let input_data: Py = Python::with_gil(|py| { let input = input.into_bound(py); let data = if pyarrow::is_arrow_table(py, &input)? { pyarrow::to_arrow_bytes(py, &input)?.into_any() } else if pandas::is_pandas_df(py, &input)? { pandas::pandas_to_arrow_bytes(py, &input)?.into_any() } else if polars::is_polars_df(py, &input)? || polars::is_polars_lf(py, &input)? { polars::polars_to_arrow_bytes(py, &input)?.into_any() } else { input }; Ok(data.unbind()) as PyResult> })?; let table = &self.table; let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; let table_data = Python::with_gil(|py| UpdateData::from_py(input_data.into_bound(py), format))?; let options = UpdateOptions { port_id, format }; AllowThreads(pin!(table.update(table_data, options))) .await .into_pyerr()?; Ok(()) } /// Validates the given expressions. pub async fn validate_expressions(&self, expressions: Py) -> PyResult> { let expressions = Python::with_gil(|py| depythonize(expressions.bind(py)))?; let records = self .table .validate_expressions(expressions) .await .into_pyerr()?; Python::with_gil(|py| Ok(pythonize::pythonize(py, &records)?.unbind())) } /// Returns a table's [`Schema`], a mapping of column names to column types. /// /// The mapping of a [`Table`]'s column names to data types is referred to /// as a [`Schema`]. Each column has a unique name and a data type, one /// of: /// /// - `"boolean"` - A boolean type /// - `"date"` - A timesonze-agnostic date type (month/day/year) /// - `"datetime"` - A millisecond-precision datetime type in the UTC /// timezone /// - `"float"` - A 64 bit float /// - `"integer"` - A signed 32 bit integer (the integer type supported by /// JavaScript) /// - `"string"` - A `String` data type (encoded internally as a /// _dictionary_) /// /// Note that all [`Table`] columns are _nullable_, regardless of the data /// type. pub async fn schema(&self) -> PyResult> { let schema = self.table.schema().await.into_pyerr()?; Ok(schema .into_iter() .map(|(x, y)| (x, format!("{y}"))) .collect()) } /// Create a new [`View`] from this table with a specified /// [`ViewConfigUpdate`]. /// /// See [`View`] struct. /// /// # Python Examples /// /// ```python /// view = await table.view( /// columns=["Sales"], /// aggregates={"Sales": "sum"}, /// group_by=["Region", "Country"], /// filter=[["Category", "in", ["Furniture", "Technology"]]] /// ) /// ``` #[pyo3(signature = (**kwargs))] pub async fn view(&self, kwargs: Option>) -> PyResult { let config = kwargs .map(|config| Python::with_gil(|py| depythonize(config.bind(py)))) .transpose()?; let view = self.table.view(config).await.into_pyerr()?; Ok(AsyncView { view: Arc::new(view), _client: self.client.clone(), }) } } /// The [`View`] struct is Perspective's query and serialization interface. It /// represents a query on the `Table`'s dataset and is always created from an /// existing `Table` instance via the [`Table::view`] method. /// /// [`View`]s are immutable with respect to the arguments provided to the /// [`Table::view`] method; to change these parameters, you must create a new /// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with /// respect to the [`Table`]'s data, and will (within a conflation window) /// update with the latest state as its parent [`Table`] updates, including /// incrementally recalculating all aggregates, pivots, filters, etc. [`View`] /// query parameters are composable, in that each parameter works independently /// _and_ in conjunction with each other, and there is no limit to the number of /// pivots, filters, etc. which can be applied. #[pyclass] #[derive(Clone)] pub struct AsyncView { pub(crate) view: Arc, _client: AsyncClient, } assert_view_api!(AsyncView); #[pymethods] impl AsyncView { /// Returns an array of strings containing the column paths of the [`View`] /// without any of the source columns. /// /// A column path shows the columns that a given cell belongs to after /// pivots are applied. pub async fn column_paths(&self, window: Option>) -> PyResult> { let window: ColumnWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); self.view.column_paths(window).await.into_pyerr() } /// Delete this [`View`] and clean up all resources associated with it. /// [`View`] objects do not stop consuming resources or processing /// updates when they are garbage collected - you must call this method /// to reclaim these. pub async fn delete(&self) -> PyResult<()> { self.view.delete().await.into_pyerr() } /// Returns this [`View`]'s _dimensions_, row and column count, as well as /// those of the [`crate::Table`] from which it was derived. /// /// - `num_table_rows` - The number of rows in the underlying /// [`crate::Table`]. /// - `num_table_columns` - The number of columns in the underlying /// [`crate::Table`] (including the `index` column if this /// [`crate::Table`] was constructed with one). /// - `num_view_rows` - The number of rows in this [`View`]. If this /// [`View`] has a `group_by` clause, `num_view_rows` will also include /// aggregated rows. /// - `num_view_columns` - The number of columns in this [`View`]. If this /// [`View`] has a `split_by` clause, `num_view_columns` will include all /// _column paths_, e.g. the number of `columns` clause times the number /// of `split_by` groups. pub async fn dimensions(&self) -> PyResult> { let dim = self.view.dimensions().await.into_pyerr()?; Python::with_gil(|py| Ok(pythonize::pythonize(py, &dim)?.unbind())) } pub async fn expand(&self, index: u32) -> PyResult { self.view.expand(index).await.into_pyerr() } pub async fn collapse(&self, index: u32) -> PyResult { self.view.collapse(index).await.into_pyerr() } /// The expression schema of this [`View`], which contains only the /// expressions created on this [`View`]. See [`View::schema`] for /// details. pub async fn expression_schema(&self) -> PyResult> { Ok(self .view .expression_schema() .await .into_pyerr()? .into_iter() .map(|(k, v)| (k, format!("{v}"))) .collect()) } /// A copy of the config object passed to the [`Table::view`] method which /// created this [`View`]. pub async fn get_config(&self) -> PyResult> { let config = self.view.get_config().await.into_pyerr()?; Python::with_gil(|py| Ok(pythonize::pythonize(py, &config)?.unbind())) } /// Calculates the [min, max] of the leaf nodes of a column `column_name`. /// /// # Returns /// /// A tuple of [min, max], whose types are column and aggregate dependent. pub async fn get_min_max(&self, name: String) -> PyResult<(PyObject, PyObject)> { let (min, max) = self.view.get_min_max(name).await.into_pyerr()?; Python::with_gil(|py| { Ok(( super::client_sync::scalar_to_py(py, &min), super::client_sync::scalar_to_py(py, &max), )) }) } /// The number of aggregated rows in this [`View`]. This is affected by the /// "group_by" configuration parameter supplied to this view's contructor. /// /// # Returns /// /// The number of aggregated rows. pub async fn num_rows(&self) -> PyResult { self.view.num_rows().await.into_pyerr() } /// The number of aggregated columns in this [`View`]. This is affected by /// the "split_by" configuration parameter supplied to this view's /// contructor. /// /// # Returns /// /// The number of aggregated columns. pub async fn num_columns(&self) -> PyResult { let dim = self.view.dimensions().await.into_pyerr()?; Ok(dim.num_view_columns) } /// The schema of this [`View`]. /// /// The [`View`] schema differs from the `schema` returned by /// [`Table::schema`]; it may have different column names due to /// `expressions` or `columns` configs, or it maye have _different /// column types_ due to the application og `group_by` and `aggregates` /// config. You can think of [`Table::schema`] as the _input_ schema and /// [`View::schema`] as the _output_ schema of a Perspective pipeline. pub async fn schema(&self) -> PyResult> { Ok(self .view .schema() .await .into_pyerr()? .into_iter() .map(|(k, v)| (k, format!("{v}"))) .collect()) } /// Register a callback with this [`View`]. Whenever the [`View`] is /// deleted, this callback will be invoked. pub async fn on_delete(&self, callback_py: Py) -> PyResult { let callback = { let callback_py = Arc::new(callback_py); Box::new(move || { Python::with_gil(|py| callback_py.call0(py)) .expect("`on_delete()` callback failed"); }) }; let callback_id = self.view.on_delete(callback).await.into_pyerr()?; Ok(callback_id) } /// Unregister a previously registered [`View::on_delete`] callback. pub async fn remove_delete(&self, callback_id: u32) -> PyResult<()> { self.view.remove_delete(callback_id).await.into_pyerr() } /// Register a callback with this [`View`]. Whenever the view's underlying /// table emits an update, this callback will be invoked with an object /// containing `port_id`, indicating which port the update fired on, and /// optionally `delta`, which is the new data that was updated for each /// cell or each row. /// /// # Arguments /// /// - `on_update` - A callback function invoked on update, which receives an /// object with two keys: `port_id`, indicating which port the update was /// triggered on, and `delta`, whose value is dependent on the mode /// parameter. /// - `options` - If this is provided as `OnUpdateOptions { mode: /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated /// rows. Otherwise `delta` will be [`Option::None`]. #[pyo3(signature=(callback, mode=None))] pub async fn on_update(&self, callback: Py, mode: Option) -> PyResult { let callback = move |x: OnUpdateData| { let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); async move { let aggregate_errors: PyResult<()> = { let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); Python::with_gil(|py| { match &x.delta { None => callback.call1(py, (x.port_id,))?, Some(delta) => { callback.call1(py, (x.port_id, PyBytes::new(py, delta)))? }, }; Ok(()) }) }; if let Err(err) = aggregate_errors { tracing::warn!("Error in on_update callback: {:?}", err); } } .boxed() }; let mode = mode .map(|x| OnUpdateMode::from_str(x.as_str())) .transpose() .into_pyerr()?; self.view .on_update(Box::new(callback), OnUpdateOptions { mode }) .await .into_pyerr() } /// Unregister a previously registered update callback with this [`View`]. /// /// # Arguments /// /// - `id` - A callback `id` as returned by a recipricol call to /// [`View::on_update`]. pub async fn remove_update(&self, callback_id: u32) -> PyResult<()> { self.view.remove_update(callback_id).await.into_pyerr() } #[pyo3(signature=(**window))] pub async fn to_dataframe(&self, window: Option>) -> PyResult> { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); let arrow = self.view.to_arrow(window).await.into_pyerr()?; Python::with_gil(|py| arrow_to_pandas(py, &arrow)) } #[pyo3(signature=(**window))] pub async fn to_polars(&self, window: Option>) -> PyResult> { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); let arrow = self.view.to_arrow(window).await.into_pyerr()?; Python::with_gil(|py| arrow_to_polars(py, &arrow)) } /// Serializes a [`View`] to the Apache Arrow data format. #[pyo3(signature=(**window))] pub async fn to_arrow(&self, window: Option>) -> PyResult> { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); let arrow = self.view.to_arrow(window).await.into_pyerr()?; Ok(Python::with_gil(|py| PyBytes::new(py, &arrow).into())) } /// Serializes this [`View`] to CSV data in a standard format. #[pyo3(signature=(**window))] pub async fn to_csv(&self, window: Option>) -> PyResult { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); self.view.to_csv(window).await.into_pyerr() } /// Serializes this [`View`] to a string of JSON data. Useful if you want to /// save additional round trip serialize/deserialize cycles. #[pyo3(signature=(**window))] pub async fn to_columns_string(&self, window: Option>) -> PyResult { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); self.view.to_columns_string(window).await.into_pyerr() } /// Serializes this [`View`] to Python objects in a column-oriented /// format. #[pyo3(signature = (**window))] pub async fn to_columns(&self, window: Option>) -> PyResult> { let json = self.to_columns_string(window).await?; Python::with_gil(|py| { let json_module = PyModule::import(py, "json")?; let records = json_module.call_method1("loads", (json,))?; Ok(records.unbind()) }) } /// Render this `View` as a JSON string. #[pyo3(signature=(window=None))] pub async fn to_json_string(&self, window: Option>) -> PyResult { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); self.view.to_json_string(window).await.into_pyerr() } /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec) /// formatted `String`. #[pyo3(signature=(window=None))] pub async fn to_ndjson(&self, window: Option>) -> PyResult { let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) .transpose()? .unwrap_or_default(); self.view.to_ndjson(window).await.into_pyerr() } /// Alias for [`View::to_json`]. #[pyo3(signature = (**window))] pub async fn to_records(&self, window: Option>) -> PyResult> { let json = self.to_json_string(window).await?; Python::with_gil(|py| { let json_module = PyModule::import(py, "json")?; let records = json_module.call_method1("loads", (json,))?; Ok(records.unbind()) }) } /// Serializes this [`View`] to Python objects in a row-oriented /// format. #[pyo3(signature = (**window))] pub async fn to_json(&self, window: Option>) -> PyResult> { self.to_records(window).await } } fn isawaitable(object: &Bound<'_, PyAny>) -> PyResult { let py = object.py(); py.import("inspect")? .call_method1("isawaitable", (object,))? .extract() }