Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom page config hook #1495

Merged
merged 19 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 73 additions & 9 deletions docs/customize.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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]:
```
Expand All @@ -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à:
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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()
```
Expand Down
29 changes: 29 additions & 0 deletions tests/app/page_config_hook_test.py
Original file line number Diff line number Diff line change
@@ -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")
23 changes: 18 additions & 5 deletions voila/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"",
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -812,19 +823,22 @@ def init_handlers(self) -> List:
"template_paths": self.template_paths,
"config": self.config,
"voila_configuration": self.voila_configuration,
"prelaunch_hook": self.prelaunch_hook,
},
)
)
else:
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/(.*)"),
Expand All @@ -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.
Expand Down
45 changes: 44 additions & 1 deletion voila/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -218,6 +218,49 @@ 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,
Expand Down
28 changes: 21 additions & 7 deletions voila/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion voila/notebook_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 20 additions & 6 deletions voila/tornado/treehandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading