File size: 13,897 Bytes
d1ceb73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
"""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:]