Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ __pycache__/
.vscode/*
*.so
*~
/.claude
/.emacs.desktop
/.emacs.desktop.lock
/.idea
Expand Down
53 changes: 52 additions & 1 deletion docs/build.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,54 @@ function copyDocsBundle() {
}
}

/**
* Stub the optional Memory64 engine binary when `@perspective-dev/server`
* was built without `PSP_WASM64`, so the docs bundle without that compile.
* The stub throws when imported, which rejects `engines.ts`'s `wasm64`
* thunk and lets `init_server` fall back to the wasm32 binary at runtime.
*/
function optionalWasm64Plugin() {
const NAMESPACE = "optional-wasm64";
return {
name: NAMESPACE,
setup(build) {
build.onResolve(
{ filter: /perspective-server\.memory64\.wasm$/ },
async (args) => {
if (args.pluginData === NAMESPACE) {
return;
}

const resolved = await build.resolve(args.path, {
kind: args.kind,
importer: args.importer,
resolveDir: args.resolveDir,
pluginData: NAMESPACE,
});

if (resolved.errors.length === 0) {
return resolved;
}

console.warn(
`No ${path.basename(args.path)} (PSP_WASM64 unset); ` +
"perspective-server will run as wasm32.",
);

return { path: args.path, namespace: NAMESPACE };
},
);

build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => ({
loader: "js",
contents: `throw new Error(${JSON.stringify(
`${args.path} was not built (set PSP_WASM64=1)`,
)});`,
}));
},
};
}

function esbuildOptions() {
return {
entryPoints: [path.join(__dirname, "src/index.ts")],
Expand All @@ -251,6 +299,7 @@ function esbuildOptions() {
".wasm": "file",
".arrow": "file",
},
plugins: [optionalWasm64Plugin()],
};
}

Expand Down Expand Up @@ -301,9 +350,11 @@ async function watch() {
copyStatic();
copyDocsBundle();

const options = esbuildOptions();
const ctx = await esbuild.context({
...esbuildOptions(),
...options,
plugins: [
...options.plugins,
{
name: "livereload",
setup(build) {
Expand Down
4 changes: 3 additions & 1 deletion docs/md/explanation/architecture/client_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ loop.start()
## Javascript client

Perspective's websocket client interfaces with the Python server, then
_replicates_ the server-side Table.
_replicates_ the server-side Table. When the server-side `Table` has an `index`,
the replica inherits it, and both `update()` and `remove()` on the server are
mirrored in the browser.

```javascript
const websocket = await perspective.websocket("ws://localhost:8080");
Expand Down
40 changes: 40 additions & 0 deletions docs/md/explanation/view/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,39 @@ When `mode` is set to `"row"`, the callback receives a delta of only the rows
that changed (as Apache Arrow), which is useful for efficiently synchronizing
tables across clients.

## Remove Callbacks

Register a callback to be notified whenever rows are removed from the underlying
`Table` by `remove()`, which requires an `index`. The callback receives the
`port_id` and the removed `index` column values as an Apache Arrow of a single
column named after the index. It fires once per update step, only for rows which
existed before that step; `replace()` reports the keys it does not re-supply,
and `clear()` reports every key:

<div class="javascript">

```javascript
const callback = await view.on_remove(({ indices, port_id }) => {
replica.remove(indices);
});

// Later, remove the callback
await view.remove_remove(callback);
```

</div>
<div class="python">

```python
def on_remove(port_id, indices):
replica.remove(indices)

callback = view.on_remove(on_remove)
view.remove_remove(callback)
```

</div>

## Flattening a View into a Table

A [`Table`] can be constructed on a [`Table::view`] instance, which will return
Expand All @@ -192,6 +225,13 @@ particularly useful for implementing a
handles the `View` serialization and `on_update` forwarding for you. This
pattern is available in JavaScript, Python and Rust.

When the source `Table` has an `index`, and the `View` is unpivoted and includes
the index column, the new `Table` inherits that `index` and subscribes to the
source's `on_remove()`, so in-place updates and `remove()` calls on the source
are mirrored rather than appended. A pivoted `View`, or one which omits the
index column, produces an unindexed, append-only `Table`. A `limit` is inherited
the same way. `replace()` and `clear()` on the source are mirrored too.

<div class="javascript">

```javascript
Expand Down
34 changes: 27 additions & 7 deletions docs/md/explanation/view/config/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ let view = table.view(Some(ViewConfigUpdate {

## Type Conversion and Coercion

Perspective expressions are strongly typed — each column and literal has a fixed
type, and most operators require matching types on both sides. To work across
types, use the conversion functions:
Perspective expressions are typed: every column, literal and function result has
a fixed type, and the validator reports an error before the expression is ever
computed if the types do not fit the operator. To move between types explicitly,
use the conversion functions:

| Function | Description |
| --------------- | ------------------------------------------------------------ |
Expand All @@ -59,10 +60,29 @@ types, use the conversion functions:

### How coercion works

Perspective does not implicitly coerce types. For example, you cannot directly
add an `integer` to a `float` — you must cast one side explicitly. Similarly,
`datetime` and `date` values are not numeric: to perform arithmetic on them, you
must first convert to a numeric representation, do the math, then convert back.
Numeric types promote to each other. Arithmetic on any mix of `integer` and
`float` operands is computed in floating point and produces a `float`. The
comparison operators compare values across every numeric type: integers are
compared exactly (including signed against unsigned), and as soon as one side is
a `float` both sides are compared as doubles. Numeric literals are `float`, so
`"Quantity" > 3` works on an `integer` column without a cast.

No other implicit coercion exists. `boolean`, `string`, `date` and `datetime`
values can only be compared with values of the same type; comparing a `string`
column to a number, a `boolean` to `1`, or a `date` to a `datetime` is a
validation error that names the operator and both types, for example
`Type Error - cannot compare string and float with '=='`. Similarly, `datetime`
and `date` values are not numeric: to perform arithmetic on them, you must first
convert to a numeric representation, do the math, then convert back.

Boolean contexts cast instead. The condition of `if` and `? :`, and the operands
of `and`, `or`, `not`, `xor`, `nand`, `nor` and `xnor`, accept any type: `null`
is `false`, a `boolean` is its own value, a number is `true` when non-zero, and
a `string` is `true` when non-null. `x == null` and `x != null` test `x` for
null and return `boolean`, the same as `is_null(x)` and `is_not_null(x)`; the
`null` literal is otherwise a value like any null cell: `"x" > 2 ? null : "x"`
yields null in the first case, and `"x" + null` or `"x" < null` are null, exactly
as they would be for a column with a null value.

Internally, `datetime` values are stored as milliseconds since the Unix epoch
(1970-01-01T00:00:00Z). Converting a `datetime` to a `float` yields this
Expand Down
12 changes: 12 additions & 0 deletions docs/md/how_to/python/callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,15 @@ view.remove_delete(on_delete_id)

Callbacks defined with a lambda function cannot be removed, as lambda functions
have no identifier.

`on_remove` fires when rows are removed from a `View`'s `Table` with an `index`,
and receives the port ID and the removed index values as an Apache Arrow
(`bytes`) of one column named after the index:

```python
def remove_callback(port_id, indices):
print("Removed", client.table(indices).view().to_records())

on_remove_id = view.on_remove(remove_callback)
view.remove_remove(on_remove_id)
```
82 changes: 82 additions & 0 deletions packages/jupyterlab/test/jupyter/widget.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,88 @@ assert w2.group_by == ["bool"]
},
);

test_jupyter(
"Table.remove propagates to widget",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table({'key': 'string', 'value': 'integer'}, index='key')",
"table.update([{'key': 'What is the answer?', 'value': 42}])",
"w = perspective.widget.PerspectiveWidget(table)",
].join("\n"),
"w",
],
async ({ page }) => {
await default_body(page);
const rows = page.locator("regular-table tbody tr");
await expect(rows).toHaveCount(1);

await add_and_execute_cell(
page,
"table.update([{'key': 'Hej', 'value': 74}])",
);
await expect(rows).toHaveCount(2);

await add_and_execute_cell(
page,
"table.update([{'key': 'Hej', 'value': 75}])",
);
await expect(rows).toHaveCount(2);

await add_and_execute_cell(page, "table.remove(['Hej'])");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("What is the answer?");

await add_and_execute_cell(page, "w");
const viewers = page.locator(
".jp-OutputArea-output perspective-viewer",
);
await expect(viewers).toHaveCount(2);
for (const v of await viewers.all()) {
await v.evaluate(async (viewer) => await viewer.flush());
await expect(
v.locator("regular-table tbody tr"),
).toHaveCount(1);
}
},
);

test_jupyter(
"Table.remove propagates to widget in client-server binding mode",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table({'key': 'string', 'value': 'integer'}, index='key')",
"table.update([{'key': 'What is the answer?', 'value': 42}])",
"w = perspective.widget.PerspectiveWidget(table, binding_mode='client-server')",
].join("\n"),
"w",
],
async ({ page }) => {
await default_body(page);
const rows = page.locator("regular-table tbody tr");
await expect(rows).toHaveCount(1);

await add_and_execute_cell(
page,
"table.update([{'key': 'Hej', 'value': 74}])",
);
await expect(rows).toHaveCount(2);

await add_and_execute_cell(
page,
"table.update([{'key': 'Hej', 'value': 75}])",
);
await expect(rows).toHaveCount(2);

await add_and_execute_cell(page, "table.remove(['Hej'])");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("What is the answer?");
},
);

// Traits mutated after construction but before the widget is displayed
// must be applied by the initial `restore()` (the restore-before-
// load-complete path).
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ catalog:
"react-dom": ">17 <20"
"react": ">17 <20"
"regular-layout": "=0.6.1"
"regular-table": "=0.9.0"
"regular-table": "=0.9.1"
"stoppable": "=1.1.0"
"ws": "^8.17.0"

Expand Down
5 changes: 3 additions & 2 deletions rust/metadata/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ use std::fs;
use perspective_client::config::*;
use perspective_client::virtual_server::Features;
use perspective_client::{
ColumnWindow, DeleteOptions, JoinOptions, OnUpdateData, OnUpdateOptions, SystemInfo,
TableInitOptions, UpdateOptions, ViewWindow,
ColumnWindow, DeleteOptions, JoinOptions, OnRemoveData, OnUpdateData, OnUpdateOptions,
SystemInfo, TableInitOptions, UpdateOptions, ViewWindow,
};
use perspective_js::TypedArrayWindow;
use perspective_viewer::config::{
Expand Down Expand Up @@ -109,6 +109,7 @@ pub fn generate_type_bindings_js() -> Result<(), Box<dyn Error>> {
DeleteOptions::export_all_to(&path)?;
Features::export_all_to(&path)?;
JoinOptions::export_all_to(&path)?;
OnRemoveData::export_all_to(&path)?;
OnUpdateData::export_all_to(&path)?;
OnUpdateOptions::export_all_to(&path)?;
SystemInfo::<f64>::export_all_to(&path)?;
Expand Down
11 changes: 10 additions & 1 deletion rust/perspective-client/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,17 @@ fn prost_build() -> Result<()> {
prost_build::Config::new()
// .bytes(["ViewToArrowResp.arrow", "from_arrow"])
.type_attribute("ViewOnUpdateResp", "#[derive(ts_rs::TS)]")
.field_attribute("ViewOnUpdateResp.delta", "#[ts(as = \"Vec::<u8>\")]")
.field_attribute(
"ViewOnUpdateResp.delta",
"#[ts(type = \"Uint8Array | undefined\")]",
)
.field_attribute("ViewOnUpdateResp.delta", "#[serde(with = \"serde_bytes\")]")
.type_attribute("ViewOnRemoveResp", "#[derive(ts_rs::TS)]")
.field_attribute("ViewOnRemoveResp.indices", "#[ts(type = \"Uint8Array\")]")
.field_attribute(
"ViewOnRemoveResp.indices",
"#[serde(with = \"serde_bytes\")]",
)
.type_attribute("ColumnType", "#[derive(ts_rs::TS)]")
.type_attribute(
"JoinType",
Expand Down
Loading
Loading