From 38c6fb14b6411338f6ca1d06fcc20c037d5f2bed Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 20 Oct 2023 07:29:32 +0200 Subject: [PATCH] Load all settings from federated extensions at startup (#1220) * 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 --- app/doc/tree/index.html | 14 +++ app/lab/package.json | 7 +- app/webpack.config.js | 5 +- docs/howto/configure/interface_switcher.md | 52 +++++++++++ docs/howto/index.md | 1 + examples/jupyter_lite_config.json | 1 + packages/application-extension/src/index.tsx | 20 ++++- packages/settings/src/settings.ts | 90 +++++-------------- packages/settings/src/tokens.ts | 5 ++ .../addons/federated_extensions.py | 51 ++++++++++- .../jupyterlite_core/constants.py | 3 +- .../jupyterlite_core/tests/test_cli.py | 3 +- py/jupyterlite-core/pyproject.toml | 2 + py/jupyterlite/pyproject.toml | 2 + tsconfig.eslint.json | 1 + ui-tests/requirements.txt | 5 +- ui-tests/test/notebook.test.ts | 55 ++++++++++++ yarn.lock | 1 + 18 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 app/doc/tree/index.html create mode 100644 docs/howto/configure/interface_switcher.md diff --git a/app/doc/tree/index.html b/app/doc/tree/index.html new file mode 100644 index 000000000..b3c3206f6 --- /dev/null +++ b/app/doc/tree/index.html @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/lab/package.json b/app/lab/package.json index 6ba6e57d2..c49dcc7bf 100644 --- a/app/lab/package.json +++ b/app/lab/package.json @@ -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", @@ -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", @@ -212,6 +214,7 @@ "@jupyterlab/vega5-extension", "@jupyterlite/application-extension", "@jupyterlite/iframe-extension", + "@jupyterlite/notebook-application-extension", "@jupyterlite/server-extension" ], "singletonPackages": [ @@ -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": "", diff --git a/app/webpack.config.js b/app/webpack.config.js index a167660c1..d6be92131 100644 --- a/app/webpack.config.js +++ b/app/webpack.config.js @@ -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); @@ -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(); }); } diff --git a/docs/howto/configure/interface_switcher.md b/docs/howto/configure/interface_switcher.md new file mode 100644 index 000000000..a3230ab94 --- /dev/null +++ b/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. diff --git a/docs/howto/index.md b/docs/howto/index.md index fb4ecbb4c..798456fa1 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -23,6 +23,7 @@ configure/settings configure/translation configure/rtc configure/config_files +configure/interface_switcher ``` ## Contents diff --git a/examples/jupyter_lite_config.json b/examples/jupyter_lite_config.json index afbe17d33..f823626d5 100644 --- a/examples/jupyter_lite_config.json +++ b/examples/jupyter_lite_config.json @@ -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" diff --git a/packages/application-extension/src/index.tsx b/packages/application-extension/src/index.tsx index bf53269a7..57f4644c8 100644 --- a/packages/application-extension/src/index.tsx +++ b/packages/application-extension/src/index.tsx @@ -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. @@ -333,14 +333,28 @@ const opener: JupyterFrontEndPlugin = { 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') { diff --git a/packages/settings/src/settings.ts b/packages/settings/src/settings.ts index 8ec6f40db..136673ce7 100644 --- a/packages/settings/src/settings.ts +++ b/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. @@ -87,26 +86,27 @@ export class Settings implements ISettings { async get(pluginId: string): Promise { 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; @@ -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 { - 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 { + 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; @@ -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 * diff --git a/packages/settings/src/tokens.ts b/packages/settings/src/tokens.ts index f20dd8989..7a533458f 100644 --- a/packages/settings/src/tokens.ts +++ b/packages/settings/src/tokens.ts @@ -7,6 +7,11 @@ import { JSONObject, PartialJSONObject, Token } from '@lumino/coreutils'; */ export const ISettings = new Token('@jupyterlite/settings:ISettings'); +/** + * The settings file to request + */ +export type SettingsFile = 'all.json' | 'all_federated.json'; + /** * An interface for the plugin settings. */ diff --git a/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py b/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py index 172b6abf2..122614172 100644 --- a/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py +++ b/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py @@ -8,6 +8,7 @@ from traitlets import List, Unicode from ..constants import ( + ALL_FEDERATED_JSON, FEDERATED_EXTENSIONS, JSON_FMT, JUPYTER_CONFIG_DATA, @@ -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""" @@ -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 diff --git a/py/jupyterlite-core/jupyterlite_core/constants.py b/py/jupyterlite-core/jupyterlite_core/constants.py index 3c9b32bcf..2b48808b0 100644 --- a/py/jupyterlite-core/jupyterlite_core/constants.py +++ b/py/jupyterlite-core/jupyterlite_core/constants.py @@ -80,8 +80,9 @@ #: Needs a better canonical location DEFAULT_OUTPUT_DIR = "_output" -#: commonly-used filename for response fixtures, e.g. settings +#: commonly-used filenames for response fixtures, e.g. settings ALL_JSON = "all.json" +ALL_FEDERATED_JSON = "all_federated.json" ### Environment Variables diff --git a/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py b/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py index a12dd49f3..2080ed791 100644 --- a/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py +++ b/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py @@ -210,7 +210,8 @@ def test_build_repl_no_sourcemaps(an_empty_lite_dir, script_runner): """does (re-)building create a predictable pattern of file counts""" out = an_empty_lite_dir / "_output" - args = original_args = "jupyter", "lite", "build" + # disable the federated_extensions addon as it may output settings for federated extensions + args = original_args = "jupyter", "lite", "build", "--disable-addons", "federated_extensions" status = script_runner.run(args, cwd=str(an_empty_lite_dir)) norm_files = sorted(out.rglob("*")) assert status.success diff --git a/py/jupyterlite-core/pyproject.toml b/py/jupyterlite-core/pyproject.toml index 8b85cde45..1a5470a9c 100644 --- a/py/jupyterlite-core/pyproject.toml +++ b/py/jupyterlite-core/pyproject.toml @@ -65,6 +65,7 @@ libarchive = [ ] lab = [ "jupyterlab >=4.0.7,<5.0", + "notebook >=7.0.6,<8.0", ] contents = [ "jupyter_server", @@ -84,6 +85,7 @@ all = [ "jupyterlab >=4.0.7,<5.0", "jupyterlab_server >=2.8.1,<3", "libarchive-c >=4.0", + "notebook >=7.0.6,<8.0", "pkginfo", "tornado >=6.1", ] diff --git a/py/jupyterlite/pyproject.toml b/py/jupyterlite/pyproject.toml index 449714015..f8d828718 100644 --- a/py/jupyterlite/pyproject.toml +++ b/py/jupyterlite/pyproject.toml @@ -52,6 +52,7 @@ libarchive = [ ] lab = [ "jupyterlab >=4.0.7,<5.0", + "notebook >=7.0.6,<8.0", ] contents = [ "jupyter_server", @@ -71,6 +72,7 @@ all = [ "jupyterlab >=4.0.7,<5.0", "jupyterlab_server >=2.8.1,<3", "libarchive-c >=4.0", + "notebook >=7.0.6,<8.0", "pkginfo", "tornado >=6.1", ] diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 332658c6b..5d2fe6d17 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -3,6 +3,7 @@ "include": [ "*", ".eslintrc.js", + "app/**/*.config.js", "docs/**/*", "packages/**/src/**/*", "packages/**/test/**/*", diff --git a/ui-tests/requirements.txt b/ui-tests/requirements.txt index 53e8e1a67..e3238f7b7 100644 --- a/ui-tests/requirements.txt +++ b/ui-tests/requirements.txt @@ -1,5 +1,6 @@ ../py/jupyterlite-javascript-kernel -jupyterlite-pyodide-kernel==0.2.0a2 -jupyterlite-p5-kernel==0.1.1 jupyterlab-language-pack-fr-FR +jupyterlite-p5-kernel==0.1.1 +jupyterlite-pyodide-kernel==0.2.0a2 +notebook==7.0.6 theme-darcula==4.0.0 diff --git a/ui-tests/test/notebook.test.ts b/ui-tests/test/notebook.test.ts index eca427bdc..938759717 100644 --- a/ui-tests/test/notebook.test.ts +++ b/ui-tests/test/notebook.test.ts @@ -143,3 +143,58 @@ test.describe('Notebook favicons', () => { expect(finalFavicon).toEqual(favicon); }); }); + +test.describe('Switch between Notebook and JupyterLab', () => { + const NOTEBOOK = 'empty.ipynb'; + + test.use({ + waitForApplication: async function ({ baseURL }, use, testInfo) { + const waitIsReady = async (page): Promise => { + const selector = page.url().includes('lab') + ? `text=${NOTEBOOK}` + : '.jp-NotebookPanel'; + await page.waitForSelector(selector); + }; + await use(waitIsReady); + }, + }); + + test('Open the notebook file browser', async ({ page }) => { + await page.goto('lab/index.html'); + + const [treePage] = await Promise.all([ + page.waitForEvent('popup'), + page.menu.clickMenuItem('Help>Launch Jupyter Notebook File Browser'), + ]); + + await treePage.waitForSelector('#filebrowser'); + + expect(treePage.url()).toContain('tree'); + }); + + test('Open a notebook with JupyterLab', async ({ page }) => { + await page.goto(`notebooks/index.html?path=${NOTEBOOK}`); + + const [labPage] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('.jp-ToolbarButtonComponent >> text=JupyterLab').first().click(), + ]); + + await labPage.waitForSelector('.jp-NotebookPanel'); + + expect(labPage.url()).toContain('lab'); + }); + + test('Open a notebook with Notebook', async ({ page }) => { + await page.goto(`lab/index.html?path=${NOTEBOOK}`); + + const [notebookPage] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('.jp-ToolbarButtonComponent >> text=Notebook').first().click(), + ]); + + await notebookPage.waitForSelector('.jp-NotebookPanel'); + + expect(notebookPage.url()).toContain('notebooks'); + }); +}); diff --git a/yarn.lock b/yarn.lock index bcf66b33a..d8575b7e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4118,6 +4118,7 @@ __metadata: "@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