diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..5c99ba78a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +**/*.d.ts +tests diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..5f3bf9662 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,63 @@ +module.exports = { + env: { + browser: true, + es6: true, + commonjs: true, + node: true, + }, + root: true, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier/@typescript-eslint", + "plugin:react/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.eslint.json", + }, + plugins: ["@typescript-eslint"], + rules: { + "@typescript-eslint/no-floating-promises": ["error", { ignoreVoid: true }], + "@typescript-eslint/naming-convention": [ + "error", + { + selector: "interface", + format: ["PascalCase"], + custom: { + regex: "^I[A-Z]", + match: true, + }, + }, + ], + "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/ban-ts-comment": ["warn", { "ts-ignore": true }], + "@typescript-eslint/ban-types": "warn", + "@typescript-eslint/no-non-null-asserted-optional-chain": "warn", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/triple-slash-reference": "warn", + "@typescript-eslint/no-inferrable-types": "off", + "no-inner-declarations": "off", + "no-prototype-builtins": "off", + "no-control-regex": "warn", + "no-undef": "warn", + "no-case-declarations": "warn", + "no-useless-escape": "off", + "prefer-const": "off", + "react/prop-types": "warn", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/.gitignore b/.gitignore index 098abc0c1..89d39e704 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ __pycache__ htmlcov id_*sa .vscode + +node_modules +lib + +ipyparallel/labextension +tsconfig.tsbuildinfo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 393eb1f2f..2da750d75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,3 +22,17 @@ repos: - id: check-case-conflict - id: check-executables-have-shebangs - id: requirements-txt-fixer + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v7.14.0 + hooks: + - id: eslint + files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx + types: [file] + additional_dependencies: + - "@typescript-eslint/eslint-plugin@2.27.0" + - "@typescript-eslint/parser@2.27.0" + - eslint@^6.0.0 + - eslint-config-prettier@6.10.1 + - eslint-plugin-prettier@3.1.4 + - eslint-plugin-react@7.21.5 + - typescript@4.1.3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..1f1fe99f8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +docs/build +htmlcov +ipyparallel/labextension +**/node_modules +**/lib +**/package.json diff --git a/install.json b/install.json new file mode 100644 index 000000000..4a8338981 --- /dev/null +++ b/install.json @@ -0,0 +1,5 @@ +{ + "packageManager": "python", + "packageName": "ipyparallel", + "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package ipyparallel" +} diff --git a/ipyparallel/__init__.py b/ipyparallel/__init__.py index 316f9a7fc..d191a55eb 100644 --- a/ipyparallel/__init__.py +++ b/ipyparallel/__init__.py @@ -74,3 +74,12 @@ def _jupyter_nbextension_paths(): 'require': 'ipyparallel/main', } ] + + +def _jupyter_labextension_paths(): + return [ + { + "src": "labextension", + "dest": "ipyparallel-labextension", + } + ] diff --git a/jupyter-config/jupyter_notebook_config.d/ipyparallel.json b/jupyter-config/jupyter_notebook_config.d/ipyparallel.json new file mode 100644 index 000000000..a79ebfd6e --- /dev/null +++ b/jupyter-config/jupyter_notebook_config.d/ipyparallel.json @@ -0,0 +1,7 @@ +{ + "NotebookApp": { + "nbserver_extensions": { + "ipyparallel.nbextension": true + } + } +} diff --git a/jupyter-config/jupyter_server_config.d/ipyparallel.json b/jupyter-config/jupyter_server_config.d/ipyparallel.json new file mode 100644 index 000000000..ecee31262 --- /dev/null +++ b/jupyter-config/jupyter_server_config.d/ipyparallel.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "ipyparallel.nbextension": true + } + } +} diff --git a/lab/schema/plugin.json b/lab/schema/plugin.json new file mode 100644 index 000000000..61388f763 --- /dev/null +++ b/lab/schema/plugin.json @@ -0,0 +1,15 @@ +{ + "jupyter.lab.setting-icon-class": "ipp-Logo", + "jupyter.lab.setting-icon-label": "IPython Parallel", + "title": "IPython Parallel", + "description": "Settings for the IPython Parallel plugin.", + "properties": { + "autoStartClient": { + "type": "boolean", + "title": "Auto-Start Client", + "description": "If set to true, every notebook and console will automatically have an IPython Parallel Cluster and Client for the active cluster injected into the kernel under the names 'cluster' and 'rc'", + "default": false + } + }, + "type": "object" +} diff --git a/lab/src/clusters.tsx b/lab/src/clusters.tsx new file mode 100644 index 000000000..2b6ad0b72 --- /dev/null +++ b/lab/src/clusters.tsx @@ -0,0 +1,788 @@ +import { + showErrorMessage, + Toolbar, + ToolbarButton, + CommandToolbarButton, +} from "@jupyterlab/apputils"; + +import { IChangedArgs } from "@jupyterlab/coreutils"; + +import * as nbformat from "@jupyterlab/nbformat"; + +import { ServerConnection } from "@jupyterlab/services"; + +import { refreshIcon } from "@jupyterlab/ui-components"; + +import { ArrayExt } from "@lumino/algorithm"; + +import { JSONObject, JSONExt, MimeData } from "@lumino/coreutils"; + +import { ElementExt } from "@lumino/domutils"; + +import { Drag } from "@lumino/dragdrop"; + +import { Message } from "@lumino/messaging"; + +import { Poll } from "@lumino/polling"; + +import { ISignal, Signal } from "@lumino/signaling"; + +import { Widget, PanelLayout } from "@lumino/widgets"; + +import { showScalingDialog } from "./scaling"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { CommandRegistry } from "@lumino/commands"; + +/** + * A refresh interval (in ms) for polling the backend cluster manager. + */ +const REFRESH_INTERVAL = 5000; + +/** + * The threshold in pixels to start a drag event. + */ +const DRAG_THRESHOLD = 5; + +/** + * The mimetype used for Jupyter cell data. + */ +const JUPYTER_CELL_MIME = "application/vnd.jupyter.cells"; + +/** + * A widget for IPython cluster management. + */ +export class ClusterManager extends Widget { + /** + * Create a new cluster manager. + */ + + constructor(options: ClusterManager.IOptions) { + super(); + this.addClass("ipp-ClusterManager"); + + this._serverSettings = ServerConnection.makeSettings(); + this._injectClientCodeForCluster = options.injectClientCodeForCluster; + this._getClientCodeForCluster = options.getClientCodeForCluster; + this._registry = options.registry; + this._launchClusterId = options.launchClusterId; + + // A function to set the active cluster. + this._setActiveById = (id: string) => { + const cluster = this._clusters.find((c) => c.id === id); + if (!cluster) { + return; + } + + const old = this._activeCluster; + if (old && old.id === cluster.id) { + return; + } + this._activeCluster = cluster; + this._activeClusterChanged.emit({ + name: "cluster", + oldValue: old, + newValue: cluster, + }); + this.update(); + }; + + const layout = (this.layout = new PanelLayout()); + + this._clusterListing = new Widget(); + this._clusterListing.addClass("ipp-ClusterListing"); + + // Create the toolbar. + const toolbar = new Toolbar(); + + // Make a label widget for the toolbar. + const toolbarLabel = new Widget(); + toolbarLabel.node.textContent = "CLUSTERS"; + toolbarLabel.addClass("ipp-ClusterManager-label"); + toolbar.addItem("label", toolbarLabel); + + // Make a refresh button for the toolbar. + toolbar.addItem( + "refresh", + new ToolbarButton({ + icon: refreshIcon, + onClick: async () => { + return this._updateClusterList(); + }, + tooltip: "Refresh Cluster List", + }) + ); + + // Make a new cluster button for the toolbar. + toolbar.addItem( + this._launchClusterId, + new CommandToolbarButton({ + commands: this._registry, + id: this._launchClusterId, + }) + ); + + layout.addWidget(toolbar); + layout.addWidget(this._clusterListing); + + // Do an initial refresh of the cluster list. + void this._updateClusterList(); + // Also refresh periodically. + this._poll = new Poll({ + factory: async () => { + await this._updateClusterList(); + }, + frequency: { interval: REFRESH_INTERVAL, backoff: true, max: 60 * 1000 }, + standby: "when-hidden", + }); + } + + /** + * The currently selected cluster, or undefined if there is none. + */ + get activeCluster(): IClusterModel | undefined { + return this._activeCluster; + } + + /** + * Set an active cluster by id. + */ + setActiveCluster(id: string): void { + this._setActiveById(id); + } + + /** + * A signal that is emitted when an active cluster changes. + */ + get activeClusterChanged(): ISignal< + this, + IChangedArgs + > { + return this._activeClusterChanged; + } + + /** + * Whether the cluster manager is ready to launch a cluster + */ + get isReady(): boolean { + return this._isReady; + } + + /** + * Get the current clusters known to the manager. + */ + get clusters(): IClusterModel[] { + return this._clusters; + } + + /** + * Refresh the current list of clusters. + */ + async refresh(): Promise { + await this._updateClusterList(); + } + + /** + * Start a new cluster. + */ + async start(): Promise { + const cluster = await this._launchCluster(); + return cluster; + } + + /** + * Stop a cluster by ID. + */ + async stop(id: string): Promise { + const cluster = this._clusters.find((c) => c.id === id); + if (!cluster) { + throw Error(`Cannot find cluster ${id}`); + } + await this._stopById(id); + } + + /** + * Scale a cluster by ID. + */ + async scale(id: string): Promise { + const cluster = this._clusters.find((c) => c.id === id); + if (!cluster) { + throw Error(`Cannot find cluster ${id}`); + } + const newCluster = await this._scaleById(id); + return newCluster; + } + + /** + * Dispose of the cluster manager. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._poll.dispose(); + super.dispose(); + } + + /** + * Handle an update request. + */ + protected onUpdateRequest(msg: Message): void { + // Don't bother if the sidebar is not visible + if (!this.isVisible) { + return; + } + + ReactDOM.render( + { + return this._scaleById(id); + }} + stopById={(id: string) => { + return this._stopById(id); + }} + setActiveById={this._setActiveById} + injectClientCodeForCluster={this._injectClientCodeForCluster} + />, + this._clusterListing.node + ); + } + + /** + * Rerender after showing. + */ + protected onAfterShow(msg: Message): void { + this.update(); + } + + /** + * Handle `after-attach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + let node = this._clusterListing.node; + node.addEventListener("p-dragenter", this); + node.addEventListener("p-dragleave", this); + node.addEventListener("p-dragover", this); + node.addEventListener("mousedown", this); + } + + /** + * Handle `before-detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + let node = this._clusterListing.node; + node.removeEventListener("p-dragenter", this); + node.removeEventListener("p-dragleave", this); + node.removeEventListener("p-dragover", this); + node.removeEventListener("mousedown", this); + document.removeEventListener("mouseup", this, true); + document.removeEventListener("mousemove", this, true); + } + + /** + * Handle the DOM events for the directory listing. + * + * @param event - The DOM event sent to the widget. + * + * #### Notes + * This method implements the DOM `EventListener` interface and is + * called in response to events on the panel's DOM node. It should + * not be called directly by user code. + */ + handleEvent(event: Event): void { + switch (event.type) { + case "mousedown": + this._evtMouseDown(event as MouseEvent); + break; + case "mouseup": + this._evtMouseUp(event as MouseEvent); + break; + case "mousemove": + this._evtMouseMove(event as MouseEvent); + break; + default: + break; + } + } + + /** + * Handle `mousedown` events for the widget. + */ + private _evtMouseDown(event: MouseEvent): void { + const { button, shiftKey } = event; + + // We only handle main or secondary button actions. + if (!(button === 0 || button === 2)) { + return; + } + // Shift right-click gives the browser default behavior. + if (shiftKey && button === 2) { + return; + } + + // Find the target cluster. + const clusterIndex = this._findCluster(event); + if (clusterIndex === -1) { + return; + } + // Prepare for a drag start + this._dragData = { + pressX: event.clientX, + pressY: event.clientY, + index: clusterIndex, + }; + + // Enter possible drag mode + document.addEventListener("mouseup", this, true); + document.addEventListener("mousemove", this, true); + event.preventDefault(); + } + + /** + * Handle the `'mouseup'` event on the document. + */ + private _evtMouseUp(event: MouseEvent): void { + // Remove the event listeners we put on the document + if (event.button !== 0 || !this._drag) { + document.removeEventListener("mousemove", this, true); + document.removeEventListener("mouseup", this, true); + } + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Handle the `'mousemove'` event for the widget. + */ + private _evtMouseMove(event: MouseEvent): void { + let data = this._dragData; + if (!data) { + return; + } + // Check for a drag initialization. + let dx = Math.abs(event.clientX - data.pressX); + let dy = Math.abs(event.clientY - data.pressY); + if (dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD) { + event.preventDefault(); + event.stopPropagation(); + void this._startDrag(data.index, event.clientX, event.clientY); + } + } + + /** + * Start a drag event. + */ + private async _startDrag( + index: number, + clientX: number, + clientY: number + ): Promise { + // Create the drag image. + const model = this._clusters[index]; + const listingItem = this._clusterListing.node.querySelector( + `li.ipp-ClusterListingItem[data-cluster-id="${model.id}"]` + ) as HTMLElement; + const dragImage = Private.createDragImage(listingItem); + + // Set up the drag event. + this._drag = new Drag({ + mimeData: new MimeData(), + dragImage, + supportedActions: "copy", + proposedAction: "copy", + source: this, + }); + + // Add mimeData for plain text so that normal editors can + // receive the data. + const textData = this._getClientCodeForCluster(model); + this._drag.mimeData.setData("text/plain", textData); + // Add cell data for notebook drops. + const cellData: nbformat.ICodeCell[] = [ + { + cell_type: "code", + source: textData, + outputs: [], + execution_count: null, + metadata: {}, + }, + ]; + this._drag.mimeData.setData(JUPYTER_CELL_MIME, cellData); + + // Remove mousemove and mouseup listeners and start the drag. + document.removeEventListener("mousemove", this, true); + document.removeEventListener("mouseup", this, true); + return this._drag.start(clientX, clientY).then((action) => { + if (this.isDisposed) { + return; + } + this._drag = null; + this._dragData = null; + }); + } + + /** + * Launch a new cluster on the server. + */ + private async _launchCluster(): Promise { + this._isReady = false; + this._registry.notifyCommandChanged(this._launchClusterId); + const response = await ServerConnection.makeRequest( + `${this._serverSettings.baseUrl}clusters/${this._launchClusterId}`, + { method: "POST" }, + this._serverSettings + ); + if (response.status !== 200) { + const err = await response.json(); + void showErrorMessage("Cluster Start Error", err); + this._isReady = true; + this._registry.notifyCommandChanged(this._launchClusterId); + throw err; + } + const model = (await response.json()) as IClusterModel; + await this._updateClusterList(); + this._isReady = true; + this._registry.notifyCommandChanged(this._launchClusterId); + return model; + } + + /** + * Refresh the list of clusters on the server. + */ + private async _updateClusterList(): Promise { + const response = await ServerConnection.makeRequest( + `${this._serverSettings.baseUrl}clusters`, + {}, + this._serverSettings + ); + if (response.status !== 200) { + const msg = + "Failed to list clusters: might the server extension not be installed/enabled?"; + const err = new Error(msg); + if (!this._serverErrorShown) { + void showErrorMessage("IPP Extension Server Error", err); + this._serverErrorShown = true; + } + throw err; + } + const data = (await response.json()) as IClusterModel[]; + this._clusters = data; + + // Check to see if the active cluster still exits. + // If it doesn't, or if there is no active cluster, + // select the first one. + const active = this._clusters.find( + (c) => c.id === (this._activeCluster && this._activeCluster.id) + ); + if (!active) { + const id = (this._clusters[0] && this._clusters[0].id) || ""; + this._setActiveById(id); + } + this.update(); + } + + /** + * Stop a cluster by its id. + */ + private async _stopById(id: string): Promise { + const response = await ServerConnection.makeRequest( + `${this._serverSettings.baseUrl}clusters/${id}`, + { method: "DELETE" }, + this._serverSettings + ); + if (response.status !== 204) { + const err = await response.json(); + void showErrorMessage("Failed to close cluster", err); + throw err; + } + await this._updateClusterList(); + } + + /** + * Scale a cluster by its id. + */ + private async _scaleById(id: string): Promise { + const cluster = this._clusters.find((c) => c.id === id); + if (!cluster) { + throw Error(`Failed to find cluster ${id} to scale`); + } + const update = await showScalingDialog(cluster); + if (JSONExt.deepEqual(update, cluster)) { + // If the user canceled, or the model is identical don't try to update. + return Promise.resolve(cluster); + } + + const response = await ServerConnection.makeRequest( + `${this._serverSettings.baseUrl}clusters/${id}`, + { + method: "PATCH", + body: JSON.stringify(update), + }, + this._serverSettings + ); + if (response.status !== 200) { + const err = await response.json(); + void showErrorMessage("Failed to scale cluster", err); + throw err; + } + const model = (await response.json()) as IClusterModel; + await this._updateClusterList(); + return model; + } + + private _findCluster(event: MouseEvent): number { + const nodes = Array.from( + this.node.querySelectorAll("li.ipp-ClusterListingItem") + ); + return ArrayExt.findFirstIndex(nodes, (node) => { + return ElementExt.hitTest(node, event.clientX, event.clientY); + }); + } + + private _drag: Drag | null; + private _dragData: { + pressX: number; + pressY: number; + index: number; + } | null = null; + private _clusterListing: Widget; + private _clusters: IClusterModel[] = []; + private _activeCluster: IClusterModel | undefined; + private _setActiveById: (id: string) => void; + private _injectClientCodeForCluster: (model: IClusterModel) => void; + private _getClientCodeForCluster: (model: IClusterModel) => string; + private _poll: Poll; + private _serverSettings: ServerConnection.ISettings; + private _activeClusterChanged = new Signal< + this, + IChangedArgs + >(this); + private _serverErrorShown = false; + private _isReady = true; + private _registry: CommandRegistry; + private _launchClusterId: string; +} + +/** + * A namespace for DasClusterManager statics. + */ +export namespace ClusterManager { + /** + * Options for the constructor. + */ + export interface IOptions { + /** + * Registry of all commands + */ + registry: CommandRegistry; + + /** + * The launchCluster command ID. + */ + launchClusterId: string; + + /** + * A callback to inject client connection cdoe. + */ + injectClientCodeForCluster: (model: IClusterModel) => void; + + /** + * A callback to get client code for a cluster. + */ + getClientCodeForCluster: (model: IClusterModel) => string; + } +} + +/** + * A React component for a launcher button listing. + */ +function ClusterListing(props: IClusterListingProps) { + let listing = props.clusters.map((cluster) => { + return ( + props.scaleById(cluster.id)} + stop={() => props.stopById(cluster.id)} + setActive={() => props.setActiveById(cluster.id)} + injectClientCode={() => props.injectClientCodeForCluster(cluster)} + /> + ); + }); + + // Return the JSX component. + return ( +
+
    {listing}
+
+ ); +} + +/** + * Props for the cluster listing component. + */ +export interface IClusterListingProps { + /** + * A list of items to render. + */ + clusters: IClusterModel[]; + + /** + * The id of the active cluster. + */ + activeClusterId: string; + + /** + * A function for stopping a cluster by ID. + */ + stopById: (id: string) => Promise; + + /** + * Scale a cluster by id. + */ + scaleById: (id: string) => Promise; + + /** + * A callback to set the active cluster by id. + */ + setActiveById: (id: string) => void; + + /** + * A callback to inject client code for a cluster. + */ + injectClientCodeForCluster: (model: IClusterModel) => void; +} + +/** + * A TSX functional component for rendering a single running cluster. + */ +function ClusterListingItem(props: IClusterListingItemProps) { + const { cluster, isActive, setActive, scale, stop, injectClientCode } = props; + let itemClass = "ipp-ClusterListingItem"; + itemClass = isActive ? `${itemClass} jp-mod-active` : itemClass; + + return ( +
  • { + setActive(); + evt.stopPropagation(); + }} + > +
    {cluster.name}
    +
    + Number of engines: {cluster.engines} +
    +
    + + +
    +
  • + ); +} + +/** + * Props for the cluster listing component. + */ +export interface IClusterListingItemProps { + /** + * A cluster model to render. + */ + cluster: IClusterModel; + + /** + * Whether the cluster is currently active. + */ + isActive: boolean; + + /** + * A function for scaling the cluster. + */ + scale: () => Promise; + + /** + * A function for stopping the cluster. + */ + stop: () => Promise; + + /** + * A callback function to set the active cluster. + */ + setActive: () => void; + + /** + * A callback to inject client code into an editor. + */ + injectClientCode: () => void; +} + +/** + * An interface for a JSON-serializable representation of a cluster. + */ +export interface IClusterModel extends JSONObject { + /** + * A unique string ID for the cluster. + */ + id: string; + + /** + * A display name for the cluster. + */ + name: string; + + /** + * Client connection file path + */ + client_connection_file: string; + + /** + * Total number of engines used by the cluster. + */ + engines: number; +} + +/** + * A namespace for module-private functionality. + */ +namespace Private { + /** + * Create a drag image for an HTML node. + */ + export function createDragImage(node: HTMLElement): HTMLElement { + const image = node.cloneNode(true) as HTMLElement; + image.classList.add("ipp-ClusterListingItem-drag"); + return image; + } +} diff --git a/lab/src/index.ts b/lab/src/index.ts new file mode 100644 index 000000000..17168d42b --- /dev/null +++ b/lab/src/index.ts @@ -0,0 +1,524 @@ +// IPython Parallel Lab extension derived from dask-labextension@f6141455d770ed7de564fc4aa403b9964cd4e617 +// License: BSD-3-Clause + +import { + ILabShell, + ILayoutRestorer, + JupyterFrontEnd, + JupyterFrontEndPlugin, +} from "@jupyterlab/application"; + +import { + ICommandPalette, + ISessionContext, + IWidgetTracker, +} from "@jupyterlab/apputils"; + +import { CodeEditor } from "@jupyterlab/codeeditor"; + +import { ConsolePanel, IConsoleTracker } from "@jupyterlab/console"; + +import { IMainMenu } from "@jupyterlab/mainmenu"; + +import { ISettingRegistry } from "@jupyterlab/settingregistry"; + +import { IStateDB } from "@jupyterlab/statedb"; + +import { + INotebookTracker, + NotebookActions, + NotebookPanel, +} from "@jupyterlab/notebook"; + +import { Kernel, KernelMessage, Session } from "@jupyterlab/services"; + +import { LabIcon } from "@jupyterlab/ui-components"; + +import { Signal } from "@lumino/signaling"; + +import { IClusterModel, ClusterManager } from "./clusters"; + +import { Sidebar } from "./sidebar"; + +import "../style/index.css"; +import logoSvgStr from "../style/logo.svg"; + +namespace CommandIDs { + /** + * Inject client code into the active editor. + */ + export const injectClientCode = "ipyparallel:inject-client-code"; + + /** + * Launch a new cluster. + */ + export const launchCluster = "ipyparallel:launch-cluster"; + + /** + * Shutdown a cluster. + */ + export const stopCluster = "ipyparallel:stop-cluster"; + + /** + * Scale a cluster. + */ + export const scaleCluster = "ipyparallel:scale-cluster"; + + /** + * Toggle the auto-starting of clients. + */ + export const toggleAutoStartClient = "ipyparallel:toggle-auto-start-client"; +} + +const PLUGIN_ID = "ipyparallel-labextension:plugin"; + +/** + * The IPython Parallel extension. + */ +const plugin: JupyterFrontEndPlugin = { + activate, + id: PLUGIN_ID, + requires: [ + ICommandPalette, + IConsoleTracker, + ILabShell, + ILayoutRestorer, + IMainMenu, + INotebookTracker, + ISettingRegistry, + IStateDB, + ], + autoStart: true, +}; + +/** + * Export the plugin as default. + */ +export default plugin; + +/** + * Activate the cluster launcher plugin. + */ +async function activate( + app: JupyterFrontEnd, + commandPalette: ICommandPalette, + consoleTracker: IConsoleTracker, + labShell: ILabShell, + restorer: ILayoutRestorer, + mainMenu: IMainMenu, + notebookTracker: INotebookTracker, + settingRegistry: ISettingRegistry, + state: IStateDB +): Promise { + const id = "ipp-cluster-launcher"; + + const clientCodeInjector = (model: IClusterModel) => { + const editor = Private.getCurrentEditor( + app, + notebookTracker, + consoleTracker + ); + if (!editor) { + return; + } + Private.injectClientCode(model, editor); + }; + + // Create the sidebar panel. + const sidebar = new Sidebar({ + clientCodeInjector, + clientCodeGetter: Private.getClientCode, + registry: app.commands, + launchClusterId: CommandIDs.launchCluster, + }); + sidebar.id = id; + sidebar.title.icon = new LabIcon({ + name: "ipyparallel:logo", + svgstr: logoSvgStr, + }); + + // sidebar.title.iconClass = 'ipp-Logo jp-SideBar-tabIcon'; + sidebar.title.caption = "IPython Parallel"; + + labShell.add(sidebar, "left", { rank: 200 }); + + sidebar.clusterManager.activeClusterChanged.connect(async () => { + const active = sidebar.clusterManager.activeCluster; + return state.save(id, { + cluster: active ? active.id : "", + }); + }); + + // A function to create a new client for a session. + const createClientForSession = async ( + session: Session.ISessionConnection | null + ) => { + if (!session) { + return; + } + const cluster = sidebar.clusterManager.activeCluster; + if (!cluster || !(await Private.shouldUseKernel(session.kernel))) { + return; + } + return Private.createClientForKernel(cluster, session.kernel!); + }; + + type SessionOwner = NotebookPanel | ConsolePanel; + // An array of the trackers to check for active sessions. + const trackers: IWidgetTracker[] = [ + notebookTracker, + consoleTracker, + ]; + + // A function to recreate a client on reconnect. + const injectOnSessionStatusChanged = async ( + sessionContext: ISessionContext + ) => { + if ( + sessionContext.session && + sessionContext.session.kernel && + sessionContext.session.kernel.status === "restarting" + ) { + return createClientForSession(sessionContext.session); + } + }; + + // A function to inject a client when a new session owner is added. + const injectOnWidgetAdded = ( + _: IWidgetTracker, + widget: SessionOwner + ) => { + widget.sessionContext.statusChanged.connect(injectOnSessionStatusChanged); + }; + + // A function to inject a client when the active cluster changes. + const injectOnClusterChanged = () => { + trackers.forEach((tracker) => { + tracker.forEach(async (widget) => { + const session = widget.sessionContext.session; + if (session && (await Private.shouldUseKernel(session.kernel))) { + return createClientForSession(session); + } + }); + }); + }; + + // Whether the cluster clients should aggressively inject themselves + // into the current session. + let autoStartClient: boolean = false; + + // Update the existing trackers and signals in light of a change to the + // settings system. In particular, this reacts to a change in the setting + // for auto-starting cluster client. + const updateTrackers = () => { + // Clear any existing signals related to the auto-starting. + Signal.clearData(injectOnWidgetAdded); + Signal.clearData(injectOnSessionStatusChanged); + Signal.clearData(injectOnClusterChanged); + + if (autoStartClient) { + // When a new console or notebook is created, inject + // a new client into it. + trackers.forEach((tracker) => { + tracker.widgetAdded.connect(injectOnWidgetAdded); + }); + + // When the status of an existing notebook changes, reinject the client. + trackers.forEach((tracker) => { + tracker.forEach(async (widget) => { + await createClientForSession(widget.sessionContext.session); + widget.sessionContext.statusChanged.connect( + injectOnSessionStatusChanged + ); + }); + }); + + // When the active cluster changes, reinject the client. + sidebar.clusterManager.activeClusterChanged.connect( + injectOnClusterChanged + ); + } + }; + + // Fetch the initial state of the settings. + void Promise.all([settingRegistry.load(PLUGIN_ID), state.fetch(id)]).then( + async (res) => { + const settings = res[0]; + if (!settings) { + console.warn("Unable to retrieve ipp-labextension settings"); + return; + } + const state = res[1] as { cluster?: string } | undefined; + const cluster = state ? state.cluster : ""; + + const onSettingsChanged = () => { + // Determine whether to use the auto-starting client. + autoStartClient = settings.get("autoStartClient").composite as boolean; + updateTrackers(); + }; + onSettingsChanged(); + // React to a change in the settings. + settings.changed.connect(onSettingsChanged); + + // If an active cluster is in the state, reset it. + if (cluster) { + await sidebar.clusterManager.refresh(); + sidebar.clusterManager.setActiveCluster(cluster); + } + } + ); + + // Add a command to inject client connection code for a given cluster model. + // This looks for a cluster model in the application context menu, + // and looks for an editor among the currently active notebooks and consoles. + // If either is not found, it bails. + app.commands.addCommand(CommandIDs.injectClientCode, { + label: "Inject IPython Client Connection Code", + execute: () => { + const cluster = Private.clusterFromClick(app, sidebar.clusterManager); + if (!cluster) { + return; + } + clientCodeInjector(cluster); + }, + }); + + // Add a command to launch a new cluster. + app.commands.addCommand(CommandIDs.launchCluster, { + label: (args) => (args["isPalette"] ? "Launch New Cluster" : "NEW"), + execute: () => sidebar.clusterManager.start(), + iconClass: (args) => + args["isPalette"] ? "" : "jp-AddIcon jp-Icon jp-Icon-16", + isEnabled: () => sidebar.clusterManager.isReady, + caption: () => { + if (sidebar.clusterManager.isReady) { + return "Start New Cluster"; + } + return "Cluster starting..."; + }, + }); + + // Add a command to launch a new cluster. + app.commands.addCommand(CommandIDs.stopCluster, { + label: "Shutdown Cluster", + execute: () => { + const cluster = Private.clusterFromClick(app, sidebar.clusterManager); + if (!cluster) { + return; + } + return sidebar.clusterManager.stop(cluster.id); + }, + }); + + // Add a command to launch a new cluster. + app.commands.addCommand(CommandIDs.scaleCluster, { + label: "Scale Cluster…", + execute: () => { + const cluster = Private.clusterFromClick(app, sidebar.clusterManager); + if (!cluster) { + return; + } + return sidebar.clusterManager.scale(cluster.id); + }, + }); + + // Add a command to toggle the auto-starting client code. + app.commands.addCommand(CommandIDs.toggleAutoStartClient, { + label: "Auto-Start IPython Parallel", + isToggled: () => autoStartClient, + execute: async () => { + const value = !autoStartClient; + const key = "autoStartClient"; + return settingRegistry + .set(PLUGIN_ID, key, value) + .catch((reason: Error) => { + console.error( + `Failed to set ${PLUGIN_ID}:${key} - ${reason.message}` + ); + }); + }, + }); + + // Add some commands to the menu and command palette. + mainMenu.settingsMenu.addGroup([ + { command: CommandIDs.toggleAutoStartClient }, + ]); + [CommandIDs.launchCluster, CommandIDs.toggleAutoStartClient].forEach( + (command) => { + commandPalette.addItem({ + category: "IPython Parallel", + command, + args: { isPalette: true }, + }); + } + ); + + // Add a context menu items. + app.contextMenu.addItem({ + command: CommandIDs.injectClientCode, + selector: ".ipp-ClusterListingItem", + rank: 10, + }); + app.contextMenu.addItem({ + command: CommandIDs.stopCluster, + selector: ".ipp-ClusterListingItem", + rank: 3, + }); + app.contextMenu.addItem({ + command: CommandIDs.scaleCluster, + selector: ".ipp-ClusterListingItem", + rank: 2, + }); + app.contextMenu.addItem({ + command: CommandIDs.launchCluster, + selector: ".ipp-ClusterListing-list", + rank: 1, + }); +} + +namespace Private { + /** + * A private counter for ids. + */ + export let id = 0; + + /** + * Whether a kernel should be used. Only evaluates to true + * if it is valid and in python. + */ + export async function shouldUseKernel( + kernel: Kernel.IKernelConnection | null | undefined + ): Promise { + if (!kernel) { + return false; + } + const spec = await kernel.spec; + return !!spec && spec.language.toLowerCase().indexOf("python") !== -1; + } + + /** + * Connect a kernel to a cluster by creating a new Client. + */ + export async function createClientForKernel( + model: IClusterModel, + kernel: Kernel.IKernelConnection + ): Promise { + const code = getClientCode(model); + const content: KernelMessage.IExecuteRequestMsg["content"] = { + store_history: false, + code, + }; + return new Promise((resolve, _) => { + const future = kernel.requestExecute(content); + future.onIOPub = (msg) => { + if (msg.header.msg_type !== "display_data") { + return; + } + resolve(void 0); + }; + }); + } + + /** + * Insert code to connect to a given cluster. + */ + export function injectClientCode( + cluster: IClusterModel, + editor: CodeEditor.IEditor + ): void { + const cursor = editor.getCursorPosition(); + const offset = editor.getOffsetAt(cursor); + const code = getClientCode(cluster); + editor.model.value.insert(offset, code); + } + + /** + * Get code to connect to a given cluster. + */ + export function getClientCode(cluster: IClusterModel): string { + return `import ipyparallel as ipp + +cluster = ipp.Cluster.from_file("${cluster.client_connection_file}") +rc = cluster.connect_client_sync() +rc`; + } + + /** + * Get the currently focused kernel in the application, + * checking both notebooks and consoles. + */ + export function getCurrentKernel( + shell: ILabShell, + notebookTracker: INotebookTracker, + consoleTracker: IConsoleTracker + ): Kernel.IKernelConnection | null | undefined { + // Get a handle on the most relevant kernel, + // whether it is attached to a notebook or a console. + let current = shell.currentWidget; + let kernel: Kernel.IKernelConnection | null | undefined; + if (current && notebookTracker.has(current)) { + kernel = (current as NotebookPanel).sessionContext.session?.kernel; + } else if (current && consoleTracker.has(current)) { + kernel = (current as ConsolePanel).sessionContext.session?.kernel; + } else if (notebookTracker.currentWidget) { + const current = notebookTracker.currentWidget; + kernel = current.sessionContext.session?.kernel; + } else if (consoleTracker.currentWidget) { + const current = consoleTracker.currentWidget; + kernel = current.sessionContext.session?.kernel; + } + return kernel; + } + + /** + * Get the currently focused editor in the application, + * checking both notebooks and consoles. + * In the case of a notebook, it creates a new cell above the currently + * active cell and then returns that. + */ + export function getCurrentEditor( + app: JupyterFrontEnd, + notebookTracker: INotebookTracker, + consoleTracker: IConsoleTracker + ): CodeEditor.IEditor | null | undefined { + // Get a handle on the most relevant kernel, + // whether it is attached to a notebook or a console. + let current = app.shell.currentWidget; + let editor: CodeEditor.IEditor | null | undefined; + if (current && notebookTracker.has(current)) { + NotebookActions.insertAbove((current as NotebookPanel).content); + const cell = (current as NotebookPanel).content.activeCell; + editor = cell && cell.editor; + } else if (current && consoleTracker.has(current)) { + const cell = (current as ConsolePanel).console.promptCell; + editor = cell && cell.editor; + } else if (notebookTracker.currentWidget) { + const current = notebookTracker.currentWidget; + NotebookActions.insertAbove(current.content); + const cell = current.content.activeCell; + editor = cell && cell.editor; + } else if (consoleTracker.currentWidget) { + const current = consoleTracker.currentWidget; + const cell = current.console.promptCell; + editor = cell && cell.editor; + } + return editor; + } + + /** + * Get a cluster model based on the application context menu click node. + */ + export function clusterFromClick( + app: JupyterFrontEnd, + manager: ClusterManager + ): IClusterModel | undefined { + const test = (node: HTMLElement) => !!node.dataset.clusterId; + const node = app.contextMenuHitTest(test); + if (!node) { + return undefined; + } + const id = node.dataset.clusterId; + + return manager.clusters.find((cluster) => cluster.id === id); + } +} diff --git a/lab/src/scaling.tsx b/lab/src/scaling.tsx new file mode 100644 index 000000000..f9675bfd1 --- /dev/null +++ b/lab/src/scaling.tsx @@ -0,0 +1,136 @@ +import { Dialog, showDialog } from "@jupyterlab/apputils"; + +import { IClusterModel } from "./clusters"; +import * as React from "react"; + +/** + * A namespace for ClusterScaling statics. + */ +namespace ClusterScaling { + /** + * The props for the ClusterScaling component. + */ + export interface IProps { + /** + * The initial cluster model shown in the scaling. + */ + initialModel: IClusterModel; + + /** + * A callback that allows the component to write state to an + * external object. + */ + stateEscapeHatch: (model: IClusterModel) => void; + } + + /** + * The state for the ClusterScaling component. + */ + export interface IState { + /** + * The proposed cluster model shown in the scaling. + */ + model: IClusterModel; + } +} + +/** + * A component for an HTML form that allows the user + * to select scaling parameters. + */ +export class ClusterScaling extends React.Component< + ClusterScaling.IProps, + ClusterScaling.IState +> { + /** + * Construct a new ClusterScaling component. + */ + constructor(props: ClusterScaling.IProps) { + super(props); + let model: IClusterModel; + model = props.initialModel; + + this.state = { model }; + } + + /** + * When the component updates we take the opportunity to write + * the state of the cluster to an external object so this can + * be sent as the result of the dialog. + */ + componentDidUpdate(): void { + let model: IClusterModel = { ...this.state.model }; + this.props.stateEscapeHatch(model); + } + + /** + * React to the number of workers changing. + */ + onScaleChanged(event: React.ChangeEvent): void { + this.setState({ + model: { + ...this.state.model, + workers: parseInt((event.target as HTMLInputElement).value, 10), + }, + }); + } + + /** + * Render the component.. + */ + render() { + const model = this.state.model; + // const disabledClass = "ipp-mod-disabled"; + return ( +
    + Manual Scaling +
    +
    + Engines + { + this.onScaleChanged(evt); + }} + /> +
    +
    +
    + ); + } +} + +/** + * Show a dialog for scaling a cluster model. + * + * @param model: the initial model. + * + * @returns a promse that resolves with the user-selected scalings for the + * cluster model. If they pressed the cancel button, it resolves with + * the original model. + */ +export function showScalingDialog( + model: IClusterModel +): Promise { + let updatedModel = { ...model }; + const escapeHatch = (update: IClusterModel) => { + updatedModel = update; + }; + + return showDialog({ + title: `Scale ${model.name}`, + body: ( + + ), + buttons: [Dialog.cancelButton(), Dialog.okButton({ label: "SCALE" })], + }).then((result) => { + if (result.button.accept) { + return updatedModel; + } else { + return model; + } + }); +} diff --git a/lab/src/sidebar.ts b/lab/src/sidebar.ts new file mode 100644 index 000000000..49a7ddccb --- /dev/null +++ b/lab/src/sidebar.ts @@ -0,0 +1,68 @@ +import { Widget, PanelLayout } from "@lumino/widgets"; +import { CommandRegistry } from "@lumino/commands"; + +import { ClusterManager, IClusterModel } from "./clusters"; + +/** + * A widget for hosting IPP cluster widgets + */ +export class Sidebar extends Widget { + /** + * Create a new IPP sidebar. + */ + constructor(options: Sidebar.IOptions) { + super(); + this.addClass("ipp-Sidebar"); + let layout = (this.layout = new PanelLayout()); + + const injectClientCodeForCluster = options.clientCodeInjector; + const getClientCodeForCluster = options.clientCodeGetter; + // Add the cluster manager component. + this._clusters = new ClusterManager({ + registry: options.registry, + launchClusterId: options.launchClusterId, + injectClientCodeForCluster, + getClientCodeForCluster, + }); + layout.addWidget(this._clusters); + } + + /** + * Get the cluster manager associated with the sidebar. + */ + get clusterManager(): ClusterManager { + return this._clusters; + } + + private _clusters: ClusterManager; +} + +/** + * A namespace for Sidebar statics. + */ +export namespace Sidebar { + /** + * Options for the constructor. + */ + export interface IOptions { + /** + * Registry of all commands + */ + registry: CommandRegistry; + + /** + * The launchCluster command ID. + */ + launchClusterId: string; + + /** + * A function that injects client-connection code for a given cluster. + */ + clientCodeInjector: (model: IClusterModel) => void; + + /** + * A function that gets client-connection code for a given cluster. + */ + clientCodeGetter: (model: IClusterModel) => string; + } +} diff --git a/lab/src/svg.d.ts b/lab/src/svg.d.ts new file mode 100644 index 000000000..af48a0484 --- /dev/null +++ b/lab/src/svg.d.ts @@ -0,0 +1,6 @@ +// svg.d.ts + +declare module "*.svg" { + const value: string; + export default value; +} diff --git a/lab/style/code-dark.svg b/lab/style/code-dark.svg new file mode 100644 index 000000000..8051b16bf --- /dev/null +++ b/lab/style/code-dark.svg @@ -0,0 +1 @@ + diff --git a/lab/style/code-light.svg b/lab/style/code-light.svg new file mode 100644 index 000000000..b2eca68b1 --- /dev/null +++ b/lab/style/code-light.svg @@ -0,0 +1 @@ + diff --git a/lab/style/index.css b/lab/style/index.css new file mode 100644 index 000000000..571ff5f41 --- /dev/null +++ b/lab/style/index.css @@ -0,0 +1,190 @@ +:root { + --ipp-launch-button-height: 24px; +} + +/** + * Rules related to the overall sidebar panel. + */ + +.ipp-Sidebar { + background: var(--jp-layout-color1); + color: var(--jp-ui-font-color1); + font-size: var(--jp-ui-font-size1); + overflow: auto; +} + +/** + * Rules related to the cluster manager. + */ + +.ipp-ClusterManager { + border-top: 6px solid var(--jp-toolbar-border-color); +} + +.ipp-ClusterManager .jp-Toolbar { + align-items: center; +} + +.ipp-ClusterManager .jp-Toolbar .ipp-ClusterManager-label { + flex: 0 0 auto; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + font-size: var(--jp-ui-font-size0); + padding: 8px 8px 8px 12px; + margin: 0px; +} + +.ipp-ClusterManager button.jp-Button > span { + display: flex; + flex-direction: row; + align-items: center; +} + +.ipp-ClusterListing ul.ipp-ClusterListing-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +.ipp-ClusterListingItem { + display: inline-block; + list-style-type: none; + padding: 8px; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: grab; +} + +.ipp-ClusterListingItem-drag { + opacity: 0.7; + color: var(--jp-ui-font-color1); + cursor: grabbing; + max-width: 260px; + transform: translateX(-50%) translateY(-50%); +} + +.ipp-ClusterListingItem-title { + margin: 0px; + font-size: var(--jp-ui-font-size2); +} + +.ipp-ClusterListingItem-link a { + text-decoration: none; + color: var(--jp-content-link-color); +} + +.ipp-ClusterListingItem-link a:hover { + text-decoration: underline; +} + +.ipp-ClusterListingItem-link a:visited { + color: var(--jp-content-link-color); +} + +.ipp-ClusterListingItem:hover { + background: var(--jp-layout-color2); +} + +.ipp-ClusterListingItem.jp-mod-active { + color: white; + background: var(--jp-brand-color0); +} + +.ipp-ClusterListingItem.jp-mod-active a, +.ipp-ClusterListingItem.jp-mod-active a:visited { + color: white; +} + +.ipp-ClusterListingItem button.jp-mod-styled { + background-color: transparent; +} + +.ipp-ClusterListingItem button.jp-mod-styled:hover { + background-color: var(--jp-layout-color3); +} + +.ipp-ClusterListingItem.jp-mod-active button.jp-mod-styled:hover { + background-color: var(--jp-brand-color1); +} + +.ipp-ClusterListingItem-button-panel { + display: flex; + align-content: center; +} + +button.ipp-ClusterListingItem-stop { + color: var(--jp-warn-color1); + font-weight: 600; +} + +button.ipp-ClusterListingItem-scale { + color: var(--jp-accent-color1); + font-weight: 600; +} + +.ipp-ClusterListingItem button.ipp-ClusterListingItem-code.jp-mod-styled { + margin: 0 4px 0 4px; + background-repeat: no-repeat; + background-position: center; +} + +/** + * Rules for the scaling dialog. + */ + +.ipp-ScalingHeader { + font-size: var(--jp-ui-font-size2); +} + +.ipp-ScalingSection { + margin-left: 24px; +} + +.ipp-ScalingSection-item { + display: flex; + align-items: center; + justify-content: space-around; + margin: 12px 0 12px 0; +} + +.ipp-ScalingHeader input[type="checkbox"] { + position: relative; + top: 4px; + left: 4px; + margin: 0 0 0 8px; +} + +.ipp-ScalingSection input[type="number"] { + width: 72px; +} + +.ipp-ScalingSection-label.ipp-mod-disabled { + color: var(--jp-ui-font-color3); +} + +.ipp-ScalingSection input[type="number"]:disabled { + color: var(--jp-ui-font-color3); +} + +/** + * Rules for the logos. + */ + +.ipp-SearchIcon { + background-image: var(--jp-icon-search); +} + +[data-jp-theme-light="true"] .ipp-CodeIcon { + background-image: url(code-light.svg); +} + +[data-jp-theme-light="false"] .ipp-CodeIcon { + background-image: url(code-dark.svg); +} + +.ipp-ClusterListingItem.jp-mod-active .ipp-CodeIcon { + background-image: url(code-dark.svg); +} diff --git a/lab/style/logo.svg b/lab/style/logo.svg new file mode 100644 index 000000000..6ebd62206 --- /dev/null +++ b/lab/style/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 000000000..e656f5e09 --- /dev/null +++ b/package.json @@ -0,0 +1,100 @@ +{ + "name": "ipyparallel-labextension", + "version": "7.0.0", + "private": false, + "description": "A JupyterLab extension for IPython Parallel.", + "keywords": [ + "ipython", + "jupyter", + "jupyterlab", + "jupyterlab-extension" + ], + "homepage": "https://github.com/ipython/ipyparallel", + "bugs": { + "url": "https://github.com/ipython/ipyparallel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/ipython/ipyparallel" + }, + "license": "BSD-3-Clause", + "author": "Min Ragan-Kelley", + "files": [ + "lab/lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "lab/schema/*.json", + "lab/style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}" + ], + "main": "lab/lib/index.js", + "types": "lab/lib/index.d.ts", + "scripts": { + "build": "jlpm run build:lib && jlpm run build:labextension:dev", + "build:labextension": "jupyter labextension build .", + "build:labextension:dev": "jupyter labextension build --development True .", + "build:lib": "tsc", + "build:prod": "jlpm run build:lib && jlpm run build:labextension", + "clean": "jlpm run clean:lib", + "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", + "clean:labextension": "rimraf ipyparallel/labextension", + "clean:lib": "rimraf lab/lib tsconfig.tsbuildinfo", + "eslint": "eslint . --ext .ts,.tsx --fix", + "eslint:check": "eslint . --ext .ts,.tsx", + "install:extension": "jupyter labextension develop --overwrite .", + "lint": "prettier --check '**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}' && jlpm eslint:check", + "prettier": "prettier --write '**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}'", + "prettier:check": "prettier --list-different '**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}'", + "test": "mocha", + "watch": "run-p watch:src watch:labextension", + "watch:labextension": "jupyter labextension watch .", + "watch:src": "tsc -w" + }, + "dependencies": { + "@jupyterlab/application": "^3.0.0", + "@jupyterlab/apputils": "^3.0.0", + "@jupyterlab/codeeditor": "^3.0.0", + "@jupyterlab/console": "^3.0.0", + "@jupyterlab/coreutils": "^5.0.0", + "@jupyterlab/mainmenu": "^3.0.0", + "@jupyterlab/nbformat": "^3.0.0", + "@jupyterlab/notebook": "^3.0.0", + "@jupyterlab/services": "^6.0.0", + "@jupyterlab/settingregistry": "^3.0.0", + "@jupyterlab/statedb": "^3.0.0", + "@jupyterlab/ui-components": "^3.0.0", + "@lumino/algorithm": "^1.3.3", + "@lumino/coreutils": "^1.5.3", + "@lumino/domutils": "^1.2.3", + "@lumino/dragdrop": "^1.7.1", + "@lumino/messaging": "^1.4.3", + "@lumino/polling": "^1.0.4", + "@lumino/signaling": "^1.4.3", + "@lumino/widgets": "^1.17.0", + "react": "^17.0.1", + "react-dom": "^17.0.1" + }, + "devDependencies": { + "@jupyterlab/builder": "^3.0.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@typescript-eslint/eslint-plugin": "^2.27.0", + "@typescript-eslint/parser": "^2.27.0", + "eslint": "^7.14.0", + "eslint-config-prettier": "^6.10.1", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-react": "^7.21.5", + "mkdirp": "^1.0.3", + "mocha": "^6.2.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.1.1", + "rimraf": "^3.0.2", + "typescript": "~4.1.3", + "yarn": "1.22.0" + }, + "resolutions": { + "@types/react": "~17.0.0" + }, + "jupyterlab": { + "extension": true, + "schemaDir": "schema", + "outputDir": "ipyparallel/labextension" + } +} diff --git a/pyproject.toml b/pyproject.toml index a7630d48c..b46d38971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,12 @@ +[build-system] +requires = [ + "jupyter_packaging~=0.7.0", + "jupyterlab>=3.0.0rc2,==3.*", + "setuptools>=40.8.0", + "wheel", +] +build-backend = "setuptools.build_meta" + [tool.black] skip-string-normalization = true target_version = [ diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 000000000..f76f52402 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "include": [".eslintrc.js", "*", "lab/src/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..643f68d33 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "composite": true, + "declaration": true, + "esModuleInterop": true, + "incremental": true, + "jsx": "react", + "module": "esnext", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "preserveWatchOutput": true, + "resolveJsonModule": true, + "outDir": "lab/lib", + "rootDir": "lab/src", + "strict": true, + "strictNullChecks": false, + "target": "es2017", + "types": [] + }, + "include": ["lab/src/*"] +}