Skip to content

Commit

Permalink
External Links Extension (jupyter-server#148)
Browse files Browse the repository at this point in the history
* WIP: initial extension implementation

* add server extension

* link frontend and server extension

* refactor JS code

* disconnect the signal properly

* clean up and add testing

* fix disconnect

* formatting

Co-authored-by: Steven Silvester <ssilvester@apple.com>
  • Loading branch information
2 people authored and GitHub Enterprise committed Sep 16, 2021
1 parent b4f8448 commit acf5c8d
Show file tree
Hide file tree
Showing 15 changed files with 1,353 additions and 3,453 deletions.
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def jp_server_config(datastudio_env):
"data_studio_jupyter_extensions.extensions.telemetry": True,
"data_studio_jupyter_extensions.extensions.heartbeat": True,
"data_studio_jupyter_extensions.extensions.display_info": True,
"data_studio_jupyter_extensions.extensions.external_links": True,
"nbclassic": True,
},
},
Expand Down
7 changes: 7 additions & 0 deletions data_studio_jupyter_extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ def _jupyter_server_extension_points(): # pragma: no cover
from data_studio_jupyter_extensions.extensions.display_info.extension import (
DisplayInfoExtension,
)
from data_studio_jupyter_extensions.extensions.external_links.extension import (
ExternalLinksExtension,
)

return [
{
Expand All @@ -42,4 +45,8 @@ def _jupyter_server_extension_points(): # pragma: no cover
"module": "data_studio_jupyter_extensions.extensions.display_info.extension",
"app": DisplayInfoExtension,
},
{
"module": "data_studio_jupyter_extensions.extensions.external_links.extension",
"app": ExternalLinksExtension,
},
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from data_studio_jupyter_extensions.extensions.external_links.extension import (
ExternalLinksExtension,
)


def _jupyter_server_extension_points(): # pragma: no cover
return [
{
"module": "data_studio_jupyter_extensions.extensions.external_links.extension",
"app": ExternalLinksExtension,
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from jupyter_server.extension.application import ExtensionApp

from .handlers import handlers


class ExternalLinksExtension(ExtensionApp):
"""Jupyter Server extension that verifies
the health of the server.
"""

name = "external_links"
handlers = handlers
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Application Heartbeat for PIE Health check"""
import json

from jupyter_server.base.handlers import JupyterHandler
from jupyter_server.services.kernels.handlers import _kernel_id_regex

from data_studio_jupyter_extensions.base_handler import DSExtensionHandlerMixin


class ExternalLinksHandler(DSExtensionHandlerMixin, JupyterHandler):
async def get(self, kernel_id):
# Look up kernel.
km = self.kernel_manager.get_kernel(kernel_id)
# Get the process ID to hand off to the notebook_service.
process_id = km.provisioner.process_id
resp = await self.nbservice_client.get_external_links_for_kernel(process_id)
data = json.dumps(resp)
self.finish(data)


handlers = [
(rf"/api/kernels/{_kernel_id_regex}/links", ExternalLinksHandler),
]
7 changes: 7 additions & 0 deletions data_studio_jupyter_extensions/tests/extensions/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,12 @@ def datastudio_display_info_extension(jp_serverapp):
name = "data_studio_jupyter_extensions.extensions.display_info"
pkg = jp_serverapp.extension_manager.extensions[name]
point = pkg.extension_points["display_info"]


@pytest.fixture
def datastudio_external_links_extension(jp_serverapp):
name = "data_studio_jupyter_extensions.extensions.external_links"
pkg = jp_serverapp.extension_manager.extensions[name]
point = pkg.extension_points["external_links"]
app = point.app
return app
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import json

import pytest
from jupyter_client.provisioning.provisioner_base import KernelProvisionerBase

from data_studio_jupyter_extensions.tests.mock.client import MockNotebookServiceClient


@pytest.fixture
def provisioner_process_id_patch(monkeypatch):
"""The tests use a LocalProvisioner, which won't have a process_id attribute.
We'll temporarily patch in this attribute here.
"""
monkeypatch.setattr(
KernelProvisionerBase, "process_id", "testprocess", raising=False
)


@pytest.fixture
def jp_server_config(datastudio_env):
config = {
"ServerApp": {
"jpserver_extensions": {
"data_studio_jupyter_extensions": True,
"data_studio_jupyter_extensions.extensions.telemetry": True,
"data_studio_jupyter_extensions.extensions.heartbeat": True,
"data_studio_jupyter_extensions.extensions.external_links": True,
"nbclassic": True,
},
},
# Use the Mock Notebook Service Client.
"DataStudioJupyterExtensions": {
"nbservice_client_class": MockNotebookServiceClient
},
}
return config


async def test_endpoint(clear_singletons, provisioner_process_id_patch, jp_fetch):
# Start a kernel to use.
r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True)
kernel_id = json.loads(r.body.decode())["id"]
external_links_url = f"/api/kernels/{kernel_id}/links"
resp = await jp_fetch(external_links_url)
data = json.loads(resp.body)
assert resp.code == 200
for item in data:
assert "label" in item
assert "description" in item or "details" in item
assert "url" in item
9 changes: 8 additions & 1 deletion data_studio_jupyter_extensions/tests/mock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ class MockNotebookServiceClient(NotebookServiceClient):

last_method_called = None

def __init__(self, openapi_spec=None):
def __init__(self, openapi_spec=None, *args, **kwargs):
super().__init__(*args, **kwargs)
if not openapi_spec:
openapi_spec = load_openapi_spec()
self.openapi_spec = openapi_spec
Expand Down Expand Up @@ -97,6 +98,12 @@ async def get_external_links_for_kernel(self, process_id):
)
method = "get"
resp = self.openapi_spec.get_response(endpoint, method=method)

# Make sure the response is a list. The open API spec is wrong here.
if not isinstance(resp, list):
resp = [resp]
# resp = json.dumps(resp)

self.last_method_called = "get_external_links_for_kernel"
return resp

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@jupyterlab/application": "^3.0.0",
"@jupyterlab/coreutils": "^5.0.0",
"@jupyterlab/logconsole": "^3.0.10",
"@jupyterlab/mainmenu": "^3.0.0",
"@jupyterlab/notebook": "^3.0.11",
"@jupyterlab/services": "^6.0.0",
"@jupyterlab/settingregistry": "^3.0.0",
Expand All @@ -75,6 +76,7 @@
"jest-websocket-mock": "^2.2.1",
"mkdirp": "^1.0.3",
"mock-socket": "^9.0.3",
"nock": "^13.1.3",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"rimraf": "^3.0.2",
Expand Down
116 changes: 116 additions & 0 deletions src/externallinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Menu } from '@lumino/widgets';
import { Signal } from '@lumino/signaling';
import { INotebookTracker } from '@jupyterlab/notebook';
import { ISessionContext } from '@jupyterlab/apputils';
import { IMainMenu } from '@jupyterlab/mainmenu';
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { requestAPI } from './handler';

class ExternalLinksMenu {
// Notebook witch
app: JupyterFrontEnd;
menu: Menu;

constructor(app: JupyterFrontEnd, mainmenu: IMainMenu) {
this.app = app;
this.menu = new Menu({ commands: this.app.commands });
this.menu.title.label = 'External Links';
mainmenu.addMenu(this.menu, { rank: 4000 });
}

registerCommand(name: string, enabled: boolean) {
if (this.app.commands.hasCommand(name)) {
return;
}

this.app.commands.addCommand(name, {
label: args => args['details'] as string,
isEnabled: () => enabled,
isVisible: () => true,
execute: args => {
const url = args['url'] as string;
window.open(url);
return;
}
});

this.app.commands.notifyCommandChanged(name);
}

async addLinks(kernel_id: string | undefined) {
this.menu.clearItems();
if (kernel_id) {
// Hit the server extension to get the links.
const url = '/api/kernels/' + kernel_id + '/links';
const links: any = await requestAPI(url);

// Define a new command for this link.
for (const link of links) {
const openExternalLink = 'externallinks:' + link['label'];

if (!this.app.commands.hasCommand(openExternalLink)) {
this.registerCommand(openExternalLink, true);
}

// Add an item to the menu that executes the above command.
this.menu.addItem({
command: openExternalLink,
args: link
});
}
} else {
this.registerCommand('externallinks:none', false);
this.menu.addItem({
command: 'externallinks:none',
args: { details: 'No Links Found', url: '/' }
});
}
}

onKernelChange(sender: ISessionContext) {
// Clear the menu of previous content.
// Get the current menu item.
const kernel_id = sender.session?.kernel?.id;
// Populate the menu.
this.addLinks(kernel_id);
}

onNotebookChange(sender: INotebookTracker) {
// Get current notebook.
const current = sender.currentWidget;
// If there is no notebook open, return null.
if (!current) {
return;
}
// Get the current menu item.
const kernel_id = current.sessionContext.session?.kernel?.id;
// Populate the menu.
this.addLinks(kernel_id);
// Add a signal that looks for kernel changes (after removing any old signals).
Signal.disconnectReceiver(this.onKernelChange);
current.sessionContext.kernelChanged.connect(this.onKernelChange, this);
}
}

/**
* Build an external links menu item.
*/
export const ExternalLinksPlugin: JupyterFrontEndPlugin<void> = {
id: 'data_studio:external_links_plugin',
autoStart: true,
requires: [IMainMenu, INotebookTracker],
activate: (
app: JupyterFrontEnd,
mainmenu: IMainMenu,
notebookTracker: INotebookTracker
) => {
console.log('JupyterLab extension external links is activated!');
// Build an "External Links" menu.
const menu = new ExternalLinksMenu(app, mainmenu);
// Populate that menu when it changes.
notebookTracker.currentChanged.connect(menu.onNotebookChange, menu);
}
};
6 changes: 1 addition & 5 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ export async function requestAPI<T>(
): Promise<T> {
// Make request to Jupyter API
const settings = ServerConnection.makeSettings();
const requestUrl = URLExt.join(
settings.baseUrl,
'data-studio', // API Namespace
endPoint
);
const requestUrl = URLExt.join(settings.baseUrl, endPoint);

let response: Response;
try {
Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
INotebookModel
} from '@jupyterlab/notebook';

import { ExternalLinksPlugin } from './externallinks';

// TODO: thread config through PageConfig instead of detecting env
// once the functionality is available in JupyterServer
declare var process: {
Expand Down Expand Up @@ -64,7 +66,7 @@ export class ButtonExtension
/**
* Initialization data for the studio extension.
*/
const plugin: JupyterFrontEndPlugin<void> = {
export const kernelStatusPlugin: JupyterFrontEndPlugin<void> = {
id: 'data_studio:plugin',
autoStart: true,
requires: [ILoggerRegistry, INotebookTracker],
Expand Down Expand Up @@ -159,4 +161,9 @@ const statusProperty = new AttachedProperty<
create: () => undefined
});

export default plugin;
const plugins: JupyterFrontEndPlugin<any>[] = [
kernelStatusPlugin,
ExternalLinksPlugin
];

export default plugins;
Loading

0 comments on commit acf5c8d

Please sign in to comment.