Skip to content

Commit

Permalink
Load all settings from federated extensions at startup (#1220)
Browse files Browse the repository at this point in the history
* Aggregate settings per federated extension

* Handle federated settings

* search for all_federated.json

* Load all the federated settings at startup

* Try adding notebook to the demo site

* Simplify webpack config

* Add notebook to ui tests environment

* Add UI test

* handle missing schemas dir

* some tweaks

* Open in Notebook from Lab

* handle single mode redirect

* UI test for switching between lab and notebook

* disable federated addon in sourcemaps test

* fix handling of paths

* minor cleanup

* add notebook dep to extras

* Add docs about the interface switcher

* lint

* hoist settingsfile type

* sort

* promise.all

* fix sorting
  • Loading branch information
jtpio committed Oct 20, 2023
1 parent 46031a5 commit 38c6fb1
Show file tree
Hide file tree
Showing 18 changed files with 239 additions and 79 deletions.
14 changes: 14 additions & 0 deletions app/doc/tree/index.html
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<script>
// redirect to /lab by default
(function () {
window.location.href = window.location.href.replace(
/(\/|\/index.html)?(\?.*)$/,
'/../../lab/index.html$2'
);
}.call(this));
</script>
</head>
</html>
7 changes: 6 additions & 1 deletion app/lab/package.json
Expand Up @@ -86,6 +86,7 @@
"@jupyterlite/kernel": "~0.2.0-rc.0",
"@jupyterlite/licenses": "~0.2.0-rc.0",
"@jupyterlite/localforage": "~0.2.0-rc.0",
"@jupyterlite/notebook-application-extension": "~0.2.0-rc.0",
"@jupyterlite/server": "~0.2.0-rc.0",
"@jupyterlite/server-extension": "~0.2.0-rc.0",
"@jupyterlite/types": "~0.2.0-rc.0",
Expand Down Expand Up @@ -157,6 +158,7 @@
"@jupyterlite/iframe-extension": "^0.2.0-rc.0",
"@jupyterlite/licenses": "^0.2.0-rc.0",
"@jupyterlite/localforage": "^0.2.0-rc.0",
"@jupyterlite/notebook-application-extension": "^0.2.0-rc.0",
"@jupyterlite/server": "^0.2.0-rc.0",
"@jupyterlite/server-extension": "^0.2.0-rc.0",
"@jupyterlite/types": "^0.2.0-rc.0",
Expand Down Expand Up @@ -212,6 +214,7 @@
"@jupyterlab/vega5-extension",
"@jupyterlite/application-extension",
"@jupyterlite/iframe-extension",
"@jupyterlite/notebook-application-extension",
"@jupyterlite/server-extension"
],
"singletonPackages": [
Expand Down Expand Up @@ -287,7 +290,9 @@
"@jupyterlab/docmanager-extension:download",
"@jupyterlab/filebrowser-extension:download",
"@jupyterlab/filebrowser-extension:share-file",
"@jupyterlab/help-extension:about"
"@jupyterlab/help-extension:about",
"@jupyterlite/notebook-application-extension:logo",
"@jupyterlite/notebook-application-extension:notify-commands"
],
"mimeExtensions": {
"@jupyterlab/javascript-extension": "",
Expand Down
5 changes: 3 additions & 2 deletions app/webpack.config.js
Expand Up @@ -227,8 +227,9 @@ class CompileSchemasPlugin {
compiler.hooks.done.tapAsync('CompileSchemasPlugin', (compilation, callback) => {
// ensure all schemas are statically compiled
const schemaDir = path.resolve(topLevelBuild, './schemas');
const allCore = 'all.json';
const files = glob.sync(`${schemaDir}/**/*.json`, {
ignore: [`${schemaDir}/all.json`],
ignore: [`${schemaDir}/all*.json`],
});
const all = files.map((file) => {
const schema = fs.readJSONSync(file);
Expand All @@ -247,7 +248,7 @@ class CompileSchemasPlugin {
};
});

fs.writeFileSync(path.resolve(schemaDir, 'all.json'), JSON.stringify(all));
fs.writeFileSync(path.resolve(schemaDir, allCore), JSON.stringify(all));
callback();
});
}
Expand Down
52 changes: 52 additions & 0 deletions docs/howto/configure/interface_switcher.md
@@ -0,0 +1,52 @@
# Enable switching between the JupyterLab and Notebook interfaces

By default JupyterLite includes both the JupyterLab and Notebook interfaces.

The JupyterLab interface is available under the `/lab` path, and the Notebook file
browser is available under the `/tree` path. However there is no convenient way to
switch between the two interfaces by default.

To add menu entries and toolbar items to help switching between interfaces, you can
install the `notebook` package in the build environment, just like any other extension.

## Installing the `notebook` package

In your build environment, install the `notebook` package:

```shell
pip install notebook
```

Or add it to your `requirements.txt` or similar file for managing dependencies:

```text
notebook
```

Then build your JupyterLite site as usual:

```shell
jupyter lite build
```

The `notebook` package includes a JupyterLab extensions that adds the menu entries and
toolbar items to switch between interfaces.

## Launch the Jupyter Notebook File Browser menu item

The `notebook` package adds a menu item to launch the Jupyter Notebook File Browser via
the `Help > Launch Jupyter Notebook File Browser`:

![a screenshot showing how to launch Jupyter Notebook from the menu entry](https://github.com/jupyterlite/jupyterlite/assets/591645/bc45a79a-1ede-44ca-b6ca-3deb5fe56187)

## Switch between JupyterLab and Notebook interfaces

The `notebook` package also adds a toolbar item to switch between the JupyterLab and
Notebook interfaces:

![a screenshot showing how to launch JupyterLab from a notebook](https://github.com/jupyterlite/jupyterlite/assets/591645/009c5e32-d8bf-4658-a711-b28c45dcdd1d)

## References

Check out the [guide for adding extensions](../configure/simple_extensions.md) for more
informations about how to add extensions to your JupyterLite site.
1 change: 1 addition & 0 deletions docs/howto/index.md
Expand Up @@ -23,6 +23,7 @@ configure/settings
configure/translation
configure/rtc
configure/config_files
configure/interface_switcher
```

## Contents
Expand Down
1 change: 1 addition & 0 deletions examples/jupyter_lite_config.json
Expand Up @@ -17,6 +17,7 @@
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab-myst-2.0.2-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab_miami_nights-0.4.0-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.8-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/notebook-7.0.6-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/label/jupyterlite_pyodide_kernel_alpha/noarch/jupyterlite-pyodide-kernel-0.2.0a1-pyh9178eb9_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/plotly-5.15.0-pyhd8ed1ab_0.conda",
"https://github.com/jupyterlite/p5-kernel/releases/download/v0.1.0/jupyterlite_p5_kernel-0.1.0-py3-none-any.whl"
Expand Down
20 changes: 17 additions & 3 deletions packages/application-extension/src/index.tsx
Expand Up @@ -37,7 +37,7 @@ import React from 'react';
/**
* A regular expression to match path to notebooks, documents and consoles
*/
const URL_PATTERN = new RegExp('/(lab|notebooks|edit|consoles)\\/?');
const URL_PATTERN = new RegExp('/(lab|tree|notebooks|edit|consoles)\\/?');

/**
* The JupyterLab document manager plugin id.
Expand Down Expand Up @@ -333,14 +333,28 @@ const opener: JupyterFrontEndPlugin<void> = {

const urlParams = new URLSearchParams(search);
const paths = urlParams.getAll('path');
if (!paths) {
if (paths.length === 0) {
return;
}
const files = paths.map((path) => decodeURIComponent(path));
app.started.then(async () => {
const page = PageConfig.getOption('notebookPage');
const [file] = files;
if (page === 'consoles') {
if (page === 'tree') {
let appUrl = '/edit';
// check if the file is a notebook
const defaultFactory = docRegistry.defaultWidgetFactory(file);
if (defaultFactory.name === 'Notebook') {
appUrl = '/notebooks';
}
const baseUrl = PageConfig.getBaseUrl();
const url = new URL(URLExt.join(baseUrl, appUrl, 'index.html'));
url.searchParams.append('path', file);

// redirect to the proper page
window.location.href = url.toString();
return;
} else if (page === 'consoles') {
commands.execute('console:create', { path: file });
return;
} else if (page === 'notebooks' || page === 'edit') {
Expand Down
90 changes: 22 additions & 68 deletions packages/settings/src/settings.ts
@@ -1,13 +1,12 @@
import { PageConfig, URLExt } from '@jupyterlab/coreutils';

import { PromiseDelegate } from '@lumino/coreutils';

import * as json5 from 'json5';

import type localforage from 'localforage';

import { IFederatedExtension } from '@jupyterlite/types';

import { IPlugin, ISettings } from './tokens';
import { PromiseDelegate } from '@lumino/coreutils';
import { IPlugin, ISettings, SettingsFile } from './tokens';

/**
* The name of the local storage.
Expand Down Expand Up @@ -87,26 +86,27 @@ export class Settings implements ISettings {
async get(pluginId: string): Promise<IPlugin | undefined> {
const all = await this.getAll();
const settings = all.settings as IPlugin[];
let found = settings.find((setting: IPlugin) => {
const setting = settings.find((setting: IPlugin) => {
return setting.id === pluginId;
});

if (!found) {
found = await this._getFederated(pluginId);
}

return found;
return setting;
}

/**
* Get all the settings
*/
async getAll(): Promise<{ settings: IPlugin[] }> {
const settingsUrl = PageConfig.getOption('settingsUrl') ?? '/';
const [allCore, allFederated] = await Promise.all([
this._getAll('all.json'),
this._getAll('all_federated.json'),
]);

// JupyterLab 4 expects all settings to be returned in one go
// so append the settings from federated plugins to the core ones
const all = allCore.concat(allFederated);

// return existing user settings if they exist
const storage = await this.storage;
const all = (await (
await fetch(URLExt.join(settingsUrl, 'all.json'))
).json()) as IPlugin[];
const settings = await Promise.all(
all.map(async (plugin) => {
const { id } = plugin;
Expand All @@ -133,37 +133,14 @@ export class Settings implements ISettings {
}

/**
* Get the settings for a federated extension
*
* @param pluginId The id of a plugin
* Get all the settings for core or federated plugins
*/
private async _getFederated(pluginId: string): Promise<IPlugin | undefined> {
const [packageName, schemaName] = pluginId.split(':');

if (!Private.isFederated(packageName)) {
return;
}

const labExtensionsUrl = PageConfig.getOption('fullLabextensionsUrl');
const schemaUrl = URLExt.join(
labExtensionsUrl,
packageName,
'schemas',
packageName,
`${schemaName}.json`,
);
const packageUrl = URLExt.join(labExtensionsUrl, packageName, 'package.json');
const schema = await (await fetch(schemaUrl)).json();
const packageJson = await (await fetch(packageUrl)).json();
const raw = ((await (await this.storage).getItem(pluginId)) as string) ?? '{}';
const settings = json5.parse(raw) || {};
return Private.override({
id: pluginId,
raw,
schema,
settings,
version: packageJson.version || '3.0.8',
});
private async _getAll(file: SettingsFile): Promise<IPlugin[]> {
const settingsUrl = PageConfig.getOption('settingsUrl') ?? '/';
const all = (await (
await fetch(URLExt.join(settingsUrl, file))
).json()) as IPlugin[];
return all;
}

private _storageName: string = DEFAULT_STORAGE_NAME;
Expand Down Expand Up @@ -195,29 +172,6 @@ namespace Private {
PageConfig.getOption('settingsOverrides') || '{}',
);

/**
* Test whether this package is configured in `federated_extensions` in this app
*
* @param packageName The npm name of a package
*/
export function isFederated(packageName: string): boolean {
let federated: IFederatedExtension[];

try {
federated = JSON.parse(PageConfig.getOption('federated_extensions'));
} catch {
return false;
}

for (const { name } of federated) {
if (name === packageName) {
return true;
}
}

return false;
}

/**
* Override the defaults of the schema with ones from PageConfig
*
Expand Down
5 changes: 5 additions & 0 deletions packages/settings/src/tokens.ts
Expand Up @@ -7,6 +7,11 @@ import { JSONObject, PartialJSONObject, Token } from '@lumino/coreutils';
*/
export const ISettings = new Token<ISettings>('@jupyterlite/settings:ISettings');

/**
* The settings file to request
*/
export type SettingsFile = 'all.json' | 'all_federated.json';

/**
* An interface for the plugin settings.
*/
Expand Down
Expand Up @@ -8,6 +8,7 @@
from traitlets import List, Unicode

from ..constants import (
ALL_FEDERATED_JSON,
FEDERATED_EXTENSIONS,
JSON_FMT,
JUPYTER_CONFIG_DATA,
Expand Down Expand Up @@ -189,7 +190,8 @@ def copy_one_federated_extension(self, pkg_json):

if self.is_prebuilt(pkg_data):
pkg_name = pkg_data["name"]
self.copy_one(pkg_json.parent, self.output_extensions / f"{pkg_name}")
output_pkg = self.output_extensions / pkg_name
self.copy_one(pkg_json.parent, output_pkg)

def is_prebuilt(self, pkg_json):
"""verify this is an actual pre-built extension, containing load information"""
Expand Down Expand Up @@ -284,6 +286,53 @@ def post_build(self, manager):
actions=[(self.copy_one, [theme_dir, dest])],
)

yield self.task(
name="settings",
doc=f"ensure {ALL_FEDERATED_JSON} includes the settings of federated extensions",
file_dep=[*lab_extensions],
actions=[(self.ensure_federated_settings, [manager, lab_extensions])],
)

def ensure_federated_settings(self, manager, lab_extensions):
"""ensure settings from federated extensions are aggregated in a single file"""
all_federated_settings = [
setting for p in lab_extensions for setting in self.get_federated_settings(p.parent)
]

app_schemas = manager.output_dir / "build" / "schemas"
if not app_schemas.is_dir():
# bail if there is no schemas dir
return
all_federated_json = app_schemas / ALL_FEDERATED_JSON
all_federated_json.write_text(json.dumps(all_federated_settings), **UTF8)

def get_federated_settings(self, extension):
"""get the settings for a federated extension"""
pkg_json = extension / PACKAGE_JSON
pkg_data = json.loads(pkg_json.read_text(**UTF8))
settings_dir = extension / "schemas"
if not settings_dir.is_dir():
# bail if there is no settings for that extension
return []
setting_files = sorted(settings_dir.rglob("*.json"))

pkg_name = pkg_data["name"]
pkg_version = pkg_data["version"]
all_settings = []
for setting_file in setting_files:
plugin_id = f"{pkg_name}:{setting_file.stem}"
schema = json.loads(setting_file.read_text(**UTF8))
setting = {
"id": plugin_id,
"raw": "{}",
"schema": schema,
"settings": {},
"version": pkg_version,
}
all_settings.append(setting)

return all_settings

def patch_jupyterlite_json(self, jupyterlite_json):
"""add the federated_extensions to jupyter-lite.json
Expand Down

0 comments on commit 38c6fb1

Please sign in to comment.