From bef83068e1244998ea86468cc243d7d9bb549766 Mon Sep 17 00:00:00 2001 From: Michal Franczel Date: Wed, 14 Oct 2026 12:46:58 +0200 Subject: [PATCH 1/3] feat(widgets): serve widget library files from Jupyter Server Add a Jupyter Server extension that answers GET /deepnote/widget-assets// with a file from the project's environment: a JupyterLab extension file, bokeh's static JS or panel's dist. Requests need the Jupyter token, only .js, .css and .json files inside those folders are served, and packages are never imported. --- deepnote_core/resources/jupyter/jupyter_server_config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deepnote_core/resources/jupyter/jupyter_server_config.py b/deepnote_core/resources/jupyter/jupyter_server_config.py index 9e9641bd..fa92d431 100644 --- a/deepnote_core/resources/jupyter/jupyter_server_config.py +++ b/deepnote_core/resources/jupyter/jupyter_server_config.py @@ -459,11 +459,13 @@ # Default: {} # c.ServerApp.nbserver_extensions = {} -## Enable jupyter_server_terminals extension for terminal support -# and jupyter_resource_usage for resource usage tracking through API +## Enable jupyter_server_terminals extension for terminal support, +# jupyter_resource_usage for resource usage tracking through API +# and deepnote_core.widget_assets to serve widget files c.ServerApp.jpserver_extensions = { "jupyter_server_terminals": True, "jupyter_resource_usage": True, + "deepnote_core.widget_assets": True, } ## The directory to use for notebooks and kernels. From 1151bb930b240950b007db523bbdec11dbe5393b Mon Sep 17 00:00:00 2001 From: Michal Franczel Date: Wed, 14 Oct 2026 12:47:15 +0200 Subject: [PATCH 2/3] feat(widgets): serve widget library files from Jupyter Server Add a Jupyter Server extension that answers GET /deepnote/widget-assets// with a file from the project's environment: a JupyterLab extension file, bokeh's static JS or panel's dist. Requests need the Jupyter token, only .js, .css and .json files inside those folders are served, and packages are never imported. --- deepnote_core/widget_assets.py | 125 ++++++++++++++ tests/unit/test_widget_assets.py | 288 +++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 deepnote_core/widget_assets.py create mode 100644 tests/unit/test_widget_assets.py diff --git a/deepnote_core/widget_assets.py b/deepnote_core/widget_assets.py new file mode 100644 index 00000000..1a406d5f --- /dev/null +++ b/deepnote_core/widget_assets.py @@ -0,0 +1,125 @@ +"""Jupyter Server extension that serves widget files. + +``GET /deepnote/widget-assets//`` returns a file installed in the +project's environment. Requests need the Jupyter token. + +Where each kind is looked up: + +- ``labextensions``: ``/`` in an installed JupyterLab extension. + Scoped modules take two segments, like ``@bokeh/jupyter_bokeh``. +- ``bokeh``: the ``server/static/js`` folder of the bokeh package. +- ``panel``: the ``dist`` folder of the panel package. + +Only ``.js``, ``.css`` and ``.json`` files are served. Packages are found +without importing them, so no user code runs in the server. +""" + +import importlib.util +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +from jupyter_core.paths import jupyter_path +from jupyter_server.base.handlers import APIHandler +from jupyter_server.utils import url_path_join +from tornado import web + +if TYPE_CHECKING: + from jupyter_server.serverapp import ServerApp + +CONTENT_TYPES: Dict[str, str] = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", +} + +# One path segment: letters, digits and "_.@-", not starting with a dot +_PATH_SEGMENT = re.compile(r"[A-Za-z0-9_@-][A-Za-z0-9_.@-]*") + +_PACKAGE_STATIC_DIRS: Dict[str, List[str]] = { + "bokeh": ["server", "static", "js"], + "panel": ["dist"], +} + + +def find_widget_asset(kind: str, path: str) -> Optional[Path]: + """Find the file a widget asset request asks for. + + Args: + kind: ``labextensions``, ``bokeh`` or ``panel``. + path: The ``/``-separated path after the kind. + + Returns: + The file's real path, or None if the path isn't allowed or the file + is missing. + """ + segments = path.split("/") + if not all(_PATH_SEGMENT.fullmatch(segment) for segment in segments): + return None + + if kind == "labextensions": + return _find_labextension_file(segments) + + if kind not in _PACKAGE_STATIC_DIRS: + return None + package_dir = _find_package_dir(kind) + if package_dir is None: + return None + return _resolve_inside(package_dir.joinpath(*_PACKAGE_STATIC_DIRS[kind]), segments) + + +def _find_labextension_file(segments: List[str]) -> Optional[Path]: + module_length = 2 if segments[0].startswith("@") else 1 + module, file_segments = segments[:module_length], segments[module_length:] + if not file_segments: + return None + + roots = [Path(root) for root in jupyter_path("labextensions")] + roots.append(Path(sys.base_prefix, "share", "jupyter", "labextensions")) + for root in dict.fromkeys(roots): + module_dir = root.joinpath(*module) + if module_dir.joinpath("package.json").is_file(): + return _resolve_inside(module_dir, file_segments) + return None + + +def _find_package_dir(name: str) -> Optional[Path]: + try: + spec = importlib.util.find_spec(name) + except (ImportError, ValueError): + return None + if spec is None or not spec.submodule_search_locations: + return None + return Path(list(spec.submodule_search_locations)[0]) + + +def _resolve_inside(root: Path, segments: List[str]) -> Optional[Path]: + try: + real_root = root.resolve(strict=True) + candidate = real_root.joinpath(*segments).resolve(strict=True) + except (OSError, RuntimeError): + return None + if not candidate.is_relative_to(real_root) or not candidate.is_file(): + return None + if candidate.suffix not in CONTENT_TYPES: + return None + return candidate + + +class WidgetAssetHandler(APIHandler): + """Serves widget files to requests that carry the Jupyter token.""" + + @web.authenticated + def get(self, kind: str, path: str) -> None: + """Send the requested file, or 404 when there isn't one.""" + asset = find_widget_asset(kind, path) + if asset is None: + raise web.HTTPError(404) + self.finish(asset.read_bytes(), set_content_type=CONTENT_TYPES[asset.suffix]) + + +def _load_jupyter_server_extension(serverapp: "ServerApp") -> None: + """Add the widget asset route when Jupyter Server starts.""" + route = url_path_join(serverapp.base_url, "deepnote/widget-assets/([a-z]+)/(.+)") + serverapp.web_app.add_handlers(".*$", [(route, WidgetAssetHandler)]) diff --git a/tests/unit/test_widget_assets.py b/tests/unit/test_widget_assets.py new file mode 100644 index 00000000..753f6ddd --- /dev/null +++ b/tests/unit/test_widget_assets.py @@ -0,0 +1,288 @@ +"""Tests for the widget asset Jupyter Server extension.""" + +import os +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Dict + +import pytest + +pytest.importorskip("jupyter_server") + +import tornado.web # noqa: E402 +from jupyter_server.auth import IdentityProvider # noqa: E402 +from tornado.httpclient import HTTPResponse # noqa: E402 +from tornado.testing import AsyncHTTPTestCase # noqa: E402 +from traitlets.config.loader import PyFileConfigLoader # noqa: E402 + +from deepnote_core.widget_assets import ( # noqa: E402 + _load_jupyter_server_extension, + find_widget_asset, +) + + +def make_labextension(data_dir: Path, module: str, files: Dict[str, str]) -> Path: + """Create a labextension folder with package.json and the given files.""" + module_dir = data_dir.joinpath("labextensions", *module.split("/")) + module_dir.mkdir(parents=True) + (module_dir / "package.json").write_text("{}") + for name, content in files.items(): + file = module_dir.joinpath(*name.split("/")) + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(content) + return module_dir + + +@pytest.fixture +def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A temporary Jupyter data folder that is searched first.""" + path = tmp_path / "data" + path.mkdir() + monkeypatch.setenv("JUPYTER_PATH", str(path)) + return path + + +class TestLabextensions: + """Finding files in JupyterLab extensions.""" + + def test_finds_file_in_extension(self, data_dir: Path) -> None: + module_dir = make_labextension( + data_dir, "fake-matplotlib", {"static/remoteEntry.x.js": "entry"} + ) + + asset = find_widget_asset( + "labextensions", "fake-matplotlib/static/remoteEntry.x.js" + ) + + assert asset == (module_dir / "static" / "remoteEntry.x.js").resolve() + + def test_finds_file_in_scoped_extension(self, data_dir: Path) -> None: + module_dir = make_labextension(data_dir, "@fake-scope/fake_bokeh", {}) + + asset = find_widget_asset( + "labextensions", "@fake-scope/fake_bokeh/package.json" + ) + + assert asset == (module_dir / "package.json").resolve() + + def test_first_data_dir_with_extension_wins( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + first, second = tmp_path / "a", tmp_path / "b" + first_dir = make_labextension(first, "fake-widgets", {"x.js": "a"}) + make_labextension(second, "fake-widgets", {"x.js": "b", "only-b.js": "b"}) + monkeypatch.setenv("JUPYTER_PATH", f"{first}{os.pathsep}{second}") + + assert ( + find_widget_asset("labextensions", "fake-widgets/x.js") + == (first_dir / "x.js").resolve() + ) + assert find_widget_asset("labextensions", "fake-widgets/only-b.js") is None + + def test_skips_extension_folder_without_package_json( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + first, second = tmp_path / "a", tmp_path / "b" + (first / "labextensions" / "fake-widgets").mkdir(parents=True) + (first / "labextensions" / "fake-widgets" / "x.js").write_text("a") + second_dir = make_labextension(second, "fake-widgets", {"x.js": "b"}) + monkeypatch.setenv("JUPYTER_PATH", f"{first}{os.pathsep}{second}") + + asset = find_widget_asset("labextensions", "fake-widgets/x.js") + + assert asset == (second_dir / "x.js").resolve() + + def test_finds_extension_under_base_prefix( + self, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + base_prefix = tmp_path / "base" + module_dir = make_labextension( + base_prefix / "share" / "jupyter", "fake-base-widgets", {"x.js": "x"} + ) + monkeypatch.setattr(sys, "base_prefix", str(base_prefix)) + + asset = find_widget_asset("labextensions", "fake-base-widgets/x.js") + + assert asset == (module_dir / "x.js").resolve() + + def test_serves_symlinked_extension_folder( + self, data_dir: Path, tmp_path: Path + ) -> None: + target = make_labextension(tmp_path / "elsewhere", "fake-widgets", {"x.js": ""}) + (data_dir / "labextensions").mkdir() + (data_dir / "labextensions" / "fake-widgets").symlink_to(target) + + asset = find_widget_asset("labextensions", "fake-widgets/x.js") + + assert asset == (target / "x.js").resolve() + + def test_rejects_symlink_to_file_outside_extension( + self, data_dir: Path, tmp_path: Path + ) -> None: + module_dir = make_labextension(data_dir, "fake-widgets", {}) + secret = tmp_path / "secret.js" + secret.write_text("secret") + (module_dir / "x.js").symlink_to(secret) + + assert find_widget_asset("labextensions", "fake-widgets/x.js") is None + + @pytest.mark.parametrize( + "path", + [ + "fake-widgets/../fake-widgets/x.js", + "fake-widgets/./x.js", + "fake-widgets//x.js", + "fake-widgets/.hidden.js", + "fake-widgets/a~b.js", + "fake-widgets/a\\b.js", + "fake-widgets/a\x00b.js", + "fake-widgets/a%2eb.js", + "fake-widgets/x.js\n", + "fake-widgets/x.py", + "fake-widgets/x.txt", + "fake-widgets/x.svg", + "fake-widgets/x.mjs", + "fake-widgets/x.js.map", + "fake-widgets/static", + "fake-widgets/missing.js", + "fake-widgets", + "@fake-scope/fake-widgets", + "missing-widgets/x.js", + ], + ) + def test_rejects_invalid_or_missing_file(self, data_dir: Path, path: str) -> None: + names = [".hidden.js", "a~b.js", "a\\b.js", "a%2eb.js", "static/y.js", "x.js"] + names += ["x.py", "x.txt", "x.svg", "x.mjs", "x.js.map"] + make_labextension(data_dir, "fake-widgets", {name: "" for name in names}) + make_labextension(data_dir, "@fake-scope/fake-widgets", {}) + + assert find_widget_asset("labextensions", path) is None + + def test_rejects_unknown_kind(self, data_dir: Path) -> None: + make_labextension(data_dir, "fake-widgets", {"x.js": ""}) + + assert find_widget_asset("nbextensions", "fake-widgets/x.js") is None + + +@pytest.fixture +def site_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Fake bokeh and panel packages that fail when imported.""" + site = tmp_path / "site" + for name, static_dir in (("bokeh", "server/static/js"), ("panel", "dist")): + monkeypatch.delitem(sys.modules, name, raising=False) + package = site / name + static = package.joinpath(*static_dir.split("/")) + (static / "lib").mkdir(parents=True) + (static / f"{name}.min.js").write_text(name) + (static / "lib" / "x.js").write_text("lib") + (package / "__init__.py").write_text("raise RuntimeError('imported')") + monkeypatch.syspath_prepend(str(site)) + return site + + +class TestPackageAssets: + """Finding static files of the bokeh and panel packages.""" + + def test_finds_bokeh_files_without_importing_it(self, site_dir: Path) -> None: + static = site_dir / "bokeh" / "server" / "static" / "js" + + assert ( + find_widget_asset("bokeh", "bokeh.min.js") + == (static / "bokeh.min.js").resolve() + ) + assert ( + find_widget_asset("bokeh", "lib/x.js") + == (static / "lib" / "x.js").resolve() + ) + assert "bokeh" not in sys.modules + + def test_finds_panel_files_without_importing_it(self, site_dir: Path) -> None: + asset = find_widget_asset("panel", "panel.min.js") + + assert asset == (site_dir / "panel" / "dist" / "panel.min.js").resolve() + assert "panel" not in sys.modules + + def test_rejects_symlink_out_of_static_folder(self, site_dir: Path) -> None: + package = site_dir / "bokeh" + (package / "config.js").write_text("not a static file") + (package / "server" / "static" / "js" / "config.js").symlink_to( + package / "config.js" + ) + + assert find_widget_asset("bokeh", "config.js") is None + + @pytest.mark.parametrize("kind", ["bokeh", "panel"]) + def test_returns_none_when_package_is_missing( + self, kind: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delitem(sys.modules, kind, raising=False) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) + + assert find_widget_asset(kind, f"{kind}.min.js") is None + + def test_returns_none_when_module_has_no_spec( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + module = ModuleType("bokeh") + module.__spec__ = None + monkeypatch.setitem(sys.modules, "bokeh", module) + + assert find_widget_asset("bokeh", "bokeh.min.js") is None + + +class TestWidgetAssetHandler(AsyncHTTPTestCase): + """The HTTP route, with token auth set up like in production.""" + + @pytest.fixture(autouse=True) + def _labextension(self, data_dir: Path) -> None: + make_labextension(data_dir, "fake-widgets", {"x.js": "console.log(1)"}) + + def get_app(self) -> tornado.web.Application: + app = tornado.web.Application( + identity_provider=IdentityProvider(token="secret"), + cookie_secret=b"test", + base_url="/", + allow_unauthenticated_access=True, + ) + _load_jupyter_server_extension(SimpleNamespace(base_url="/", web_app=app)) + return app + + def fetch_asset(self, path: str, token: str = "") -> HTTPResponse: + headers = {"Authorization": f"Bearer {token}"} if token else {} + return self.fetch( + f"/deepnote/widget-assets/{path}", headers=headers, follow_redirects=False + ) + + def test_rejects_request_without_token(self) -> None: + response = self.fetch_asset("labextensions/fake-widgets/x.js") + + assert response.code == 403 + + def test_rejects_wrong_token(self) -> None: + response = self.fetch_asset("labextensions/fake-widgets/x.js", token="wrong") + + assert response.code == 403 + + def test_serves_file_with_token(self) -> None: + response = self.fetch_asset("labextensions/fake-widgets/x.js", token="secret") + + assert response.code == 200 + assert response.body == b"console.log(1)" + assert response.headers["Content-Type"] == "text/javascript; charset=utf-8" + + def test_returns_404_for_encoded_parent_segments(self) -> None: + response = self.fetch_asset( + "labextensions/fake-widgets/%2e%2e/fake-widgets/x.js", token="secret" + ) + + assert response.code == 404 + + +def test_jupyter_config_enables_extension() -> None: + config_dir = Path(__file__).parents[2] / "deepnote_core" / "resources" / "jupyter" + loader = PyFileConfigLoader("jupyter_server_config.py", path=str(config_dir)) + + extensions = loader.load_config().ServerApp.jpserver_extensions + + assert extensions[_load_jupyter_server_extension.__module__] is True From 66667ec545a26afcb70845d13f1039aea23f2937 Mon Sep 17 00:00:00 2001 From: Michal Franczel Date: Wed, 14 Oct 2026 13:27:05 +0200 Subject: [PATCH 3/3] docs(widgets): add one-line docstrings to widget asset helpers and tests --- deepnote_core/widget_assets.py | 3 +++ tests/unit/test_widget_assets.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/deepnote_core/widget_assets.py b/deepnote_core/widget_assets.py index 1a406d5f..75965847 100644 --- a/deepnote_core/widget_assets.py +++ b/deepnote_core/widget_assets.py @@ -70,6 +70,7 @@ def find_widget_asset(kind: str, path: str) -> Optional[Path]: def _find_labextension_file(segments: List[str]) -> Optional[Path]: + """Find a file in the first installed copy of a JupyterLab extension.""" module_length = 2 if segments[0].startswith("@") else 1 module, file_segments = segments[:module_length], segments[module_length:] if not file_segments: @@ -85,6 +86,7 @@ def _find_labextension_file(segments: List[str]) -> Optional[Path]: def _find_package_dir(name: str) -> Optional[Path]: + """Return the folder of an installed package without importing it.""" try: spec = importlib.util.find_spec(name) except (ImportError, ValueError): @@ -95,6 +97,7 @@ def _find_package_dir(name: str) -> Optional[Path]: def _resolve_inside(root: Path, segments: List[str]) -> Optional[Path]: + """Return the real path of a servable file inside ``root``, or None.""" try: real_root = root.resolve(strict=True) candidate = real_root.joinpath(*segments).resolve(strict=True) diff --git a/tests/unit/test_widget_assets.py b/tests/unit/test_widget_assets.py index 753f6ddd..bbba3d5f 100644 --- a/tests/unit/test_widget_assets.py +++ b/tests/unit/test_widget_assets.py @@ -47,6 +47,7 @@ class TestLabextensions: """Finding files in JupyterLab extensions.""" def test_finds_file_in_extension(self, data_dir: Path) -> None: + """A file in an installed extension is found.""" module_dir = make_labextension( data_dir, "fake-matplotlib", {"static/remoteEntry.x.js": "entry"} ) @@ -58,6 +59,7 @@ def test_finds_file_in_extension(self, data_dir: Path) -> None: assert asset == (module_dir / "static" / "remoteEntry.x.js").resolve() def test_finds_file_in_scoped_extension(self, data_dir: Path) -> None: + """A scoped extension like ``@scope/name`` is found.""" module_dir = make_labextension(data_dir, "@fake-scope/fake_bokeh", {}) asset = find_widget_asset( @@ -69,6 +71,7 @@ def test_finds_file_in_scoped_extension(self, data_dir: Path) -> None: def test_first_data_dir_with_extension_wins( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Only the first data folder that has the extension is used.""" first, second = tmp_path / "a", tmp_path / "b" first_dir = make_labextension(first, "fake-widgets", {"x.js": "a"}) make_labextension(second, "fake-widgets", {"x.js": "b", "only-b.js": "b"}) @@ -83,6 +86,7 @@ def test_first_data_dir_with_extension_wins( def test_skips_extension_folder_without_package_json( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """A folder without package.json isn't treated as an extension.""" first, second = tmp_path / "a", tmp_path / "b" (first / "labextensions" / "fake-widgets").mkdir(parents=True) (first / "labextensions" / "fake-widgets" / "x.js").write_text("a") @@ -96,6 +100,7 @@ def test_skips_extension_folder_without_package_json( def test_finds_extension_under_base_prefix( self, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Extensions under ``sys.base_prefix`` are found.""" base_prefix = tmp_path / "base" module_dir = make_labextension( base_prefix / "share" / "jupyter", "fake-base-widgets", {"x.js": "x"} @@ -109,6 +114,7 @@ def test_finds_extension_under_base_prefix( def test_serves_symlinked_extension_folder( self, data_dir: Path, tmp_path: Path ) -> None: + """A symlinked extension folder is followed.""" target = make_labextension(tmp_path / "elsewhere", "fake-widgets", {"x.js": ""}) (data_dir / "labextensions").mkdir() (data_dir / "labextensions" / "fake-widgets").symlink_to(target) @@ -120,6 +126,7 @@ def test_serves_symlinked_extension_folder( def test_rejects_symlink_to_file_outside_extension( self, data_dir: Path, tmp_path: Path ) -> None: + """A symlink pointing outside the extension is refused.""" module_dir = make_labextension(data_dir, "fake-widgets", {}) secret = tmp_path / "secret.js" secret.write_text("secret") @@ -152,6 +159,7 @@ def test_rejects_symlink_to_file_outside_extension( ], ) def test_rejects_invalid_or_missing_file(self, data_dir: Path, path: str) -> None: + """Invalid paths, other file types and missing files return None.""" names = [".hidden.js", "a~b.js", "a\\b.js", "a%2eb.js", "static/y.js", "x.js"] names += ["x.py", "x.txt", "x.svg", "x.mjs", "x.js.map"] make_labextension(data_dir, "fake-widgets", {name: "" for name in names}) @@ -160,6 +168,7 @@ def test_rejects_invalid_or_missing_file(self, data_dir: Path, path: str) -> Non assert find_widget_asset("labextensions", path) is None def test_rejects_unknown_kind(self, data_dir: Path) -> None: + """Kinds other than labextensions, bokeh and panel return None.""" make_labextension(data_dir, "fake-widgets", {"x.js": ""}) assert find_widget_asset("nbextensions", "fake-widgets/x.js") is None @@ -185,6 +194,7 @@ class TestPackageAssets: """Finding static files of the bokeh and panel packages.""" def test_finds_bokeh_files_without_importing_it(self, site_dir: Path) -> None: + """Bokeh's static files are found without importing bokeh.""" static = site_dir / "bokeh" / "server" / "static" / "js" assert ( @@ -198,12 +208,14 @@ def test_finds_bokeh_files_without_importing_it(self, site_dir: Path) -> None: assert "bokeh" not in sys.modules def test_finds_panel_files_without_importing_it(self, site_dir: Path) -> None: + """Panel's dist files are found without importing panel.""" asset = find_widget_asset("panel", "panel.min.js") assert asset == (site_dir / "panel" / "dist" / "panel.min.js").resolve() assert "panel" not in sys.modules def test_rejects_symlink_out_of_static_folder(self, site_dir: Path) -> None: + """A symlink out of the static folder is refused.""" package = site_dir / "bokeh" (package / "config.js").write_text("not a static file") (package / "server" / "static" / "js" / "config.js").symlink_to( @@ -216,6 +228,7 @@ def test_rejects_symlink_out_of_static_folder(self, site_dir: Path) -> None: def test_returns_none_when_package_is_missing( self, kind: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """A package that isn't installed returns None.""" monkeypatch.delitem(sys.modules, kind, raising=False) monkeypatch.setattr(sys, "path", [str(tmp_path)]) @@ -224,6 +237,7 @@ def test_returns_none_when_package_is_missing( def test_returns_none_when_module_has_no_spec( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """A loaded module without a spec returns None.""" module = ModuleType("bokeh") module.__spec__ = None monkeypatch.setitem(sys.modules, "bokeh", module) @@ -236,9 +250,11 @@ class TestWidgetAssetHandler(AsyncHTTPTestCase): @pytest.fixture(autouse=True) def _labextension(self, data_dir: Path) -> None: + """Install a fake extension for every test.""" make_labextension(data_dir, "fake-widgets", {"x.js": "console.log(1)"}) def get_app(self) -> tornado.web.Application: + """Build an app with the route and token auth.""" app = tornado.web.Application( identity_provider=IdentityProvider(token="secret"), cookie_secret=b"test", @@ -249,22 +265,26 @@ def get_app(self) -> tornado.web.Application: return app def fetch_asset(self, path: str, token: str = "") -> HTTPResponse: + """Request an asset, with a Bearer token if one is given.""" headers = {"Authorization": f"Bearer {token}"} if token else {} return self.fetch( f"/deepnote/widget-assets/{path}", headers=headers, follow_redirects=False ) def test_rejects_request_without_token(self) -> None: + """No token gets 403, not a redirect to login.""" response = self.fetch_asset("labextensions/fake-widgets/x.js") assert response.code == 403 def test_rejects_wrong_token(self) -> None: + """A wrong token gets 403.""" response = self.fetch_asset("labextensions/fake-widgets/x.js", token="wrong") assert response.code == 403 def test_serves_file_with_token(self) -> None: + """The right token gets the file with its content type.""" response = self.fetch_asset("labextensions/fake-widgets/x.js", token="secret") assert response.code == 200 @@ -272,6 +292,7 @@ def test_serves_file_with_token(self) -> None: assert response.headers["Content-Type"] == "text/javascript; charset=utf-8" def test_returns_404_for_encoded_parent_segments(self) -> None: + """Encoded ``..`` segments get 404.""" response = self.fetch_asset( "labextensions/fake-widgets/%2e%2e/fake-widgets/x.js", token="secret" ) @@ -280,6 +301,7 @@ def test_returns_404_for_encoded_parent_segments(self) -> None: def test_jupyter_config_enables_extension() -> None: + """The Jupyter config turns the extension on.""" config_dir = Path(__file__).parents[2] / "deepnote_core" / "resources" / "jupyter" loader = PyFileConfigLoader("jupyter_server_config.py", path=str(config_dir))