Repository: altair-viz/altair
Pull request #3248 (2023-11-04 15:37:33)

Type hint utils/save.py and utils/mimebundle.py
Adds type hints to utils/save.py and utils/mimebundle.py. After this PR and #3208, there are only a few minor steps left in #2951 🥳 


Patch:
diff --git a/altair/utils/display.py b/altair/utils/display.py
index f2bb99ba..ffe8f6ac 100644
--- a/altair/utils/display.py
+++ b/altair/utils/display.py
@@ -210,6 +210,8 @@ class HTMLRenderer:
     def __call__(self, spec: dict, **metadata) -> Dict[str, str]:
         kwargs = self.kwargs.copy()
         kwargs.update(metadata)
-        return spec_to_mimebundle(
+        # To get proper return value type, would need to write complex
+        # overload signatures for spec_to_mimebundle based on `format`
+        return spec_to_mimebundle(  # type: ignore[return-value]
             spec, format="html", output_div=self.output_div, **kwargs
         )
diff --git a/altair/utils/html.py b/altair/utils/html.py
index c1084aee..b2aeb551 100644
--- a/altair/utils/html.py
+++ b/altair/utils/html.py
@@ -213,8 +213,8 @@ TEMPLATES: Dict[str, jinja2.Template] = {
 def spec_to_html(
     spec: dict,
     mode: str,
-    vega_version: str,
-    vegaembed_version: str,
+    vega_version: Optional[str],
+    vegaembed_version: Optional[str],
     vegalite_version: Optional[str] = None,
     base_url: str = "https://cdn.jsdelivr.net/npm",
     output_div: str = "vis",
diff --git a/altair/utils/mimebundle.py b/altair/utils/mimebundle.py
index fa36ed4b..4948904e 100644
--- a/altair/utils/mimebundle.py
+++ b/altair/utils/mimebundle.py
@@ -1,3 +1,5 @@
+from typing import Literal, Optional, Union, cast, Tuple
+
 from .deprecation import AltairDeprecationWarning
 from .html import spec_to_html
 from ._importers import import_vl_convert
@@ -6,15 +8,15 @@ import warnings
 
 
 def spec_to_mimebundle(
-    spec,
-    format,
-    mode=None,
-    vega_version=None,
-    vegaembed_version=None,
-    vegalite_version=None,
-    engine=None,
+    spec: dict,
+    format: Literal["html", "json", "png", "svg", "pdf", "vega", "vega-lite"],
+    mode: Optional[Literal["vega-lite"]] = None,
+    vega_version: Optional[str] = None,
+    vegaembed_version: Optional[str] = None,
+    vegalite_version: Optional[str] = None,
+    engine: Optional[Literal["vl-convert", "altair_saver"]] = None,
     **kwargs,
-):
+) -> Union[dict, Tuple[dict, dict]]:
     """Convert a vega-lite specification to a mimebundle
 
     The mimebundle type is controlled by the ``format`` argument, which can be
@@ -54,18 +56,20 @@ def spec_to_mimebundle(
     if mode != "vega-lite":
         raise ValueError("mode must be 'vega-lite'")
 
+    internal_mode: Literal["vega-lite", "vega"] = mode
     if using_vegafusion():
         spec = compile_with_vegafusion(spec)
-        mode = "vega"
+        internal_mode = "vega"
 
     if format in ["png", "svg", "pdf", "vega"]:
+        format = cast(Literal["png", "svg", "pdf", "vega"], format)
         return _spec_to_mimebundle_with_engine(
-            spec, format, mode, engine=engine, **kwargs
+            spec, format, internal_mode, engine=engine, **kwargs
         )
     if format == "html":
         html = spec_to_html(
             spec,
-            mode=mode,
+            mode=internal_mode,
             vega_version=vega_version,
             vegaembed_version=vegaembed_version,
             vegalite_version=vegalite_version,
@@ -84,7 +88,12 @@ def spec_to_mimebundle(
     )
 
 
-def _spec_to_mimebundle_with_engine(spec, format, mode, **kwargs):
+def _spec_to_mimebundle_with_engine(
+    spec: dict,
+    format: Literal["png", "svg", "pdf", "vega"],
+    mode: Literal["vega-lite", "vega"],
+    **kwargs,
+) -> Union[dict, Tuple[dict, dict]]:
     """Helper for Vega-Lite to mimebundle conversions that require an engine
 
     Parameters
@@ -183,7 +192,10 @@ def _spec_to_mimebundle_with_engine(spec, format, mode, **kwargs):
         )
 
 
-def _validate_normalize_engine(engine, format):
+def _validate_normalize_engine(
+    engine: Optional[Literal["vl-convert", "altair_saver"]],
+    format: Literal["png", "svg", "pdf", "vega"],
+) -> str:
     """Helper to validate and normalize the user-provided engine
 
     engine : {None, 'vl-convert', 'altair_saver'}
diff --git a/altair/utils/save.py b/altair/utils/save.py
index 797210b3..97f86c71 100644
--- a/altair/utils/save.py
+++ b/altair/utils/save.py
@@ -1,13 +1,19 @@
 import json
 import pathlib
 import warnings
+from typing import IO, Union, Optional, Literal
 
 from .mimebundle import spec_to_mimebundle
 from ..vegalite.v5.data import data_transformers
 from altair.utils._vegafusion_data import using_vegafusion
 
 
-def write_file_or_filename(fp, content, mode="w", encoding=None):
+def write_file_or_filename(
+    fp: Union[str, pathlib.PurePath, IO],
+    content: Union[str, bytes],
+    mode: str = "w",
+    encoding: Optional[str] = None,
+) -> None:
     """Write content to fp, whether fp is a string, a pathlib Path or a
     file-like object"""
     if isinstance(fp, str) or isinstance(fp, pathlib.PurePath):
@@ -17,7 +23,9 @@ def write_file_or_filename(fp, content, mode="w", encoding=None):
         fp.write(content)
 
 
-def set_inspect_format_argument(format, fp, inline):
+def set_inspect_format_argument(
+    format: Optional[str], fp: Union[str, pathlib.PurePath, IO], inline: bool
+) -> str:
     """Inspect the format argument in the save function"""
     if format is None:
         if isinstance(fp, str):
@@ -36,7 +44,12 @@ def set_inspect_format_argument(format, fp, inline):
     return format
 
 
-def set_inspect_mode_argument(mode, embed_options, spec, vegalite_version):
+def set_inspect_mode_argument(
+    mode: Optional[Literal["vega-lite"]],
+    embed_options: dict,
+    spec: dict,
+    vegalite_version: Optional[str],
+) -> Literal["vega-lite"]:
     """Inspect the mode argument in the save function"""
     if mode is None:
         if "mode" in embed_options:
@@ -57,20 +70,20 @@ def set_inspect_mode_argument(mode, embed_options, spec, vegalite_version):
 
 def save(
     chart,
-    fp,
-    vega_version,
-    vegaembed_version,
-    format=None,
-    mode=None,
-    vegalite_version=None,
-    embed_options=None,
-    json_kwds=None,
-    webdriver=None,
-    scale_factor=1,
-    engine=None,
-    inline=False,
+    fp: Union[str, pathlib.PurePath, IO],
+    vega_version: Optional[str],
+    vegaembed_version: Optional[str],
+    format: Optional[Literal["json", "html", "png", "svg", "pdf"]] = None,
+    mode: Optional[Literal["vega-lite"]] = None,
+    vegalite_version: Optional[str] = None,
+    embed_options: Optional[dict] = None,
+    json_kwds: Optional[dict] = None,
+    webdriver: Optional[Literal["chrome", "firefox"]] = None,
+    scale_factor: float = 1,
+    engine: Optional[Literal["vl-convert", "altair_saver"]] = None,
+    inline: bool = False,
     **kwargs,
-):
+) -> None:
     """Save a chart to file in a variety of formats
 
     Supported formats are [json, html, png, svg, pdf]
@@ -121,7 +134,7 @@ def save(
     if embed_options is None:
         embed_options = {}
 
-    format = set_inspect_format_argument(format, fp, inline)
+    format = set_inspect_format_argument(format, fp, inline)  # type: ignore[assignment]
 
     def perform_save():
         spec = chart.to_dict(context={"pre_transform": False})
diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py
index 259cce0e..f0126ac3 100644
--- a/altair/vegalite/v5/api.py
+++ b/altair/vegalite/v5/api.py
@@ -1055,6 +1055,25 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
             requirejs=requirejs,
         )
 
+    def to_url(self, *, fullscreen: bool = False) -> str:
+        """Convert a chart to a URL that opens the chart specification in the Vega chart editor
+        The chart specification (including any inline data) is encoded in the URL.
+
+        This method requires that the vl-convert-python package is installed.
+
+        Parameters
+        ----------
+        fullscreen : bool
+            If True, editor will open chart in fullscreen mode. Default False
+        """
+        from ...utils._importers import import_vl_convert
+
+        vlc = import_vl_convert()
+        if _using_vegafusion():
+            return vlc.vega_to_url(self.to_dict(format="vega"), fullscreen=fullscreen)
+        else:
+            return vlc.vegalite_to_url(self.to_dict(), fullscreen=fullscreen)
+
     def save(
         self,
         fp: Union[str, IO],
@@ -1109,10 +1128,10 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         # normally trigger a MaxRowsError
         if override_data_transformer:
             with data_transformers.disable_max_rows():
-                result = save(**kwds)
+                save(**kwds)
         else:
-            result = save(**kwds)
-        return result
+            save(**kwds)
+        return
 
     # Fallback for when rendering fails; the full repr is too long to be
     # useful in nearly all cases.
diff --git a/altair/vegalite/v5/display.py b/altair/vegalite/v5/display.py
index 714e17b4..93926e62 100644
--- a/altair/vegalite/v5/display.py
+++ b/altair/vegalite/v5/display.py
@@ -59,7 +59,9 @@ def json_renderer(spec: dict, **metadata) -> DefaultRendererReturnType:
 
 
 def png_renderer(spec: dict, **metadata) -> Dict[str, bytes]:
-    return spec_to_mimebundle(
+    # To get proper return value type, would need to write complex
+    # overload signatures for spec_to_mimebundle based on `format`
+    return spec_to_mimebundle(  # type: ignore[return-value]
         spec,
         format="png",
         mode="vega-lite",
@@ -71,7 +73,9 @@ def png_renderer(spec: dict, **metadata) -> Dict[str, bytes]:
 
 
 def svg_renderer(spec: dict, **metadata) -> Dict[str, str]:
-    return spec_to_mimebundle(
+    # To get proper return value type, would need to write complex
+    # overload signatures for spec_to_mimebundle based on `format`
+    return spec_to_mimebundle(  # type: ignore[return-value]
         spec,
         format="svg",
         mode="vega-lite",
diff --git a/doc/releases/changes.rst b/doc/releases/changes.rst
index db4ad5c8..85244762 100644
--- a/doc/releases/changes.rst
+++ b/doc/releases/changes.rst
@@ -8,6 +8,8 @@ Version 5.2.0 (unreleased month date, year)
 
 Enhancements
 ~~~~~~~~~~~~
+- Support saving charts as PDF files using the vl-convert export engine (#3244)
+- Support converting charts to sharable Vega editor URLs with ``chart.to_url()`` (#3252)
 
 Bug Fixes
 ~~~~~~~~~
diff --git a/doc/user_guide/saving_charts.rst b/doc/user_guide/saving_charts.rst
index e265c85d..fad5864d 100644
--- a/doc/user_guide/saving_charts.rst
+++ b/doc/user_guide/saving_charts.rst
@@ -224,6 +224,26 @@ size at the default resolution of 72 ppi::
 
     chart.save('chart.png', scale_factor=2)
 
+Sharable URL
+~~~~~~~~~~~~
+The :meth:`Chart.to_url` method can be used to build a sharable URL that opens the chart
+specification in the online Vega editor_.
+
+.. altair-plot::
+    :output: repr
+
+    import altair as alt
+    from vega_datasets import data
+
+    chart = alt.Chart(data.cars.url).mark_point().encode(
+        x='Horsepower:Q',
+        y='Miles_per_Gallon:Q',
+        color='Origin:N'
+    )
+
+    chart.to_url()
+
 .. _vl-convert: https://github.com/vega/vl-convert
 .. _altair_saver: http://github.com/altair-viz/altair_saver/
 .. _vegaEmbed: https://github.com/vega/vega-embed
+.. _editor: https://vega.github.io/editor/
\ No newline at end of file


Test patch:
diff --git a/tests/vegalite/v5/test_api.py b/tests/vegalite/v5/test_api.py
index c61d466d..fa220557 100644
--- a/tests/vegalite/v5/test_api.py
+++ b/tests/vegalite/v5/test_api.py
@@ -380,6 +380,30 @@ def test_save_html(basic_chart, inline):
         assert 'src="https://cdn.jsdelivr.net/npm/vega-embed@6' in content
 
 
+def test_to_url(basic_chart):
+    share_url = basic_chart.to_url()
+    expected_vegalite_encoding = (
+        "N4Igxg9gdgZglgcxALlANzgUwO4tJKAFzigFcJSBnAdTgBNCALFAZgAY2AacaYsiygAlMiRoVYcAvpO50AhoTl4QU"
+        "OQFtMKEPMUBaMACY5LTAA4AnACM55ugFY6ARgBspgOz2zh03Wfs5bCwsIDIganIATgDWyoQAngAOmsgg1hEh3JhQk"
+        "HQkSKggAB7K8JgANnRaStzxSVpQEGokcmUZIHElWBValiA1ickgAI6kckRwisRomtLcACSUYIyY4VpihAmUyAD029"
+        "MIcgB0CBOMpJaHcBDbi8vhe5gHumUTmHt2h44fjocAVpTQPraBRySiYQiUZQ6OT6IwmCzWWwOFzuTymby+fyBYLIA"
+        "DaoCUKQAgkDesgDKYZAStAAhUkoOx2KkgQkgADC9OQABYWMzWQARTnmRx8rQAUU5phFnGpKQAYpy7LyZSytABxTmO"
+        "cyilKCSVuHUgACSioMkgAutIgA"
+    )
+
+    assert (
+        share_url
+        == f"https://vega.github.io/editor/#/url/vega-lite/{expected_vegalite_encoding}"
+    )
+
+    # Check fullscreen
+    fullscreen_share_url = basic_chart.to_url(fullscreen=True)
+    assert (
+        fullscreen_share_url
+        == f"https://vega.github.io/editor/#/url/vega-lite/{expected_vegalite_encoding}/view"
+    )
+
+
 def test_facet_basic():
     # wrapped facet
     chart1 = (