diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fa863ba1bb..c1d4c3b59f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -183,7 +183,7 @@ parameters: jobs: - job: 'WebAssembly' pool: - vmImage: 'ubuntu-16.04' + vmImage: "ubuntu-latest" strategy: matrix: @@ -274,7 +274,7 @@ jobs: - job: 'Linux' pool: - vmImage: 'ubuntu-16.04' + vmImage: "ubuntu-latest" strategy: matrix: @@ -495,9 +495,6 @@ jobs: - bash: jupyter labextension install $(System.DefaultWorkingDirectory)/packages/perspective-jupyterlab displayName: "Install perspective-jupyterlab labextension" - - bash: yarn jlab_link - displayName: "Link local Perspective to Jupyterlab" - - bash: yarn test_js --jupyter --debug displayName: "Run Jupyterlab tests" env: diff --git a/binder/requirements.txt b/binder/requirements.txt index 5c6c6bebc0..9bf94a49b7 100644 --- a/binder/requirements.txt +++ b/binder/requirements.txt @@ -1,4 +1,4 @@ ipywidgets==7.5.1 -jupyterlab==3.0.9 +jupyterlab==3.0.14 pandas==0.25.3 pyarrow==3.0.0 \ No newline at end of file diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md new file mode 100644 index 0000000000..5f3f7622cf --- /dev/null +++ b/examples/jupyter-notebooks/README.md @@ -0,0 +1,15 @@ +# Jupyter Notebook examples for `perspective-python` + +This folder contains several notebooks designed as an introduction to the various features of `perspective-python`: + +- `table_tutorial.ipynb` shows how to load, update, query, and serialize data using `Table` and `View`, and how to connect multiple `Table` instances together using `on_update`. +- `widget_tutorial.ipynb` demonstrates how to use `PerspectiveWidget` as a powerful interactive visualization component within a Jupyter notebook. +- `pandas_pivots.ipynb` displays Perspective's ability to read pivots from a pivoted DataFrame and automatically apply it as part of a `PerspectiveWidget`. + +Each notebook is fully self-contained and should offer a good place to start for those interested in using `perspective-python` whether within a Jupyter environment or in a pure-Python context. + +For examples pertaining to `perspective-python` Tornado servers, check out: + +- [tornado-python](https://github.com/finos/perspective/tree/master/examples/tornado-python): a simple Tornado server that delivers a static dataset to the user using `perspective-python` and ``. +- [tornado-streaming-python](https://github.com/finos/perspective/tree/master/examples/tornado-streaming-python): a streaming Tornado server that demonstrates `perspective-python`'s high throughput and performance in streaming scenarios. +- [workspace-editing-python](https://github.com/finos/perspective/tree/master/examples/workspace-editing-python): a full-featured example using `` that illustrates a deep and powerful integration between `` and `perspective-python`. \ No newline at end of file diff --git a/examples/jupyter-notebooks/widget_tutorial.ipynb b/examples/jupyter-notebooks/widget_tutorial.ipynb index 270e269d7d..33c934c73d 100644 --- a/examples/jupyter-notebooks/widget_tutorial.ipynb +++ b/examples/jupyter-notebooks/widget_tutorial.ipynb @@ -181,7 +181,7 @@ "# Create a complex widget\n", "w = PerspectiveWidget(\n", " df,\n", - " plugin=\"heatmap\",\n", + " plugin=\"Heatmap\",\n", " columns=[\"Sales\"],\n", " row_pivots=[\"State\"],\n", " sort=[[\"Sales\", \"desc\"]]\n", @@ -254,4 +254,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/packages/perspective-jupyterlab/package.json b/packages/perspective-jupyterlab/package.json index 8216a4a55b..47f2ca80cb 100644 --- a/packages/perspective-jupyterlab/package.json +++ b/packages/perspective-jupyterlab/package.json @@ -1,7 +1,7 @@ { "name": "@finos/perspective-jupyterlab", "version": "0.9.0", - "description": "Perspective.js", + "description": "A Jupyterlab extension for the Perspective library, designed to be used with perspective-python.", "files": [ "dist/*.d.ts", "dist/*.js.map", diff --git a/packages/perspective-jupyterlab/src/config/plugin.config.js b/packages/perspective-jupyterlab/src/config/plugin.config.js index 2c7fafbc2f..93a7e23b1c 100644 --- a/packages/perspective-jupyterlab/src/config/plugin.config.js +++ b/packages/perspective-jupyterlab/src/config/plugin.config.js @@ -27,7 +27,7 @@ module.exports = { maxEntrypointSize: 512000, maxAssetSize: 512000 }, - externals: [/^([a-z0-9]|@(?!finos\/perspective-viewer))/], + externals: [/\@jupyter|\@lumino/], stats: {modules: false, hash: false, version: false, builtAt: false, entrypoints: false}, module: { rules: [ @@ -77,7 +77,9 @@ module.exports = { }, output: { filename: "[name].js", - libraryTarget: "commonjs2", + library: { + type: "umd" + }, publicPath: "", path: path.resolve(__dirname, "../../dist") } diff --git a/packages/perspective-jupyterlab/src/less/index.less b/packages/perspective-jupyterlab/src/less/index.less index 4a365885e7..77e0e00da5 100644 --- a/packages/perspective-jupyterlab/src/less/index.less +++ b/packages/perspective-jupyterlab/src/less/index.less @@ -12,11 +12,18 @@ div.PSPContainer-dark { flex: 1; } +// Widget height for Jupyterlab .jp-NotebookPanel-notebook div.PSPContainer, .jp-NotebookPanel-notebook div.PSPContainer-dark { height: 520px; } +// Widget height for Voila +.jp-OutputArea-output div.PSPContainer, +.jp-OutputArea-output div.PSPContainer-dark { + height: 520px; +} + div.PSPContainer perspective-viewer { .perspective-viewer-material-dense(); --plugin--border: 1px solid #e0e0e0; diff --git a/packages/perspective-jupyterlab/src/ts/psp_widget.ts b/packages/perspective-jupyterlab/src/ts/psp_widget.ts index b5db287999..d43dea5d1c 100644 --- a/packages/perspective-jupyterlab/src/ts/psp_widget.ts +++ b/packages/perspective-jupyterlab/src/ts/psp_widget.ts @@ -24,7 +24,6 @@ export interface PerspectiveWidgetOptions extends PerspectiveViewerOptions { server?: boolean; title?: string; bindto?: HTMLElement; - plugin_config?: PerspectiveViewerOptions; // these shouldn't exist, PerspectiveViewerOptions should be sufficient e.g. // ["row-pivots"] @@ -66,7 +65,7 @@ export class PerspectiveWidget extends Widget { const sort: Sort = options.sort || []; const filters: Filters = options.filters || []; const expressions: Expressions = options.expressions || options["expressions"] || []; - const plugin_config: PerspectiveViewerOptions = options.plugin_config || {}; + const plugin_config: object = options.plugin_config || {}; const dark: boolean = options.dark || false; const editable: boolean = options.editable || false; const server: boolean = options.server || false; @@ -128,12 +127,18 @@ export class PerspectiveWidget extends Widget { } } - save(): PerspectiveViewerOptions { - return this.viewer.save(); + async toggleConfig(): Promise { + if (this.isVisible) { + await this.viewer.toggleConfig(); + } + } + + async save(): Promise { + return await this.viewer.save(); } - restore(config: PerspectiveViewerOptions): Promise { - return this.viewer.restore(config); + async restore(config: PerspectiveViewerOptions): Promise { + return await this.viewer.restore(config); } /** @@ -141,8 +146,8 @@ export class PerspectiveWidget extends Widget { * * @param table A `perspective.table` object. */ - load(table: Table): void { - this.viewer.load(table); + async load(table: Table): Promise { + await this.viewer.load(table); } /** @@ -157,8 +162,8 @@ export class PerspectiveWidget extends Widget { /** * Removes all rows from the viewer's table. Does not reset viewer state. */ - clear(): void { - this.viewer.table.clear(); + async clear(): Promise { + await this.viewer.table.clear(); } /** @@ -167,8 +172,8 @@ export class PerspectiveWidget extends Widget { * * @param data */ - replace(data: TableData): void { - this.viewer.table.replace(data); + async replace(data: TableData): Promise { + await this.viewer.table.replace(data); } /** @@ -294,13 +299,20 @@ export class PerspectiveWidget extends Widget { } } - get plugin_config(): PerspectiveViewerOptions { + // `plugin_config` cannot be synchronously read from the viewer, as it is + // not part of the attribute API and only emitted from save(). Users can + // pass in a plugin config and have it applied to the viewer, but they + // cannot read the current `plugin_config` of the viewer if it has not + // already been set from Python. + get plugin_config(): object { return this._plugin_config; } - set plugin_config(plugin_config: PerspectiveViewerOptions) { + set plugin_config(plugin_config: object) { this._plugin_config = plugin_config; + + // Allow plugin configs passed from Python to take effect on the viewer if (this._plugin_config) { - this.viewer.restore(this._plugin_config); + this.viewer.restore({plugin_config: this._plugin_config}); } } @@ -371,10 +383,6 @@ export class PerspectiveWidget extends Widget { } } - toggleConfig(): void { - this._viewer.toggleConfig(); - } - static createNode(node: HTMLDivElement): HTMLPerspectiveViewerElement { node.classList.add("p-Widget"); node.classList.add(PSP_CONTAINER_CLASS); @@ -405,13 +413,14 @@ export class PerspectiveWidget extends Widget { } }); resize_observer.observe(node, {attributes: true}); + viewer.toggleConfig(); } return viewer; } private _viewer: HTMLPerspectiveViewerElement; - private _plugin_config: PerspectiveViewerOptions; + private _plugin_config: object; private _client: boolean; private _server: boolean; private _dark: boolean; diff --git a/packages/perspective-jupyterlab/src/ts/renderer.ts b/packages/perspective-jupyterlab/src/ts/renderer.ts index cbaa234f19..1ba946fc3f 100644 --- a/packages/perspective-jupyterlab/src/ts/renderer.ts +++ b/packages/perspective-jupyterlab/src/ts/renderer.ts @@ -103,6 +103,7 @@ export class PerspectiveDocumentWidget extends DocumentWidget this.context.model.fromString(resultAsB64); this.context.save(); } else if (this._type === "json") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any = await view.to_json(); this.context.model.fromJSON(result); this.context.save(); diff --git a/packages/perspective-jupyterlab/src/ts/view.ts b/packages/perspective-jupyterlab/src/ts/view.ts index 0363166337..6bd6a2a2ad 100644 --- a/packages/perspective-jupyterlab/src/ts/view.ts +++ b/packages/perspective-jupyterlab/src/ts/view.ts @@ -341,6 +341,15 @@ export class PerspectiveView extends DOMWidgetView { } } + /** + * When the View is removed after the widget terminates, clean up the + * client viewer and Web Worker. + */ + remove(): void { + this.pWidget.delete(); + this.client_worker.terminate(); + } + /** * When traitlets are updated in python, update the corresponding value on * the front-end viewer. `client` and `server` are not included, as they diff --git a/packages/perspective-jupyterlab/test/js/resize.spec.js b/packages/perspective-jupyterlab/test/js/resize.spec.js index 436e9c2749..c47cbbffef 100644 --- a/packages/perspective-jupyterlab/test/js/resize.spec.js +++ b/packages/perspective-jupyterlab/test/js/resize.spec.js @@ -15,12 +15,10 @@ utils.with_server({}, () => { "resize.html", () => { test.capture( - "Basic widget functions", + "Config should show by default", async page => { await page.waitForSelector("perspective-viewer:not([updating])"); - await page.evaluate(async () => await document.querySelector("perspective-viewer").toggleConfig()); await page.waitForSelector("perspective-viewer[settings]"); - await page.waitForSelector("perspective-viewer:not([updating])"); }, {} ); diff --git a/packages/perspective-jupyterlab/test/jupyter/widget.spec.js b/packages/perspective-jupyterlab/test/jupyter/widget.spec.js index a14704cceb..b115eeeb55 100644 --- a/packages/perspective-jupyterlab/test/jupyter/widget.spec.js +++ b/packages/perspective-jupyterlab/test/jupyter/widget.spec.js @@ -59,7 +59,7 @@ utils.with_jupyterlab(process.env.__JUPYTERLAB_PORT__, () => { return tbl.querySelector("thead tr").childElementCount; }); - expect(num_columns).toEqual(14); + expect(num_columns).toBeGreaterThanOrEqual(8); const num_rows = await viewer.evaluate(async viewer => { const tbl = viewer.querySelector("regular-table"); @@ -129,7 +129,7 @@ utils.with_jupyterlab(process.env.__JUPYTERLAB_PORT__, () => { return tbl.querySelectorAll("tbody tr").length; }); - expect(num_columns).toEqual(13); + expect(num_columns).toEqual(12); expect(num_rows).toEqual(5); }); @@ -178,6 +178,40 @@ utils.with_jupyterlab(process.env.__JUPYTERLAB_PORT__, () => { expect(plugin).toEqual("Y Line"); } ); + + test.jupyterlab( + "Sets layout", + [["table = perspective.Table(arrow_data)\n", "w = perspective.PerspectiveWidget(table)"], ["w"], ["w.columns = ['f64']\n", "w.layout.width = '200px'\nw.layout.height = '100px'"]], + async page => { + await execute_all_cells(page); + await page.waitForTimeout(5000); + const container = await page.waitForSelector(".jp-OutputArea-output .PSPContainer", {visible: true}); + await page.waitForTimeout(2500); + const dimensions = await container.evaluate(async container => { + const computed = getComputedStyle(container); + return [computed.width, computed.height]; + }); + expect(dimensions[0]).toEqual("200px"); + expect(dimensions[1]).toEqual("100px"); + } + ); + + test.jupyterlab( + "Sets plugin config", + [["table = perspective.Table(arrow_data)\n", "w = perspective.PerspectiveWidget(table)"], ["w"], ["w.columns = ['f64']\n", "w.plugin_config = {'f64': {'fixed': 10}}"]], + async page => { + const viewer = await default_body(page); + const plugin_config = await viewer.evaluate(async viewer => { + const config = await viewer.save(); + return config.plugin_config; + }); + expect(plugin_config).toEqual({ + f64: { + fixed: 10 + } + }); + } + ); }, {name: "Simple", root: path.join(__dirname, "..", "..")} ); diff --git a/packages/perspective-jupyterlab/test/results/linux.docker.json b/packages/perspective-jupyterlab/test/results/linux.docker.json index 5ab8bc4209..286482a8ba 100644 --- a/packages/perspective-jupyterlab/test/results/linux.docker.json +++ b/packages/perspective-jupyterlab/test/results/linux.docker.json @@ -1,6 +1,6 @@ { - "resize_Basic_widget_functions": "7bb46cb439e6d4fbfdde6965d8fc2383", - "resize_Resize_the_container_causes_the_widget_to_resize": "91f8fa9f8ec317f0c656c6115807adb4", - "resize_row_pivots_traitlet_works": "686982074776272a55a4b3b63503125f", - "__GIT_COMMIT__": "455eea35adcda4ab55f4c778a9f4b16c1a019f46" + "resize_Resize_the_container_causes_the_widget_to_resize": "4ea2f9616992d3273a579532e1b9f25b", + "resize_row_pivots_traitlet_works": "2bda67e1502d83e55145fab4a4edacf9", + "resize_Config_should_show_by_default": "7bb46cb439e6d4fbfdde6965d8fc2383", + "__GIT_COMMIT__": "704d4ee796275b823d76579150353dfc9731b42a" } \ No newline at end of file diff --git a/packages/perspective-viewer/index.d.ts b/packages/perspective-viewer/index.d.ts index 354deb0dfe..812534fc20 100644 --- a/packages/perspective-viewer/index.d.ts +++ b/packages/perspective-viewer/index.d.ts @@ -12,15 +12,18 @@ import {Table, View} from "@finos/perspective"; export interface HTMLPerspectiveViewerElement extends PerspectiveViewerOptions, HTMLElement { load(data: Table): void; - notifyResize(): void; delete(): Promise; flush(): Promise; getEditPort(): Promise; - toggleConfig(): void; - save(): PerspectiveViewerOptions; + toggleConfig(): Promise; + download(flat: boolean): Promise; + copy(flat: boolean): Promise; + save(): Promise; + restore(x: PerspectiveViewerOptions): Promise; reset(): void; - restore(x: any): Promise; + notifyResize(): void; restyleElement(): void; + readonly table?: Table; readonly view?: View; } @@ -33,15 +36,16 @@ export type Pivots = string[]; export type Columns = string[]; export interface PerspectiveViewerOptions { - aggregates?: Aggregates; - editable?: boolean; plugin?: string; columns?: Columns; - expressions?: Expressions; "row-pivots"?: Pivots; "column-pivots"?: Pivots; + aggregates?: Aggregates; filters?: Filters; sort?: Sort; + expressions?: Expressions; + plugin_config?: object; + editable?: boolean; selectable?: boolean; } diff --git a/packages/perspective/index.d.ts b/packages/perspective/index.d.ts index 6698605bc9..37a55b8a19 100644 --- a/packages/perspective/index.d.ts +++ b/packages/perspective/index.d.ts @@ -157,7 +157,7 @@ declare module "@finos/perspective" { PERSPECTIVE_READY = "perspective-ready" } - export type PerspectiveWorker = { + export type PerspectiveWorker = Worker & { table(data: TableData | View, options?: TableOptions): Promise; }; diff --git a/packages/perspective/src/js/perspective.node.js b/packages/perspective/src/js/perspective.node.js index 3ef812e28e..43fef14b8c 100644 --- a/packages/perspective/src/js/perspective.node.js +++ b/packages/perspective/src/js/perspective.node.js @@ -60,7 +60,8 @@ const DEFAULT_ASSETS = [ "@finos/perspective-viewer/dist/umd", "@finos/perspective-viewer-datagrid/dist/umd", "@finos/perspective-viewer-d3fc/dist/umd", - "@finos/perspective-workspace/dist/umd" + "@finos/perspective-workspace/dist/umd", + "@finos/perspective-jupyterlab/dist/umd" ]; const CONTENT_TYPES = { @@ -92,10 +93,18 @@ function perspective_assets(assets, host_psp) { response.setHeader("Access-Control-Request-Method", "*"); response.setHeader("Access-Control-Allow-Methods", "OPTIONS,GET"); response.setHeader("Access-Control-Allow-Headers", "*"); + let url = request.url.split(/[\?\#]/)[0]; + + // Strip version numbers from the URL so we can handle CDN-like requests + // of the form @[^~]major.minor.patch when testing local versions of + // Perspective against Voila. + url = url.replace(/@[\^~]?\d+.[\d\*]*.[\d\*]*/, ""); + if (url === "/") { url = "/index.html"; } + let extname = path.extname(url); let contentType = CONTENT_TYPES[extname] || "text/html"; try { diff --git a/python/perspective/perspective/core/plugin.py b/python/perspective/perspective/core/plugin.py index bf92ab456b..6391aba983 100644 --- a/python/perspective/perspective/core/plugin.py +++ b/python/perspective/perspective/core/plugin.py @@ -22,13 +22,13 @@ class Plugin(Enum): YBAR = "Y Bar" XBAR = "X Bar" YLINE = "Y Line" - YAREA = "y_area" - YSCATTER = "y_scatter" - XYLINE = "xy_line" - XYSCATTER = "xy_scatter" - TREEMAP = "treemap" - SUNBURST = "sunburst" - HEATMAP = "heatmap" + YAREA = "Y Area" + YSCATTER = "Y Scatter" + XYLINE = "X/Y Line" + XYSCATTER = "X/Y Scatter" + TREEMAP = "Treemap" + SUNBURST = "Sunburst" + HEATMAP = "Heatmap" YBAR_D3 = "Y Bar" XBAR_D3 = "X Bar" diff --git a/python/perspective/perspective/tests/viewer/test_viewer.py b/python/perspective/perspective/tests/viewer/test_viewer.py index 3d0363f40d..0bc3b47d1d 100644 --- a/python/perspective/perspective/tests/viewer/test_viewer.py +++ b/python/perspective/perspective/tests/viewer/test_viewer.py @@ -240,3 +240,19 @@ def test_save_restore(self): assert viewer.plugin == "X Bar" assert viewer.editable is True assert viewer.expressions == ['"a" * 2'] + + def test_save_restore_plugin_config(self): + viewer = PerspectiveViewer(plugin="datagrid", plugin_config={"a": {"fixed": 4}}) + config = viewer.save() + + assert config["plugin_config"] == { + "a": { + "fixed": 4 + } + } + + viewer.reset() + assert viewer.plugin_config == {} + + viewer.restore(**config) + assert viewer.plugin_config == config["plugin_config"] diff --git a/python/perspective/perspective/viewer/viewer.py b/python/perspective/perspective/viewer/viewer.py index 12b4f89448..95ae93527b 100644 --- a/python/perspective/perspective/viewer/viewer.py +++ b/python/perspective/perspective/viewer/viewer.py @@ -52,6 +52,7 @@ class PerspectiveViewer(PerspectiveTraitlets, object): "expressions", "plugin", "editable", + "plugin_config", ) def __init__( @@ -93,7 +94,8 @@ def __init__( expressions which are applied to the view. plugin (:obj:`str`/:obj:`perspective.Plugin`): Which plugin to select by default. - plugin_config (:obj:`dict`): Custom config for all plugins by name. + plugin_config (:obj:`dict`): A configuration for the plugin, i.e. + the datagrid plugin or a chart plugin. dark (:obj:`bool`): Whether to invert the color theme. editable (:obj:`bool`): Whether to allow editability using the grid. @@ -265,6 +267,7 @@ def reset(self): self.aggregates = {} self.columns = [] self.plugin = "datagrid" + self.plugin_config = {} self.editable = False def delete(self, delete_table=True): diff --git a/python/perspective/perspective/widget/widget.py b/python/perspective/perspective/widget/widget.py index c68bd12754..15a966dc30 100644 --- a/python/perspective/perspective/widget/widget.py +++ b/python/perspective/perspective/widget/widget.py @@ -14,7 +14,7 @@ from datetime import date, datetime from functools import partial -from ipywidgets import Widget +from ipywidgets import DOMWidget from traitlets import observe, Unicode from ..core.data import deconstruct_pandas @@ -172,7 +172,7 @@ def to_dict(self): return {"id": self.id, "type": self.type, "data": self.data} -class PerspectiveWidget(Widget, PerspectiveViewer): +class PerspectiveWidget(DOMWidget, PerspectiveViewer): """:class`~perspective.PerspectiveWidget` allows for Perspective to be used in the form of a JupyterLab IPython widget.