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

[Feat] Enable external serving of Vizro assets #775

Merged
merged 23 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
49ebfc5
Remove blueprint scheme for asset serving, make JS work in browser
antonymilne Oct 3, 2024
552b351
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 3, 2024
3f93bac
Tidy and fix bugs
antonymilne Oct 3, 2024
cc0385c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 3, 2024
1ff112b
Tidy and fix bugs
antonymilne Oct 3, 2024
527d8db
Tidy and fix bugs
antonymilne Oct 3, 2024
9a4e69e
Merge branch 'main' into feat/allow-servering-external-assets
antonymilne Oct 3, 2024
2b6252f
Tidy and fix bugs
antonymilne Oct 3, 2024
54b04c1
Tidy and fix bugs
antonymilne Oct 3, 2024
e8dd4b1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 3, 2024
fb4540a
Test out overwrite
huong-li-nguyen Oct 4, 2024
6b0aff4
Revert "Test out overwrite"
huong-li-nguyen Oct 4, 2024
ed2a69a
Update vizro-core/docs/pages/user-guides/run.md
antonymilne Oct 7, 2024
eda0292
Update vizro-core/src/vizro/_vizro.py
antonymilne Oct 7, 2024
ad943ab
Make sure vizro bootstrap is first
antonymilne Oct 7, 2024
15dd8af
Update changelog
antonymilne Oct 9, 2024
efef57e
Sort alphabetically
antonymilne Oct 9, 2024
1ac4535
Write tests
antonymilne Oct 9, 2024
078f287
Prepare for merge
antonymilne Oct 9, 2024
f023de7
Merge branch 'main' into feat/allow-servering-external-assets
antonymilne Oct 9, 2024
5630470
Remove library resources
antonymilne Oct 10, 2024
bf66d59
Finally test tests?
antonymilne Oct 10, 2024
cf09685
Merge branch 'main' into feat/allow-servering-external-assets
antonymilne Oct 10, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
-->

<!--
### Highlights ✨

- A bullet item for the Highlights ✨ category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1))

-->
<!--
### Removed

- A bullet item for the Removed category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1))

-->

### Added

- All Vizro resources are served through a CDN when `serve_locally=False` ([#775](https://github.com/mckinsey/vizro/pull/775))
antonymilne marked this conversation as resolved.
Show resolved Hide resolved

<!--
### Changed

- A bullet item for the Changed category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1))

-->
<!--
### Deprecated

- A bullet item for the Deprecated category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1))

-->

### Fixed

- Remove extraneous `<link>` to font file ([#775](https://github.com/mckinsey/vizro/pull/775))
antonymilne marked this conversation as resolved.
Show resolved Hide resolved

antonymilne marked this conversation as resolved.
Show resolved Hide resolved
<!--
### Security

- A bullet item for the Security category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1))

-->
2 changes: 1 addition & 1 deletion vizro-core/docs/pages/user-guides/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ data_manager.cache = Cache(config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_T

!!! warning

Simple cache exists purely for single-process development purposes and is not intended to be used in production. If you deploy with multiple workers, [for example with gunicorn](run.md/#gunicorn), then you should use a production-ready cache backend. All of Flask-Caching's [built-in backends](https://flask-caching.readthedocs.io/en/latest/#built-in-cache-backends) other than `SimpleCache` are suitable for production. In particular, you might like to use [`FileSystemCache`](https://cachelib.readthedocs.io/en/stable/file/) or [`RedisCache`](https://cachelib.readthedocs.io/en/stable/redis/):
Simple cache exists purely for single-process development purposes and is not intended to be used in production. If you deploy with multiple workers, [for example with Gunicorn](run.md/#gunicorn), then you should use a production-ready cache backend. All of Flask-Caching's [built-in backends](https://flask-caching.readthedocs.io/en/latest/#built-in-cache-backends) other than `SimpleCache` are suitable for production. In particular, you might like to use [`FileSystemCache`](https://cachelib.readthedocs.io/en/stable/file/) or [`RedisCache`](https://cachelib.readthedocs.io/en/stable/redis/):

```py title="Production-ready caches"
# Store cached data in CACHE_DIR
Expand Down
8 changes: 6 additions & 2 deletions vizro-core/docs/pages/user-guides/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ INFO:werkzeug:WARNING: This is a development server. Do not use it in a producti

!!! warning "In production"

As per the above warning message, which is [further explained in the Flask documentation](https://flask.palletsprojects.com/en/3.0.x/deploying/), the Flask development server is intended for use only during local development and **should not** be used when deploying to production. Instead, you should instead use a production-ready solution such as [gunicorn](#gunicorn).
As per the above warning message, which is [further explained in the Flask documentation](https://flask.palletsprojects.com/en/3.0.x/deploying/), the Flask development server is intended for use only during local development and **should not** be used when deploying to production. Instead, you should instead use a production-ready solution such as [Gunicorn](#gunicorn).

### Automatic reloading and debugging

Expand Down Expand Up @@ -147,4 +147,8 @@ A Vizro app wraps a Dash app, which itself wraps a Flask app. Hence to deploy a

Internally, `app = Vizro()` contains a Flask app in `app.dash.server`. However, as a convenience, the Vizro `app` itself implements the [WSGI application interface](https://werkzeug.palletsprojects.com/en/3.0.x/terms/#wsgi) as a shortcut to the underlying Flask app. This means that, as in the [above example with Gunicorn](#gunicorn), the Vizro `app` object can be directly supplied to the WSGI server.

[`Vizro`][vizro.Vizro] accepts `**kwargs` that are passed through to `Dash`. This enables you to configure the underlying Dash app using the same [arguments that are available](https://dash.plotly.com/reference#dash.dash) in `Dash`. For example, in a deployment context, you might like to specify a custom `url_base_pathname` to serve your Vizro app at a specific URL rather than at your domain root.
[`Vizro`][vizro.Vizro] accepts `**kwargs` that are passed through to `Dash`. This enables you to configure the underlying Dash app using the same [arguments that are available](https://dash.plotly.com/reference#dash.dash) in `Dash`. For example, in a deployment context, some of the below arguments may be useful:
antonymilne marked this conversation as resolved.
Show resolved Hide resolved

- `url_base_pathname`: serve your Vizro app at a specific path rather than at the domain root, e.g. if you host your dashboard at http://www.example.com/my_dashboard/ then you would set `url_base_pathname="/my_dashboard/"` .
- `serve_locally`: set to `False` to [serve Dash component libraries from a Content Delivery Network (CDN)](https://dash.plotly.com/external-resources#serving-dash's-component-libraries-locally-or-from-a-cdn), which reduces load on the server and can improve performance. Vizro uses [jsDeliver](https://www.jsdelivr.com/) as a CDN for CSS and JavaScript sources.
- `assets_external_path`: when `serve_locally=False`, you can also [serve your own assets from a CDN](https://dash.plotly.com/external-resources#load-assets-from-a-folder-hosted-on-a-cdn).
2 changes: 1 addition & 1 deletion vizro-core/examples/scratch_dev/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
],
)

dashboard = vm.Dashboard(pages=[page])
dashboard = vm.Dashboard(pages=[page], navigation=vm.Navigation(nav_selector=vm.NavBar()))

if __name__ == "__main__":
Vizro().build(dashboard).run()
32 changes: 3 additions & 29 deletions vizro-core/src/vizro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dash.development.base_component import Component

from ._themes import dark, light
from ._vizro import Vizro
from ._vizro import Vizro, _library_css_files, _library_js_files, _make_resource_spec

logging.basicConfig(level=os.getenv("VIZRO_LOG_LEVEL", "WARNING"))
pio.templates["vizro_dark"] = dark
Expand All @@ -27,31 +27,5 @@ class _Dummy(Component):
pass


# For dev versions, a branch or tag called e.g. 0.1.20.dev0 does not exist and so won't work with the CDN. We point
huong-li-nguyen marked this conversation as resolved.
Show resolved Hide resolved
# to main instead, but this can be manually overridden to the current feature branch name if required.
# _git_branch = __version__ if "dev" not in __version__ else "main"
_git_branch = __version__ if "dev" not in __version__ else "main"
_library_css = ["static/css/figures"]
_base_external_url = f"https://cdn.jsdelivr.net/gh/mckinsey/vizro@{_git_branch}/vizro-core/src/vizro/"

# CSS is packaged and accessed using relative_package_path when serve_locally=False (the default) in
# the Dash instantiation. When serve_locally=True then, where defined, external_url will be used instead.
_css_dist = [
{
"namespace": "vizro",
"relative_package_path": f"{css_file}.css",
"external_url": f"{_base_external_url}{css_file}.min.css",
}
for css_file in _library_css
]

# Include font file so that figures with icons can be used outside Vizro as pure Dash components.
# The file can be served through the CDN in the same way as the CSS files but external_url is irrelevant here. The way
# the file is requested is through a relative url("./fonts/...") in the requesting CSS file. When the CSS file is
# served from the CDN then this will refer to the font file also on the CDN.
_css_dist.append(
{
"namespace": "vizro",
"relative_package_path": "static/css/fonts/material-symbols-outlined.woff2",
}
)
_css_dist = [_make_resource_spec(css_file) for css_file in _library_css_files]
_js_dist = [_make_resource_spec(js_file) for js_file in _library_js_files]
4 changes: 3 additions & 1 deletion vizro-core/src/vizro/_constants.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""File to store constants."""

from pathlib import Path

ALL_OPTION = "ALL"
NONE_OPTION = "NONE"
STATIC_URL_PREFIX = "vizro"
MODULE_PAGE_404 = "not_found_404"
EMPTY_SPACE_CONST = -1
ON_PAGE_LOAD_ACTION_PREFIX = "on_page_load_action"
FILTER_ACTION_PREFIX = "filter_action"
PARAMETER_ACTION_PREFIX = "parameter_action"
ACCORDION_DEFAULT_TITLE = "SELECT PAGE"
VIZRO_ASSETS_PATH = Path(__file__).with_name("static")
109 changes: 63 additions & 46 deletions vizro-core/src/vizro/_vizro.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
import logging
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, List
from typing import TYPE_CHECKING, Iterable

import dash
import flask
import plotly.io as pio
from flask_caching import SimpleCache

from vizro._constants import STATIC_URL_PREFIX
from vizro._constants import VIZRO_ASSETS_PATH
from vizro.managers import data_manager, model_manager
from vizro.models import Dashboard

Expand All @@ -21,6 +20,19 @@
from _typeshed.wsgi import StartResponse, WSGIEnvironment


# Files needed to use Vizro as a library (not a framework), e.g. in a pure Dash app. These files are automatically
# served on import of vizro, regardless of whether the Vizro class or any other bits are used.
# This list should be kept to the bare minimum so we don't insert any more than the minimum required CSS on pure Dash
# apps.
# At the moment the only library components we support just are KPI cards, which just need CSS files. The
# _library_js_files is here just for consistency and might be populated in future.
_library_css_files = {
VIZRO_ASSETS_PATH / "css/figures.css",
VIZRO_ASSETS_PATH / "css/fonts/material-symbols-outlined.woff2",
}
_library_js_files = set()


class Vizro:
"""The main class of the `vizro` package."""

Expand All @@ -46,41 +58,18 @@ def __init__(self, **kwargs):
use_pages=True,
)

# Include Vizro assets (in the static folder) as external scripts and stylesheets. We extend self.dash.config
# objects so the user can specify additional external_scripts and external_stylesheets via kwargs.
vizro_assets_folder = Path(__file__).with_name("static")
requests_pathname_prefix = self.dash.config.requests_pathname_prefix
# Exclude vizro/css/figures.css since these are distributed through the vizro._css_dist mechanism.
# In future we will probably handle all assets this way and none of this code will be required.
vizro_css = [
requests_pathname_prefix + path
for path in self._get_external_assets(vizro_assets_folder, "css")
if path != "vizro/css/figures.css"
]

# Ensure vizro-bootstrap.min.css is loaded in first to allow overwrites
vizro_css.sort(key=lambda x: not x.endswith("vizro-bootstrap.min.css"))
huong-li-nguyen marked this conversation as resolved.
Show resolved Hide resolved

vizro_js = [
{"src": requests_pathname_prefix + path, "type": "module"}
for path in self._get_external_assets(vizro_assets_folder, "js")
]
self.dash.config.external_stylesheets.extend(vizro_css)
self.dash.config.external_scripts.extend(vizro_js)

# Serve all assets (including files other than css and js) that live in vizro_assets_folder at the
# route /vizro. Based on code in Dash.init_app that serves assets_folder. This respects the case that the
# dashboard is not hosted at the root of the server, e.g. http://www.example.com/dashboard/vizro.
routes_pathname_prefix = self.dash.config.routes_pathname_prefix
blueprint_prefix = routes_pathname_prefix.replace("/", "_").replace(".", "_")
self.dash.server.register_blueprint(
flask.Blueprint(
f"{blueprint_prefix}vizro_assets",
self.dash.config.name,
static_folder=vizro_assets_folder,
static_url_path=routes_pathname_prefix + STATIC_URL_PREFIX,
)
)
# Add static assets that were not already included in the library. These are registered only when Vizro() is
# called, i.e. when Vizro is used as a framework.
for path in set(VIZRO_ASSETS_PATH.rglob("*")) - _library_css_files - _library_js_files:
if path.suffix == ".css":
self.dash.css.append_css(_make_resource_spec(path))
elif path.suffix == ".js":
self.dash.scripts.append_script(_make_resource_spec(path))
else:
# map files and fonts and images. These are treated like scripts since this is how Dash handles them.
# This adds paths to self.dash.registered_paths so that they can be accessed without throwing an
# error in dash._validate.validate_js_path.
self.dash.scripts.append_script(_make_resource_spec(path))
petar-qb marked this conversation as resolved.
Show resolved Hide resolved

data_manager.cache.init_app(self.dash.server)

Expand Down Expand Up @@ -173,13 +162,41 @@ def _reset():
dash._pages.CONFIG.clear()
dash._pages.CONFIG.__dict__.clear()

@staticmethod
def _get_external_assets(folder: Path, extension: str) -> List[str]:
"""Returns a list of paths to assets with given `extension` in `folder`, prefixed with `STATIC_URL_PREFIX`.

e.g. with STATIC_URL_PREFIX="vizro", extension="css", folder="/path/to/vizro/vizro-core/src/vizro/static",
we will get ["vizro/css/accordion.css", "vizro/css/button.css", ...].
"""
return sorted(
(STATIC_URL_PREFIX / path.relative_to(folder)).as_posix() for path in folder.rglob(f"*.{extension}")
def _make_resource_spec(path: Path):
# For dev versions, a branch or tag called e.g. 0.1.20.dev0 does not exist and so won't work with the CDN. We point
# to main instead, but this can be manually overridden to the current feature branch name if required.
antonymilne marked this conversation as resolved.
Show resolved Hide resolved

# _git_branch = __version__ if "dev" not in __version__ else "main"
_git_branch = "feat/allow-servering-external-assets"
antonymilne marked this conversation as resolved.
Show resolved Hide resolved
BASE_EXTERNAL_URL = f"https://cdn.jsdelivr.net/gh/mckinsey/vizro@{_git_branch}/vizro-core/src/vizro/"

# Get path relative to the vizro package root, where this file resides.
relative_path = path.relative_to(Path(__file__).parent)

resource_spec = {
"namespace": "vizro",
"relative_package_path": str(relative_path),
}

if relative_path.suffix in {".css", ".js"}:
# The CDN automatically minifies CSS and JS files which aren't already minified. Convert "filename.css" to
# "filename.min.css" for these files.
external_relative_path = (
relative_path
if ".min" in relative_path.suffixes
else relative_path.with_suffix(f".min{relative_path.suffix}")
)

# Dash uses relative_package_path when serve_locally=False (the default) in the Dash instantiation.
# When serve_locally=True then, where defined, external_url will be used instead.
resource_spec["external_url"] = f"{BASE_EXTERNAL_URL}{external_relative_path}"
else:
# Files that aren't css or js cannot be minified, do not have external_url and set dynamic=True to ensure that
# the file isn't included in the HTML source. See https://github.com/plotly/dash/pull/1078.
# map and font files are served through the CDN in the same way as the CSS files but external_url is
# irrelevant here. The way the file is requested is through a relative url("./fonts/...") in the requesting
# CSS file. When the CSS file is served from the CDN then this will refer to the font file also on the CDN.
resource_spec["dynamic"] = True

return resource_spec
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _build_action_loop_callbacks() -> None:

# Callback that enables gateway callback to work in the multiple page app
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="trigger_to_global_store"),
ClientsideFunction(namespace="build_action_loop_callbacks", function_name="trigger_to_global_store"),
Output({"type": "gateway_input", "trigger_id": actions_chain.id}, "data"),
Input(
component_id=actions_chain_trigger_component_id,
Expand All @@ -57,7 +57,7 @@ def _build_action_loop_callbacks() -> None:

# Determines the final sequence of actions to be triggered.
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="gateway"),
ClientsideFunction(namespace="build_action_loop_callbacks", function_name="gateway"),
output=[Output("remaining_actions", "data")]
+ [Output({"type": "action_trigger", "action_name": action.id}, "data") for action in actions],
inputs=[
Expand All @@ -72,7 +72,7 @@ def _build_action_loop_callbacks() -> None:

# Callback that triggers the next iteration
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="after_action_cycle_breaker"),
ClientsideFunction(namespace="build_action_loop_callbacks", function_name="after_action_cycle_breaker"),
Output("cycle_breaker_empty_output_store", "data"),
Input("action_finished", "data"),
prevent_initial_call=True,
Expand Down
2 changes: 1 addition & 1 deletion vizro-core/src/vizro/models/_components/ag_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def pre_build(self):

def build(self):
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_ag_grid_theme"),
ClientsideFunction(namespace="dashboard", function_name="update_ag_grid_theme"),
Output(self._input_component_id, "className"),
Input("theme_selector", "checked"),
)
Expand Down
4 changes: 2 additions & 2 deletions vizro-core/src/vizro/models/_components/form/date_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ def build(self):
]

clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_date_picker_values"),
ClientsideFunction(namespace="date_picker", function_name="update_date_picker_values"),
output=output,
inputs=inputs,
)
# clientside callback is required as a workaround when the date-picker is overflowing its parent container
# if there is not enough space. Caused by another workaround for this issue:
# https://github.com/snehilvj/dash-mantine-components/issues/219
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_date_picker_position"),
ClientsideFunction(namespace="date_picker", function_name="update_date_picker_position"),
output=Output(self.id, "dropdownPosition"),
inputs=Input(self.id, "n_clicks"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def build(self):
]

clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_range_slider_values"),
ClientsideFunction(namespace="range_slider", function_name="update_range_slider_values"),
output=output,
inputs=inputs,
)
Expand Down
2 changes: 1 addition & 1 deletion vizro-core/src/vizro/models/_components/form/slider.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def build(self):
]

clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_slider_values"),
ClientsideFunction(namespace="slider", function_name="update_slider_values"),
output=output,
inputs=inputs,
)
Expand Down
2 changes: 1 addition & 1 deletion vizro-core/src/vizro/models/_components/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def pre_build(self):
@_log_call
def build(self):
clientside_callback(
ClientsideFunction(namespace="clientside", function_name="update_graph_theme"),
ClientsideFunction(namespace="dashboard", function_name="update_graph_theme"),
output=[Output(self.id, "figure"), Output(self.id, "style")],
inputs=[
Input(self.id, "figure"),
Expand Down
Loading
Loading