From 85da3c6e0ca623be9ad7edd197df6986330345e8 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Tue, 27 Jan 2026 13:54:31 -0500 Subject: [PATCH 1/2] Support `client.table(view)` in Python Signed-off-by: Andrew Stein --- .../tests/table/test_table_view_table.py | 130 ++++++++++++++++++ .../src/client/client_async.rs | 2 +- .../src/client/client_sync.rs | 2 +- .../src/client/table_data.rs | 6 +- 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 rust/perspective-python/perspective/tests/table/test_table_view_table.py diff --git a/rust/perspective-python/perspective/tests/table/test_table_view_table.py b/rust/perspective-python/perspective/tests/table/test_table_view_table.py new file mode 100644 index 0000000000..e6f1c9b608 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_view_table.py @@ -0,0 +1,130 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import pandas as pd +import numpy as np +from perspective import PerspectiveError +from datetime import date, datetime +from pytest import approx, mark, raises + +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def date_timestamp(date): + return int(datetime.combine(date, datetime.min.time()).timestamp()) * 1000 + + +def compare_delta(received, expected): + """Compare an arrow-serialized row delta by constructing a Table.""" + tbl = Table(received) + assert tbl.view().to_columns() == expected + + +class TestView(object): + def test_view_zero(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 2 + assert view2.schema() == {"a": "integer", "b": "integer"} + assert view2.to_records() == data + + def test_view_one(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + tbl2 = Table(view) + view2 = tbl2.view() + + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 3 + assert view2.schema() == { + "a (Group by 1)": "integer", + "a": "integer", + "b": "integer", + } + + assert view2.to_records() == [ + {"a (Group by 1)": None, "a": 4, "b": 6}, + {"a (Group by 1)": 1, "a": 1, "b": 2}, + {"a (Group by 1)": 3, "a": 3, "b": 4}, + ] + + def test_view_two(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 5 + assert view2.schema() == { + "a (Group by 1)": "integer", + "2|a": "integer", + "2|b": "integer", + "4|a": "integer", + "4|b": "integer", + } + + assert view2.to_records() == [ + { + "a (Group by 1)": None, + "2|a": 1, + "2|b": 2, + "4|a": 3, + "4|b": 4, + }, + { + "a (Group by 1)": 1, + "2|a": 1, + "2|b": 2, + "4|a": None, + "4|b": None, + }, + { + "a (Group by 1)": 3, + "2|a": None, + "2|b": None, + "4|a": 3, + "4|b": 4, + }, + ] + + def test_view_two_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 4 + assert view2.schema() == { + "2|a": "integer", + "2|b": "integer", + "4|a": "integer", + "4|b": "integer", + } + + assert view2.to_records() == [ + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4}, + ] diff --git a/rust/perspective-python/src/client/client_async.rs b/rust/perspective-python/src/client/client_async.rs index 26762de984..d80d4096ac 100644 --- a/rust/perspective-python/src/client/client_async.rs +++ b/rust/perspective-python/src/client/client_async.rs @@ -627,7 +627,7 @@ impl AsyncTable { #[pyclass] #[derive(Clone)] pub struct AsyncView { - view: Arc, + pub(crate) view: Arc, _client: AsyncClient, } diff --git a/rust/perspective-python/src/client/client_sync.rs b/rust/perspective-python/src/client/client_sync.rs index edbe88b0bf..8b64ca5eb3 100644 --- a/rust/perspective-python/src/client/client_sync.rs +++ b/rust/perspective-python/src/client/client_sync.rs @@ -469,7 +469,7 @@ impl Table { /// Perspective conserves memory by relying on a single [`Table`] to power /// multiple [`View`]s concurrently. #[pyclass(subclass, name = "View", module = "perspective")] -pub struct View(AsyncView); +pub struct View(pub(crate) AsyncView); assert_view_api!(View); diff --git a/rust/perspective-python/src/client/table_data.rs b/rust/perspective-python/src/client/table_data.rs index 786bc96f33..3281352adf 100644 --- a/rust/perspective-python/src/client/table_data.rs +++ b/rust/perspective-python/src/client/table_data.rs @@ -70,7 +70,11 @@ pub impl TableData { input: Bound<'_, PyAny>, format: Option, ) -> Result { - if let Some(update) = UpdateData::from_py_partial(&input, format)? { + if let Ok(view) = input.downcast::() { + Ok(TableData::View((*view.borrow().view).clone())) + } else if let Ok(view) = input.downcast::() { + Ok(TableData::View((*view.borrow().0.view).clone())) + } else if let Some(update) = UpdateData::from_py_partial(&input, format)? { Ok(TableData::Update(update)) } else if let Ok(pylist) = input.downcast::() { let json_module = PyModule::import(input.py(), "json")?; From ad9ff25994972333374a10ef429833968e9f8876 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Tue, 27 Jan 2026 13:55:44 -0500 Subject: [PATCH 2/2] Fix windows Signed-off-by: Andrew Stein --- Cargo.lock | 3 +-- Cargo.toml | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80815092ac..8b2ae04dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2132,8 +2132,7 @@ dependencies = [ [[package]] name = "protobuf-src" version = "2.1.1+27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6217c3504da19b85a3a4b2e9a5183d635822d83507ba0986624b5c05b83bfc40" +source = "git+https://github.com/MaterializeInc/rust-protobuf-native?rev=1aba500e469f8bdc384a0fe9e69c189fda72e059#1aba500e469f8bdc384a0fe9e69c189fda72e059" dependencies = [ "cmake", ] diff --git a/Cargo.toml b/Cargo.toml index fb3c21bd1c..21a89b65ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ strip = true # These are only respected when `cargo` is invoked from the project root [patch.crates-io] # simd-adler32 = { git = "https://github.com/mcountryman/simd-adler32.git", rev = "b279034d9eb554c3e5e0af523db044f08d8297ba" } +protobuf-src = { rev = "1aba500e469f8bdc384a0fe9e69c189fda72e059", git = "https://github.com/MaterializeInc/rust-protobuf-native" } perspective-client = { path = "rust/perspective-client" } perspective-server = { path = "rust/perspective-server" } perspective-js = { path = "rust/perspective-js" }