You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""JupyterLab Server handlers"""
|
|
|
|
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pathlib
|
|
import warnings
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlparse
|
|
|
|
from jupyter_server.base.handlers import FileFindHandler, JupyterHandler
|
|
from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin
|
|
from jupyter_server.utils import url_path_join as ujoin
|
|
from tornado import template, web
|
|
|
|
from .config import LabConfig, get_page_config, recursive_update
|
|
from .licenses_handler import LicensesHandler, LicensesManager
|
|
from .listings_handler import ListingsHandler, fetch_listings
|
|
from .settings_handler import SettingsHandler
|
|
from .settings_utils import _get_overrides
|
|
from .themes_handler import ThemesHandler
|
|
from .translations_handler import TranslationsHandler
|
|
from .workspaces_handler import WorkspacesHandler, WorkspacesManager
|
|
|
|
if TYPE_CHECKING:
|
|
from .app import LabServerApp
|
|
# -----------------------------------------------------------------------------
|
|
# Module globals
|
|
# -----------------------------------------------------------------------------
|
|
|
|
MASTER_URL_PATTERN = (
|
|
r"/(?P<mode>{}|doc)(?P<workspace>/workspaces/[a-zA-Z0-9\-\_]+)?(?P<tree>/tree/.*)?"
|
|
)
|
|
|
|
DEFAULT_TEMPLATE = template.Template(
|
|
"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Error</title>
|
|
</head>
|
|
<body>
|
|
<h2>Cannot find template: "{{name}}"</h2>
|
|
<p>In "{{path}}"</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
)
|
|
|
|
|
|
def is_url(url: str) -> bool:
|
|
"""Test whether a string is a full url (e.g. https://nasa.gov)
|
|
|
|
https://stackoverflow.com/a/52455972
|
|
"""
|
|
try:
|
|
result = urlparse(url)
|
|
return all([result.scheme, result.netloc])
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
class LabHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler):
|
|
"""Render the JupyterLab View."""
|
|
|
|
@lru_cache # noqa: B019
|
|
def get_page_config(self) -> dict[str, Any]:
|
|
"""Construct the page config object"""
|
|
self.application.store_id = getattr( # type:ignore[attr-defined]
|
|
self.application, "store_id", 0
|
|
)
|
|
config = LabConfig()
|
|
app: LabServerApp = self.extensionapp # type:ignore[assignment]
|
|
settings_dir = app.app_settings_dir
|
|
# Handle page config data.
|
|
page_config = self.settings.setdefault("page_config_data", {})
|
|
terminals = self.settings.get("terminals_available", False)
|
|
server_root = self.settings.get("server_root_dir", "")
|
|
server_root = server_root.replace(os.sep, "/")
|
|
base_url = self.settings.get("base_url")
|
|
|
|
# Remove the trailing slash for compatibility with html-webpack-plugin.
|
|
full_static_url = self.static_url_prefix.rstrip("/")
|
|
page_config.setdefault("fullStaticUrl", full_static_url)
|
|
|
|
page_config.setdefault("terminalsAvailable", terminals)
|
|
page_config.setdefault("ignorePlugins", [])
|
|
page_config.setdefault("serverRoot", server_root)
|
|
page_config["store_id"] = self.application.store_id # type:ignore[attr-defined]
|
|
|
|
server_root = os.path.normpath(os.path.expanduser(server_root))
|
|
preferred_path = ""
|
|
try:
|
|
preferred_path = self.serverapp.contents_manager.preferred_dir
|
|
except Exception:
|
|
# FIXME: Remove fallback once CM.preferred_dir is ubiquitous.
|
|
try:
|
|
# Remove the server_root from app pref dir
|
|
if self.serverapp.preferred_dir and self.serverapp.preferred_dir != server_root:
|
|
preferred_path = (
|
|
pathlib.Path(self.serverapp.preferred_dir)
|
|
.relative_to(server_root)
|
|
.as_posix()
|
|
)
|
|
except Exception: # noqa: S110
|
|
pass
|
|
# JupyterLab relies on an unset/default path being "/"
|
|
page_config["preferredPath"] = preferred_path or "/"
|
|
|
|
self.application.store_id += 1 # type:ignore[attr-defined]
|
|
|
|
mathjax_config = self.settings.get("mathjax_config", "TeX-AMS_HTML-full,Safe")
|
|
# TODO Remove CDN usage.
|
|
mathjax_url = self.mathjax_url
|
|
if not mathjax_url:
|
|
mathjax_url = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js"
|
|
|
|
page_config.setdefault("mathjaxConfig", mathjax_config)
|
|
page_config.setdefault("fullMathjaxUrl", mathjax_url)
|
|
|
|
# Put all our config in page_config
|
|
for name in config.trait_names():
|
|
page_config[_camelCase(name)] = getattr(app, name)
|
|
|
|
# Add full versions of all the urls
|
|
for name in config.trait_names():
|
|
if not name.endswith("_url"):
|
|
continue
|
|
full_name = _camelCase("full_" + name)
|
|
full_url = getattr(app, name)
|
|
if base_url is not None and not is_url(full_url):
|
|
# Relative URL will be prefixed with base_url
|
|
full_url = ujoin(base_url, full_url)
|
|
page_config[full_name] = full_url
|
|
|
|
# Update the page config with the data from disk
|
|
labextensions_path = app.extra_labextensions_path + app.labextensions_path
|
|
recursive_update(
|
|
page_config, get_page_config(labextensions_path, settings_dir, logger=self.log)
|
|
)
|
|
|
|
# modify page config with custom hook
|
|
page_config_hook = self.settings.get("page_config_hook", None)
|
|
if page_config_hook:
|
|
page_config = page_config_hook(self, page_config)
|
|
|
|
return page_config
|
|
|
|
@web.authenticated
|
|
@web.removeslash
|
|
def get(
|
|
self, mode: str | None = None, workspace: str | None = None, tree: str | None = None
|
|
) -> None:
|
|
"""Get the JupyterLab html page."""
|
|
workspace = "default" if workspace is None else workspace.replace("/workspaces/", "")
|
|
tree_path = "" if tree is None else tree.replace("/tree/", "")
|
|
|
|
page_config = self.get_page_config()
|
|
|
|
# Add parameters parsed from the URL
|
|
if mode == "doc":
|
|
page_config["mode"] = "single-document"
|
|
else:
|
|
page_config["mode"] = "multiple-document"
|
|
page_config["workspace"] = workspace
|
|
page_config["treePath"] = tree_path
|
|
|
|
# Write the template with the config.
|
|
tpl = self.render_template("index.html", page_config=page_config) # type:ignore[no-untyped-call]
|
|
self.write(tpl)
|
|
|
|
|
|
class NotFoundHandler(LabHandler):
|
|
"""A handler for page not found."""
|
|
|
|
@lru_cache # noqa: B019
|
|
def get_page_config(self) -> dict[str, Any]:
|
|
"""Get the page config."""
|
|
# Making a copy of the page_config to ensure changes do not affect the original
|
|
page_config = super().get_page_config().copy()
|
|
page_config["notFoundUrl"] = self.request.path
|
|
return page_config
|
|
|
|
|
|
def add_handlers(handlers: list[Any], extension_app: LabServerApp) -> None:
|
|
"""Add the appropriate handlers to the web app."""
|
|
# Normalize directories.
|
|
for name in LabConfig.class_trait_names():
|
|
if not name.endswith("_dir"):
|
|
continue
|
|
value = getattr(extension_app, name)
|
|
setattr(extension_app, name, value.replace(os.sep, "/"))
|
|
|
|
# Normalize urls
|
|
# Local urls should have a leading slash but no trailing slash
|
|
for name in LabConfig.class_trait_names():
|
|
if not name.endswith("_url"):
|
|
continue
|
|
value = getattr(extension_app, name)
|
|
if is_url(value):
|
|
continue
|
|
if not value.startswith("/"):
|
|
value = "/" + value
|
|
if value.endswith("/"):
|
|
value = value[:-1]
|
|
setattr(extension_app, name, value)
|
|
|
|
url_pattern = MASTER_URL_PATTERN.format(extension_app.app_url.replace("/", ""))
|
|
handlers.append((url_pattern, LabHandler))
|
|
|
|
# Cache all or none of the files depending on the `cache_files` setting.
|
|
no_cache_paths = [] if extension_app.cache_files else ["/"]
|
|
|
|
# Handle federated lab extensions.
|
|
labextensions_path = extension_app.extra_labextensions_path + extension_app.labextensions_path
|
|
labextensions_url = ujoin(extension_app.labextensions_url, "(.*)")
|
|
handlers.append(
|
|
(
|
|
labextensions_url,
|
|
FileFindHandler,
|
|
{"path": labextensions_path, "no_cache_paths": no_cache_paths},
|
|
)
|
|
)
|
|
|
|
# Handle local settings.
|
|
if extension_app.schemas_dir:
|
|
# Load overrides once, rather than in each copy of the settings handler
|
|
overrides, error = _get_overrides(extension_app.app_settings_dir)
|
|
|
|
if error:
|
|
overrides_warning = "Failed loading overrides: %s"
|
|
extension_app.log.warning(overrides_warning, error)
|
|
|
|
settings_config: dict[str, Any] = {
|
|
"app_settings_dir": extension_app.app_settings_dir,
|
|
"schemas_dir": extension_app.schemas_dir,
|
|
"settings_dir": extension_app.user_settings_dir,
|
|
"labextensions_path": labextensions_path,
|
|
"overrides": overrides,
|
|
}
|
|
|
|
# Handle requests for the list of settings. Make slash optional.
|
|
settings_path = ujoin(extension_app.settings_url, "?")
|
|
handlers.append((settings_path, SettingsHandler, settings_config))
|
|
|
|
# Handle requests for an individual set of settings.
|
|
setting_path = ujoin(extension_app.settings_url, "(?P<schema_name>.+)")
|
|
handlers.append((setting_path, SettingsHandler, settings_config))
|
|
|
|
# Handle translations.
|
|
# Translations requires settings as the locale source of truth is stored in it
|
|
if extension_app.translations_api_url:
|
|
# Handle requests for the list of language packs available.
|
|
# Make slash optional.
|
|
translations_path = ujoin(extension_app.translations_api_url, "?")
|
|
handlers.append((translations_path, TranslationsHandler, settings_config))
|
|
|
|
# Handle requests for an individual language pack.
|
|
translations_lang_path = ujoin(extension_app.translations_api_url, "(?P<locale>.*)")
|
|
handlers.append((translations_lang_path, TranslationsHandler, settings_config))
|
|
|
|
# Handle saved workspaces.
|
|
if extension_app.workspaces_dir:
|
|
workspaces_config = {"manager": WorkspacesManager(extension_app.workspaces_dir)}
|
|
|
|
# Handle requests for the list of workspaces. Make slash optional.
|
|
workspaces_api_path = ujoin(extension_app.workspaces_api_url, "?")
|
|
handlers.append((workspaces_api_path, WorkspacesHandler, workspaces_config))
|
|
|
|
# Handle requests for an individually named workspace.
|
|
workspace_api_path = ujoin(extension_app.workspaces_api_url, "(?P<space_name>.+)")
|
|
handlers.append((workspace_api_path, WorkspacesHandler, workspaces_config))
|
|
|
|
# Handle local listings.
|
|
|
|
settings_config = extension_app.settings.get("config", {}).get("LabServerApp", {})
|
|
blocked_extensions_uris: str = settings_config.get("blocked_extensions_uris", "")
|
|
allowed_extensions_uris: str = settings_config.get("allowed_extensions_uris", "")
|
|
|
|
if (blocked_extensions_uris) and (allowed_extensions_uris):
|
|
warnings.warn(
|
|
"Simultaneous blocked_extensions_uris and allowed_extensions_uris is not supported. Please define only one of those.",
|
|
stacklevel=2,
|
|
)
|
|
import sys
|
|
|
|
sys.exit(-1)
|
|
|
|
ListingsHandler.listings_refresh_seconds = settings_config.get(
|
|
"listings_refresh_seconds", 60 * 60
|
|
)
|
|
ListingsHandler.listings_request_opts = settings_config.get("listings_request_options", {})
|
|
listings_url = ujoin(extension_app.listings_url)
|
|
listings_path = ujoin(listings_url, "(.*)")
|
|
|
|
if blocked_extensions_uris:
|
|
ListingsHandler.blocked_extensions_uris = set(blocked_extensions_uris.split(","))
|
|
if allowed_extensions_uris:
|
|
ListingsHandler.allowed_extensions_uris = set(allowed_extensions_uris.split(","))
|
|
|
|
fetch_listings(None)
|
|
|
|
if (
|
|
len(ListingsHandler.blocked_extensions_uris) > 0
|
|
or len(ListingsHandler.allowed_extensions_uris) > 0
|
|
):
|
|
from tornado import ioloop
|
|
|
|
callback_time = ListingsHandler.listings_refresh_seconds * 1000
|
|
ListingsHandler.pc = ioloop.PeriodicCallback(
|
|
lambda: fetch_listings(None), # type:ignore[assignment]
|
|
callback_time=callback_time,
|
|
jitter=0.1,
|
|
)
|
|
ListingsHandler.pc.start() # type:ignore[attr-defined]
|
|
|
|
handlers.append((listings_path, ListingsHandler, {}))
|
|
|
|
# Handle local themes.
|
|
if extension_app.themes_dir:
|
|
themes_url = extension_app.themes_url
|
|
themes_path = ujoin(themes_url, "(.*)")
|
|
handlers.append(
|
|
(
|
|
themes_path,
|
|
ThemesHandler,
|
|
{
|
|
"themes_url": themes_url,
|
|
"path": extension_app.themes_dir,
|
|
"labextensions_path": labextensions_path,
|
|
"no_cache_paths": no_cache_paths,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Handle licenses.
|
|
if extension_app.licenses_url:
|
|
licenses_url = extension_app.licenses_url
|
|
licenses_path = ujoin(licenses_url, "(.*)")
|
|
handlers.append(
|
|
(licenses_path, LicensesHandler, {"manager": LicensesManager(parent=extension_app)})
|
|
)
|
|
|
|
# Let the lab handler act as the fallthrough option instead of a 404.
|
|
fallthrough_url = ujoin(extension_app.app_url, r".*")
|
|
handlers.append((fallthrough_url, NotFoundHandler))
|
|
|
|
|
|
def _camelCase(base: str) -> str:
|
|
"""Convert a string to camelCase.
|
|
https://stackoverflow.com/a/20744956
|
|
"""
|
|
output = "".join(x for x in base.title() if x.isalpha())
|
|
return output[0].lower() + output[1:]
|