From 5364bd5fd922ee458d2727074222f304888358a1 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Dec 2023 13:46:31 -0500 Subject: [PATCH 1/5] rename imports to match vl-convert's bundle config --- altair/jupyter/js/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 81ad634e6..241430c71 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,5 +1,5 @@ -import embed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; -import debounce from "https://esm.sh/lodash-es@4.17.21/debounce"; +import vegaEmbed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; +import lodashDebounce from "https://esm.sh/lodash-es@4.17.21/debounce"; export async function render({ model, el }) { let finalize; @@ -22,7 +22,7 @@ export async function render({ model, el }) { let spec = model.get("spec"); let api; try { - api = await embed(el, spec); + api = await vegaEmbed(el, spec); } catch (error) { showError(error) return; @@ -45,7 +45,7 @@ export async function render({ model, el }) { model.set("_vl_selections", newSelections); model.save_changes(); }; - api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait})); + api.view.addSignalListener(selectionName, lodashDebounce(selectionHandler, wait, {maxWait})); initialSelections[selectionName] = { value: cleanJson(api.view.signal(selectionName) ?? {}), @@ -62,7 +62,7 @@ export async function render({ model, el }) { model.set("_params", newParams); model.save_changes(); }; - api.view.addSignalListener(paramName, debounce(paramHandler, wait, {maxWait})); + api.view.addSignalListener(paramName, lodashDebounce(paramHandler, wait, {maxWait})); initialParams[paramName] = api.view.signal(paramName) ?? null } From c2e55a2b439b378aeabe5a31820a8a9afb110f20 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Dec 2023 13:47:01 -0500 Subject: [PATCH 2/5] Add enable_offline class method on JupyterChart to use bundled dependencies for offline support --- altair/jupyter/jupyter_chart.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 5b2f4af68..0bf7a06dc 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -112,6 +112,24 @@ class JupyterChart(anywidget.AnyWidget): # Internal param traitlets _params = traitlets.Dict().tag(sync=True) + @classmethod + def enable_offline(cls): + from altair.utils._importers import import_vl_convert, vl_version_for_vl_convert + + vlc = import_vl_convert() + + src_lines = (_here / "js" / "index.js").read_text().split("\n") + + # Remove leading lines with only whitespace or imports + while src_lines and ( + len(src_lines[0].strip()) == 0 or src_lines[0].startswith("import") + ): + src_lines.pop(0) + + src = "\n".join(src_lines) + bundled_src = vlc.javascript_bundle(src, vl_version=vl_version_for_vl_convert()) + cls._esm = bundled_src + def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): """ Jupyter Widget for displaying and updating Altair Charts, and From 22d8c5d54860bec6caeef4cc3fb67027de4acb66 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 2 Jan 2024 09:20:21 -0500 Subject: [PATCH 3/5] Add offline mode for JupyterChart and the "jupyter" renderer This is done by loading JS dependencies from vl-convert. --- altair/jupyter/js/index.js | 5 +++ altair/jupyter/jupyter_chart.py | 67 +++++++++++++++++++++++++-------- altair/vegalite/v5/display.py | 6 ++- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 9202e99be..a7e3b589d 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,6 +1,11 @@ import vegaEmbed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; import lodashDebounce from "https://esm.sh/lodash-es@4.17.21/debounce"; +// Note: For offline support, the import lines above are removed and the remaining script +// is bundled using vl-convert's javascript_bundle function. See the documentation of +// the javascript_bundle function for details on the available imports and their names. +// If an additional import is required in the future, it will need to be added to vl-convert +// in order to preserve offline support. export async function render({ model, el }) { let finalize; diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 6f3011e09..c3defae1d 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -93,8 +93,12 @@ def _set_value(self, key, value): self.observe(self._make_read_only, names=key) +def load_js_src() -> str: + return (_here / "js" / "index.js").read_text() + + class JupyterChart(anywidget.AnyWidget): - _esm = (_here / "js" / "index.js").read_text() + _esm = load_js_src() _css = r""" .vega-embed { /* Make sure action menu isn't cut off */ @@ -123,23 +127,56 @@ class JupyterChart(anywidget.AnyWidget): _js_to_py_updates = traitlets.Any(allow_none=True).tag(sync=True) _py_to_js_updates = traitlets.Any(allow_none=True).tag(sync=True) - @classmethod - def enable_offline(cls): - from altair.utils._importers import import_vl_convert, vl_version_for_vl_convert - - vlc = import_vl_convert() + # Track whether charts are configured for offline use + _is_offline = False - src_lines = (_here / "js" / "index.js").read_text().split("\n") + @classmethod + def enable_offline(cls, offline: bool = True): + """ + Configure JupyterChart's offline behavior - # Remove leading lines with only whitespace or imports - while src_lines and ( - len(src_lines[0].strip()) == 0 or src_lines[0].startswith("import") - ): - src_lines.pop(0) + Parameters + ---------- + offline: bool + If True, configure JupyterChart to operate in offline mode where JavaScript + dependencies are loaded from vl-convert. + If False, configure it to operate in online mode where JavaScript dependencies + are loaded from CDN dynamically. This is the default behavior. + """ + from altair.utils._importers import import_vl_convert, vl_version_for_vl_convert - src = "\n".join(src_lines) - bundled_src = vlc.javascript_bundle(src, vl_version=vl_version_for_vl_convert()) - cls._esm = bundled_src + if offline: + if cls._is_offline: + # Already offline + return + + vlc = import_vl_convert() + + src_lines = load_js_src().split("\n") + + # Remove leading lines with only whitespace, comments, or imports + while src_lines and ( + len(src_lines[0].strip()) == 0 + or src_lines[0].startswith("import") + or src_lines[0].startswith("//") + ): + src_lines.pop(0) + + src = "\n".join(src_lines) + + # vl-convert's javascript_bundle function creates a self-contained JavaScript bundle + # for JavaScript snippets that import from a small set of dependencies that + # vl-convert includes. To see the available imports and their imported names, run + # import vl_convert as vlc + # help(vlc.javascript_bundle) + bundled_src = vlc.javascript_bundle( + src, vl_version=vl_version_for_vl_convert() + ) + cls._esm = bundled_src + cls._is_offline = True + else: + cls._esm = load_js_src() + cls._is_offline = False def __init__( self, diff --git a/altair/vegalite/v5/display.py b/altair/vegalite/v5/display.py index b13d62e06..02ad8ac03 100644 --- a/altair/vegalite/v5/display.py +++ b/altair/vegalite/v5/display.py @@ -86,10 +86,14 @@ def svg_renderer(spec: dict, **metadata) -> Dict[str, str]: ) -def jupyter_renderer(spec: dict): +def jupyter_renderer(spec: dict, **metadata): """Render chart using the JupyterChart Jupyter Widget""" from altair import Chart, JupyterChart + # Configure offline mode + offline = metadata.get("offline", False) + JupyterChart.enable_offline(offline=offline) + # Need to ignore attr-defined mypy rule because mypy doesn't see _repr_mimebundle_ # conditionally defined in AnyWidget return JupyterChart(chart=Chart.from_dict(spec))._repr_mimebundle_() # type: ignore[attr-defined] From adc5ee79fe46341d35f71bf5bbd6ad30840a74f8 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 2 Jan 2024 11:12:25 -0500 Subject: [PATCH 4/5] Add offline docs --- doc/releases/changes.rst | 2 ++ doc/user_guide/display_frontends.rst | 5 +++++ doc/user_guide/jupyter_chart.rst | 30 ++++++++++++++++++++++------ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/doc/releases/changes.rst b/doc/releases/changes.rst index 432975514..d065b28bf 100644 --- a/doc/releases/changes.rst +++ b/doc/releases/changes.rst @@ -9,6 +9,8 @@ Version 5.3.0 (unreleased month day, year) Enhancements ~~~~~~~~~~~~ - Add "jupyter" renderer which uses JupyterChart for rendering (#3283). See :ref:`renderers` for more information. +- Add offline support for JupyterChart and the new "jupyter" renderer. See :ref:`user-guide-jupyterchart-offline` + for more information. - Docs: Add :ref:`section on dashboards ` which have support for Altair (#3299) - Support restrictive FIPS-compliant environment (#3291) diff --git a/doc/user_guide/display_frontends.rst b/doc/user_guide/display_frontends.rst index bb488986a..06c689011 100644 --- a/doc/user_guide/display_frontends.rst +++ b/doc/user_guide/display_frontends.rst @@ -50,6 +50,11 @@ The most used built-in renderers are: object explicitly following the instructions in the :ref:`user-guide-jupyterchart` documentation. +``alt.renderers.enable("jupyter", offline=True)`` + *(added in version 5.3):* Same as the ``"jupyter"`` renderer above, but loads JavaScript + dependencies from the ``vl-convert-python`` package (rather than from an online CDN) + so that an internet connection is not required. + In addition, Altair includes the following renderers: - ``"default"``, ``"colab"``, ``"kaggle"``, ``"zeppelin"``: identical to ``"html"`` diff --git a/doc/user_guide/jupyter_chart.rst b/doc/user_guide/jupyter_chart.rst index d2bcaa63a..d00327f3d 100644 --- a/doc/user_guide/jupyter_chart.rst +++ b/doc/user_guide/jupyter_chart.rst @@ -426,15 +426,33 @@ is used to combine the chart and HTML table in a column layout. Your browser does not support the video tag. +.. _user-guide-jupyterchart-offline: + +Offline Usage +------------- +By default, the ``JupyterChart`` widget loads its JavaScript dependencies dynamically from a CDN +location, which requires an active internet connection. Starting in Altair 5.3, JupyterChart supports +loading its JavaScript dependencies from the ``vl-convert-python`` package, which enables offline usage. + +Offline mode is enabled using the ``JupyterChart.enable_offline`` class method. + +.. code-block:: python + + import altair as alt + alt.JupyterChart.enable_offline() + +This only needs to be called once, after which all displayed JupyterCharts will operate in offline mode. + +Offline mode can be disabled by passing ``offline=False`` to this same method. + +.. code-block:: python + + import altair as alt + alt.JupyterChart.enable_offline(offline=False) + Limitations ----------- Setting Selections ~~~~~~~~~~~~~~~~~~ It's not currently possible to set selection states from Python. - -Internet Connection Required -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The JupyterChart class currently loads its JavaScript dependencies dynamically from a CDN location. -This keeps the ``altair`` package small, but it means that an internet connection is required -to display JupyterChart instances. In the future, we would like to provide optional offline support. From 0ae167c6760ccf3b107060d72cb23f171c4b66bf Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 2 Jan 2024 11:37:29 -0500 Subject: [PATCH 5/5] mypy --- altair/vegalite/v5/display.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/altair/vegalite/v5/display.py b/altair/vegalite/v5/display.py index 02ad8ac03..a37a14977 100644 --- a/altair/vegalite/v5/display.py +++ b/altair/vegalite/v5/display.py @@ -92,7 +92,9 @@ def jupyter_renderer(spec: dict, **metadata): # Configure offline mode offline = metadata.get("offline", False) - JupyterChart.enable_offline(offline=offline) + + # mypy doesn't see the enable_offline class method for some reason + JupyterChart.enable_offline(offline=offline) # type: ignore[attr-defined] # Need to ignore attr-defined mypy rule because mypy doesn't see _repr_mimebundle_ # conditionally defined in AnyWidget