diff --git a/docs/customize.md b/docs/customize.md index d5bb143ce..3b5e485ff 100644 --- a/docs/customize.md +++ b/docs/customize.md @@ -226,7 +226,16 @@ There is a Voilà template cookiecutter available to give you a running start. This cookiecutter contains some docker configuration for live reloading of your template changes to make development easier. Please refer to the [cookiecutter repo](https://github.com/voila-dashboards/voila-template-cookiecutter) for more information on how to use the Voilà template cookiecutter. -### Accessing the tornado request (`prelaunch-hook`) +### Customizing Voila with Hooks + +Voila provides hooks that allow you to customize its behavior to fit your specific needs. These hooks enable you to inject custom functions at certain points during Voila's execution, giving you control over aspects like notebook execution and frontend configuration. + +Currently, Voila supports the following hooks: + +- prelaunch_hook: Access and modify the Tornado request and notebook before execution. +- page_config_hook: Customize the page_config object, which controls the Voila frontend configuration. + +#### Accessing the tornado request (`prelaunch-hook`) In certain custom setups when you need to access the tornado request object in order to check for authentication cookies, access details about the request headers, or modify the notebook before rendering. You can leverage the `prelaunch-hook`, which lets you inject a function to inspect the notebook and the request prior to executing them. @@ -240,7 +249,7 @@ Because `prelaunch-hook` only runs after receiving a new request but before the The format of this hook should be: ```python -def hook(req: tornado.web.RequestHandler, +def prelaunch_hook(req: tornado.web.RequestHandler, notebook: nbformat.NotebookNode, cwd: str) -> Optional[nbformat.NotebookNode]: ``` @@ -250,6 +259,39 @@ def hook(req: tornado.web.RequestHandler, - The last argument is the current working directory should you need to mutate anything on disk. - The return value of your hook function can either be `None`, or a `NotebookNode`. +#### Customize the page config object (`page_config_hook`) + +The page_config_hook allows you to customize the page_config object, which controls various aspects of the Voila frontend. This is useful when you need to modify frontend settings such as the URLs for static assets or other configuration parameters. + +By default, Voila uses the following page_config: + +```python +# Default page_config +page_config = { + "appVersion": __version__, + "appUrl": "voila/", + "themesUrl": "/voila/api/themes", + "baseUrl": base_url, + "terminalsAvailable": False, + "fullStaticUrl": url_path_join(base_url, "voila/static"), + "fullLabextensionsUrl": url_path_join(base_url, "voila/labextensions"), + "extensionConfig": voila_configuration.extension_config, +} +``` + +The format of this hook should be: + +```python +def page_config_hook( + current_page_config: Dict[str, Any], + base_url: str, + settings: Dict[str, Any], + log: Logger, + voila_configuration: VoilaConfiguration, + notebook_path: str +) -> Dict[str, Any]: +``` + #### Adding the hook function to Voilà There are two ways to add the hook function to Voilà: @@ -259,16 +301,22 @@ There are two ways to add the hook function to Voilà: Here is an example of the configuration file. This file needs to be placed in the directory where you start Voilà. ```python -def hook_function(req, notebook, cwd): +def prelaunch_hook_function(req, notebook, cwd): """Do your stuffs here""" return notebook -c.Voila.prelaunch_hook = hook_function +def page_config_hook_function(current_page_config, **kwargs): + """Modify the current_page_config""" + return new_page_config + +c.VoilaConfiguration.prelaunch_hook = hook_function +c.VoilaConfiguration.page_config_hook = page_config_hook + ``` - Start Voilà from a python script: -Here is an example of a custom `prelaunch-hook` to execute a notebook with `papermill`: +Here is an example of a custom `prelaunch-hook` to execute a notebook with `papermill`, and a `page_config_hook` to add a custom labextensions URL: ```python def parameterize_with_papermill(req, notebook, cwd): @@ -302,9 +350,22 @@ def parameterize_with_papermill(req, notebook, cwd): # Parameterize with papermill return parameterize_notebook(notebook, parameters) + + +def page_config_hook( + current_page_config: Dict[str, Any], + base_url: str, + settings: Dict[str, Any], + log: Logger, + voila_configuration: VoilaConfiguration, + notebook_path: str +): + page_config['fullLabextensionsUrl'] = '/custom/labextensions_url' + return page_config + ``` -To add this hook to your `Voilà` application: +You can use both hooks simultaneously to customize notebook execution and frontend configuration, to add this hooks to your `Voilà` application: ```python from voila.app import Voila @@ -313,15 +374,18 @@ from voila.config import VoilaConfiguration # customize config how you like config = VoilaConfiguration() +# set the prelaunch hook +config.prelaunch_hook = parameterize_with_papermill + +# set the page config hook +config.page_config_hook = page_config_hook + # create a voila instance app = Voila() # set the config app.voila_configuration = config -# set the prelaunch hook -app.prelaunch_hook = parameterize_with_papermill - # launch app.start() ``` diff --git a/tests/app/page_config_hook_test.py b/tests/app/page_config_hook_test.py new file mode 100644 index 000000000..2c0f74645 --- /dev/null +++ b/tests/app/page_config_hook_test.py @@ -0,0 +1,29 @@ +import os + +import pytest + + +BASE_DIR = os.path.dirname(__file__) + + +@pytest.fixture +def voila_notebook(notebook_directory): + return os.path.join(notebook_directory, "print.ipynb") + + +@pytest.fixture +def voila_config(): + def foo(current_page_config, **kwargs): + current_page_config["foo"] = "my custom config" + return current_page_config + + def config(app): + app.voila_configuration.page_config_hook = foo + + return config + + +async def test_prelaunch_hook(http_server_client, base_url): + response = await http_server_client.fetch(base_url) + assert response.code == 200 + assert "my custom config" in response.body.decode("utf-8") diff --git a/voila/app.py b/voila/app.py index 609e6d3e9..40ea2eebc 100644 --- a/voila/app.py +++ b/voila/app.py @@ -352,6 +352,16 @@ def hook(req: tornado.web.RequestHandler, ), ) + @validate("prelaunch_hook") + def _valid_prelaunch_hook(self, proposal): + warn( + "Voila.prelaunch_hook is deprecated, please use VoilaConfiguration.prelaunch_hook instead", + DeprecationWarning, + stacklevel=2, + ) + self.voila_configuration.prelaunch_hook = proposal["value"] + return proposal["value"] + if JUPYTER_SERVER_2: cookie_secret = Bytes( b"", @@ -611,7 +621,7 @@ def init_settings(self) -> Dict: preheat_kernel: bool = self.voila_configuration.preheat_kernel pool_size: int = self.voila_configuration.default_pool_size - if preheat_kernel and self.prelaunch_hook: + if preheat_kernel and self.voila_configuration.prelaunch_hook: raise Exception("`preheat_kernel` and `prelaunch_hook` are incompatible") progressive_rendering = self.voila_configuration.progressive_rendering @@ -629,6 +639,7 @@ def init_settings(self) -> Dict: self.voila_configuration.multi_kernel_manager_class, preheat_kernel, pool_size, + page_config_hook=self.voila_configuration.page_config_hook, ) self.kernel_manager = kernel_manager_class( parent=self, @@ -812,7 +823,6 @@ def init_handlers(self) -> List: "template_paths": self.template_paths, "config": self.config, "voila_configuration": self.voila_configuration, - "prelaunch_hook": self.prelaunch_hook, }, ) ) @@ -820,11 +830,15 @@ def init_handlers(self) -> List: self.log.debug("serving directory: %r", self.root_dir) handlers.extend( [ - (self.server_url, TornadoVoilaTreeHandler, tree_handler_conf), + ( + self.server_url, + TornadoVoilaTreeHandler, + {"voila_configuration": self.voila_configuration}, + ), ( url_path_join(self.server_url, r"/voila/tree" + path_regex), TornadoVoilaTreeHandler, - tree_handler_conf, + {"voila_configuration": self.voila_configuration}, ), ( url_path_join(self.server_url, r"/voila/render/(.*)"), @@ -833,7 +847,6 @@ def init_handlers(self) -> List: "template_paths": self.template_paths, "config": self.config, "voila_configuration": self.voila_configuration, - "prelaunch_hook": self.prelaunch_hook, }, ), # On serving a directory, expose the content handler. diff --git a/voila/configuration.py b/voila/configuration.py index 229aa1ec9..3c52d3d49 100644 --- a/voila/configuration.py +++ b/voila/configuration.py @@ -8,7 +8,7 @@ ############################################################################# import traitlets.config -from traitlets import Bool, Dict, Enum, Int, List, Type, Unicode, validate +from traitlets import Bool, Callable, Dict, Enum, Int, List, Type, Unicode, validate from warnings import warn @@ -218,6 +218,50 @@ def _valid_file_blacklist(self, proposal): help="""Whether or not voila should attempt to fix and resolve a notebooks kernelspec metadata""", ) + prelaunch_hook = Callable( + default_value=None, + allow_none=True, + config=True, + help="""A function that is called prior to the launch of a new kernel instance + when a user visits the voila webpage. Used for custom user authorization + or any other necessary pre-launch functions. + + Should be of the form: + + def hook(req: tornado.web.RequestHandler, + notebook: nbformat.NotebookNode, + cwd: str) + + Although most customizations can leverage templates, if you need access + to the request object (e.g. to inspect cookies for authentication), + or to modify the notebook itself (e.g. to inject some custom structure, + although much of this can be done by interacting with the kernel + in javascript) the prelaunch hook lets you do that. + """, + ) + + page_config_hook = Callable( + default_value=None, + allow_none=True, + config=True, + help="""A function that is called to modify the page config for a given notebook. + Should be of the form: + + def page_config_hook( + current_page_config: Dict[str, Any], + base_url: str, + settings: Dict[str, Any], + log: Logger, + voila_configuration: VoilaConfiguration, + notebook_path: str + ) -> Dict[str, Any]: + + The hook receives the default page_config dictionary and all its parameters, it should + return a dictionary that will be passed to the template as the `page_config` variable + and the NotebookRenderer. This can be used to pass custom configuration. + """, + ) + progressive_rendering = Bool( False, config=True, diff --git a/voila/handler.py b/voila/handler.py index 059e23ca8..3151a36b1 100644 --- a/voila/handler.py +++ b/voila/handler.py @@ -74,7 +74,9 @@ def initialize(self, **kwargs): self.template_paths = kwargs.pop("template_paths", []) self.traitlet_config = kwargs.pop("config", None) self.voila_configuration: VoilaConfiguration = kwargs["voila_configuration"] - self.prelaunch_hook = kwargs.get("prelaunch_hook", None) + self.prelaunch_hook = self.voila_configuration.prelaunch_hook + self.page_config_hook = self.voila_configuration.page_config_hook + # we want to avoid starting multiple kernels due to template mistakes self.kernel_started = False @@ -188,6 +190,23 @@ async def get_generator(self, path=None): return mathjax_config = self.settings.get("mathjax_config") mathjax_url = self.settings.get("mathjax_url") + + page_config_kwargs = { + "base_url": self.base_url, + "settings": self.settings, + "log": self.log, + "voila_configuration": self.voila_configuration, + } + + page_config = get_page_config(**page_config_kwargs) + + if self.page_config_hook: + page_config = self.page_config_hook( + page_config, + **page_config_kwargs, + notebook_path=notebook_path, + ) + gen = NotebookRenderer( request_handler=self, voila_configuration=self.voila_configuration, @@ -199,12 +218,7 @@ async def get_generator(self, path=None): base_url=self.base_url, kernel_spec_manager=self.kernel_spec_manager, prelaunch_hook=self.prelaunch_hook, - page_config=get_page_config( - base_url=self.base_url, - settings=self.settings, - log=self.log, - voila_configuration=self.voila_configuration, - ), + page_config=page_config, mathjax_config=mathjax_config, mathjax_url=mathjax_url, ) diff --git a/voila/notebook_renderer.py b/voila/notebook_renderer.py index c1a8791d3..04f3a2643 100644 --- a/voila/notebook_renderer.py +++ b/voila/notebook_renderer.py @@ -45,7 +45,7 @@ def __init__(self, **kwargs): self.config_manager = kwargs.get("config_manager") self.contents_manager = kwargs.get("contents_manager") self.kernel_spec_manager = kwargs.get("kernel_spec_manager") - self.prelaunch_hook = kwargs.get("prelaunch_hook") + self.prelaunch_hook = self.voila_configuration.prelaunch_hook self.base_url = kwargs.get("base_url") self.page_config = deepcopy(kwargs.get("page_config")) self.default_kernel_name = "python3" diff --git a/voila/tornado/treehandler.py b/voila/tornado/treehandler.py index c797838f7..99d808774 100644 --- a/voila/tornado/treehandler.py +++ b/voila/tornado/treehandler.py @@ -21,6 +21,10 @@ class TornadoVoilaTreeHandler(VoilaTreeHandler): + def initialize(self, **kwargs): + super().initialize(**kwargs) + self.page_config_hook = self.voila_configuration.page_config_hook + @web.authenticated async def get(self, path=""): cm = self.contents_manager @@ -58,12 +62,22 @@ def allowed_content(content): theme_arg = self.validate_theme(theme_arg, classic_tree) - page_config = get_page_config( - base_url=self.base_url, - settings=self.settings, - log=self.log, - voila_configuration=self.voila_configuration, - ) + page_config_kwargs = { + "base_url": self.base_url, + "settings": self.settings, + "log": self.log, + "voila_configuration": self.voila_configuration, + } + + page_config = get_page_config(**page_config_kwargs) + + if self.page_config_hook: + self.page_config_hook( + page_config, + **page_config_kwargs, + notebook_path=path, + ) + page_config["jupyterLabTheme"] = theme_arg page_config["frontend"] = "voila" page_config["query"] = self.request.query diff --git a/voila/utils.py b/voila/utils.py index 3878c9561..079e6ae4b 100644 --- a/voila/utils.py +++ b/voila/utils.py @@ -16,7 +16,8 @@ from copy import deepcopy from functools import partial from pathlib import Path -from typing import Awaitable, Dict, List +from typing import Awaitable, Dict, List, Any +from logging import Logger import websockets from jupyter_core.paths import jupyter_path @@ -89,7 +90,20 @@ async def _get_request_info(ws_url: str) -> Awaitable: return ri -def get_page_config(base_url, settings, log, voila_configuration: VoilaConfiguration): +def get_page_config( + base_url: str, + settings: Dict[str, Any], + log: Logger, + voila_configuration: VoilaConfiguration, +) -> Dict[str, Any]: + """Get the page configuration for Voila. + + Args: + base_url (str): The base URL of the Voila application. + settings (Dict[str, Any]): The settings of the Voila application. + log (Logger): The logger instance. + voila_configuration (VoilaConfiguration): The Voila configuration instance. + """ page_config = { "appVersion": __version__, "appUrl": "voila/", diff --git a/voila/voila_kernel_manager.py b/voila/voila_kernel_manager.py index 6ea3be49a..8e5d59b8a 100644 --- a/voila/voila_kernel_manager.py +++ b/voila/voila_kernel_manager.py @@ -12,7 +12,7 @@ import os import re from pathlib import Path -from typing import Awaitable +from typing import Awaitable, Callable, Optional from typing import Dict as TypeDict from typing import List as TypeList from typing import Tuple, Type, TypeVar, Union @@ -34,7 +34,10 @@ async def wait_before(delay: float, aw: Awaitable) -> Awaitable: def voila_kernel_manager_factory( - base_class: Type[T], preheat_kernel: bool, default_pool_size: int + base_class: Type[T], + preheat_kernel: bool, + default_pool_size: int, + page_config_hook: Optional[Callable] = None, ) -> T: """ Decorator used to make a normal kernel manager compatible with pre-heated @@ -50,10 +53,12 @@ def voila_kernel_manager_factory( - preheat_kernel (Bool): Flag to decorate the input class - default_pool_size (int): Size of pre-heated kernel pool for each notebook. Zero or negative number means disabled + - page_config_hook (Callable, optional): Hook to modify the default page config. Returns: T: Decorated class """ + if not preheat_kernel: class NormalKernelManager(base_class): @@ -388,6 +393,23 @@ def _notebook_renderer_factory( settings = self.parent.app.settings mathjax_config = settings.get("mathjax_config") mathjax_url = settings.get("mathjax_url") + + page_config_kwargs = { + "base_url": self.parent.base_url, + "settings": self.parent.app.settings, + "log": self.parent.log, + "voila_configuration": voila_configuration, + } + + page_config = get_page_config(**page_config_kwargs) + + if page_config_hook: + page_config = page_config_hook( + page_config, + **page_config_kwargs, + notebook_path=notebook_path, + ) + return NotebookRenderer( voila_configuration=voila_configuration, traitlet_config=self.parent.config, @@ -397,12 +419,7 @@ def _notebook_renderer_factory( contents_manager=self.parent.contents_manager, base_url=self.parent.base_url, kernel_spec_manager=self.parent.kernel_spec_manager, - page_config=get_page_config( - base_url=self.parent.base_url, - settings=self.parent.app.settings, - log=self.parent.log, - voila_configuration=voila_configuration, - ), + page_config=page_config, mathjax_config=mathjax_config, mathjax_url=mathjax_url, )