From f0a78a64fe0451120a7536231b45d6ac3bce5bcf Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 4 Mar 2025 12:02:11 -0500
Subject: [PATCH 01/39] small io refactor to support kaleido v1

---
 plotly/io/_kaleido.py | 184 ++++++++++++++++++++----------------------
 plotly/io/kaleido.py  |   2 +-
 2 files changed, 89 insertions(+), 97 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 029b79f1029..6709bb7f0c9 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -1,29 +1,41 @@
 import os
 import json
 from pathlib import Path
+import importlib.metadata as importlib_metadata
+from packaging.version import Version
+import tempfile
+
 import plotly
 from plotly.io._utils import validate_coerce_fig_to_dict
 
 try:
-    from kaleido.scopes.plotly import PlotlyScope
-
-    scope = PlotlyScope()
-
-    # Compute absolute path to the 'plotly/package_data/' directory
-    root_dir = os.path.dirname(os.path.abspath(plotly.__file__))
-    package_dir = os.path.join(root_dir, "package_data")
-    scope.plotlyjs = os.path.join(package_dir, "plotly.min.js")
-    if scope.mathjax is None:
-        scope.mathjax = (
-            "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js"
-        )
-except ImportError:
+    import kaleido
+
+    kaleido_available = True
+    kaleido_major = Version(importlib_metadata.version("kaleido")).major
+
+    if kaleido_major < 1:
+        # Kaleido v0
+        from kaleido.scopes.plotly import PlotlyScope
+
+        scope = PlotlyScope()
+        # Compute absolute path to the 'plotly/package_data/' directory
+        root_dir = os.path.dirname(os.path.abspath(plotly.__file__))
+        package_dir = os.path.join(root_dir, "package_data")
+        scope.plotlyjs = os.path.join(package_dir, "plotly.min.js")
+        if scope.mathjax is None:
+            scope.mathjax = (
+                "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js"
+            )
+except ImportError as e:
+    kaleido_available = False
+    kaleido_major = -1
     PlotlyScope = None
     scope = None
 
 
 def to_image(
-    fig, format=None, width=None, height=None, scale=None, validate=True, engine="auto"
+    fig, format=None, width=None, height=None, scale=None, validate=True, engine=None
 ):
     """
     Convert a figure to a static image bytes string
@@ -35,34 +47,28 @@ def to_image(
 
     format: str or None
         The desired image format. One of
-          - 'png'
-          - 'jpg' or 'jpeg'
-          - 'webp'
-          - 'svg'
-          - 'pdf'
-          - 'eps' (Requires the poppler library to be installed and on the PATH)
+            - 'png'
+            - 'jpg' or 'jpeg'
+            - 'webp'
+            - 'svg'
+            - 'pdf'
+            - 'eps' (Requires the poppler library to be installed and on the PATH)
 
-        If not specified, will default to:
-             - `plotly.io.kaleido.scope.default_format` if engine is "kaleido"
-             - `plotly.io.orca.config.default_format` if engine is "orca"
+        If not specified, will default to `plotly.io.kaleido.scope.default_format`
 
     width: int or None
         The width of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the width of the exported image
         in physical pixels.
 
-        If not specified, will default to:
-             - `plotly.io.kaleido.scope.default_width` if engine is "kaleido"
-             - `plotly.io.orca.config.default_width` if engine is "orca"
+        If not specified, will default to `plotly.io.kaleido.scope.default_width`
 
     height: int or None
         The height of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the height of the exported image
         in physical pixels.
 
-        If not specified, will default to:
-             - `plotly.io.kaleido.scope.default_height` if engine is "kaleido"
-             - `plotly.io.orca.config.default_height` if engine is "orca"
+        If not specified, will default to `plotly.io.kaleido.scope.default_height`
 
     scale: int or float or None
         The scale factor to use when exporting the figure. A scale factor
@@ -70,65 +76,23 @@ def to_image(
         to the figure's layout pixel dimensions. Whereas as scale factor of
         less than 1.0 will decrease the image resolution.
 
-        If not specified, will default to:
-             - `plotly.io.kaleido.scope.default_scale` if engine is "kaleido"
-             - `plotly.io.orca.config.default_scale` if engine is "orca"
-
+        If not specified, will default to `plotly.io.kaleido.scope.default_scale`
 
     validate: bool
         True if the figure should be validated before being converted to
         an image, False otherwise.
 
-    engine: str
-        Image export engine to use:
-         - "kaleido": Use Kaleido for image export
-         - "orca": Use Orca for image export
-         - "auto" (default): Use Kaleido if installed, otherwise use orca
+    engine (deprecated): str
+        No longer used. Kaleido is the only supported engine.
 
     Returns
     -------
     bytes
         The image data
     """
-    # Handle engine
-    # -------------
-    if engine == "auto":
-        if scope is not None:
-            # Default to kaleido if available
-            engine = "kaleido"
-        else:
-            # See if orca is available
-            from ._orca import validate_executable
-
-            try:
-                validate_executable()
-                engine = "orca"
-            except:
-                # If orca not configured properly, make sure we display the error
-                # message advising the installation of kaleido
-                engine = "kaleido"
-
-    if engine == "orca":
-        # Fall back to legacy orca image export path
-        from ._orca import to_image as to_image_orca
-
-        return to_image_orca(
-            fig,
-            format=format,
-            width=width,
-            height=height,
-            scale=scale,
-            validate=validate,
-        )
-    elif engine != "kaleido":
-        raise ValueError(
-            "Invalid image export engine specified: {engine}".format(
-                engine=repr(engine)
-            )
-        )
 
     # Raise informative error message if Kaleido is not installed
-    if scope is None:
+    if not kaleido_available:
         raise ValueError(
             """
 Image export using the "kaleido" engine requires the kaleido package,
@@ -137,12 +101,32 @@ def to_image(
 """
         )
 
-    # Validate figure
-    # ---------------
+    # Convert figure to dict (and validate if requested)
     fig_dict = validate_coerce_fig_to_dict(fig, validate)
-    img_bytes = scope.transform(
-        fig_dict, format=format, width=width, height=height, scale=scale
-    )
+
+    # Request image bytes
+    if kaleido_major > 0:
+        # Kaleido v1
+        opts = {
+            k: v
+            for k, v in dict(
+                format=format,
+                width=width,
+                height=height,
+                scale=scale,
+            ).items()
+            if v is not None
+        }
+        img_bytes = kaleido.calc_fig_sync(
+            fig_dict,
+            path=None,
+            opts=opts,
+        )
+    else:
+        # Kaleido v0
+        img_bytes = scope.transform(
+            fig_dict, format=format, width=width, height=height, scale=scale
+        )
 
     return img_bytes
 
@@ -190,18 +174,14 @@ def write_image(
         property is 1.0, this will also be the width of the exported image
         in physical pixels.
 
-        If not specified, will default to:
-            - `plotly.io.kaleido.scope.default_width` if engine is "kaleido"
-            - `plotly.io.orca.config.default_width` if engine is "orca"
+        If not specified, will default to`plotly.io.kaleido.scope.default_width`
 
     height: int or None
         The height of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the height of the exported image
         in physical pixels.
 
-        If not specified, will default to:
-            - `plotly.io.kaleido.scope.default_height` if engine is "kaleido"
-            - `plotly.io.orca.config.default_height` if engine is "orca"
+        If not specified, will default to `plotly.io.kaleido.scope.default_height`
 
     scale: int or float or None
         The scale factor to use when exporting the figure. A scale factor
@@ -209,19 +189,14 @@ def write_image(
         to the figure's layout pixel dimensions. Whereas as scale factor of
         less than 1.0 will decrease the image resolution.
 
-        If not specified, will default to:
-            - `plotly.io.kaleido.scope.default_scale` if engine is "kaleido"
-            - `plotly.io.orca.config.default_scale` if engine is "orca"
+        If not specified, will default to `plotly.io.kaleido.scope.default_scale`
 
     validate: bool
         True if the figure should be validated before being converted to
         an image, False otherwise.
 
-    engine: str
-        Image export engine to use:
-         - "kaleido": Use Kaleido for image export
-         - "orca": Use Orca for image export
-         - "auto" (default): Use Kaleido if installed, otherwise use orca
+    engine (deprecated): str
+        No longer used. Kaleido is the only supported engine.
 
     Returns
     -------
@@ -323,7 +298,7 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
     """
 
     # Raise informative error message if Kaleido is not installed
-    if scope is None:
+    if not kaleido_available:
         raise ValueError(
             """
 Full figure generation requires the kaleido package,
@@ -341,7 +316,24 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
             "To suppress this warning, set warn=False"
         )
 
-    fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))
+    if kaleido_major > 0:
+        # Kaleido v1
+        try:
+            json_file = Path(tempfile.mkstemp(suffix=".json")[1])
+            kaleido.write_fig_sync(
+                fig,
+                json_file,
+                dict(format="json"),
+            )
+            with open(json_file, "r") as f:
+                fig = json.load(f)
+        finally:
+            # Cleanup: remove temp file
+            json_file.unlink()
+    else:
+        # Kaleido v0
+        fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))
+
     if as_dict:
         return fig
     else:
diff --git a/plotly/io/kaleido.py b/plotly/io/kaleido.py
index c14b315047b..b9ff6c7582e 100644
--- a/plotly/io/kaleido.py
+++ b/plotly/io/kaleido.py
@@ -1 +1 @@
-from ._kaleido import to_image, write_image, scope
+from ._kaleido import write_image, to_image

From a681c84aca0a487ee1e34e92386ac1addc069cd8 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 4 Mar 2025 12:03:37 -0500
Subject: [PATCH 02/39] update kaleido tests to support kaleido v1

---
 .../test_kaleido/test_kaleido.py              | 175 +++++++-----------
 1 file changed, 63 insertions(+), 112 deletions(-)

diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index 263fd85483a..436f2813b4b 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -1,66 +1,31 @@
-import plotly.io as pio
-import plotly.io.kaleido
-from contextlib import contextmanager
 from io import BytesIO
 from pathlib import Path
-from unittest.mock import Mock
-
-fig = {"layout": {"title": {"text": "figure title"}}}
-
-
-def make_writeable_mocks():
-    """Produce some mocks which we will use for testing the `write_image()` function.
-
-    These mocks should be passed as the `file=` argument to `write_image()`.
-
-    The tests should verify that the method specified in the `active_write_function`
-    attribute is called once, and that scope.transform is called with the `format=`
-    argument specified by the `.expected_format` attribute.
-
-    In total we provide two mocks: one for a writable file descriptor, and other for a
-    pathlib.Path object.
-    """
-
-    # Part 1: A mock for a file descriptor
-    # ------------------------------------
-    mock_file_descriptor = Mock()
+import tempfile
 
-    # A file descriptor has no write_bytes method, unlike a pathlib Path.
-    del mock_file_descriptor.write_bytes
-
-    # The expected write method for a file descriptor is .write
-    mock_file_descriptor.active_write_function = mock_file_descriptor.write
-
-    # Since there is no filename, there should be no format detected.
-    mock_file_descriptor.expected_format = None
-
-    # Part 2: A mock for a pathlib path
-    # ---------------------------------
-    mock_pathlib_path = Mock(spec=Path)
-
-    # A pathlib Path object has no write method, unlike a file descriptor.
-    del mock_pathlib_path.write
-
-    # The expected write method for a pathlib Path is .write_bytes
-    mock_pathlib_path.active_write_function = mock_pathlib_path.write_bytes
-
-    # Mock a path with PNG suffix
-    mock_pathlib_path.suffix = ".png"
-    mock_pathlib_path.expected_format = "png"
+from pdfrw import PdfReader
+from PIL import Image
+import plotly.io as pio
 
-    return mock_file_descriptor, mock_pathlib_path
+fig = {"data": [], "layout": {"title": {"text": "figure title"}}}
 
 
-@contextmanager
-def mocked_scope():
-    # Code to acquire resource, e.g.:
-    scope_mock = Mock()
-    original_scope = pio._kaleido.scope
-    pio._kaleido.scope = scope_mock
-    try:
-        yield scope_mock
-    finally:
-        pio._kaleido.scope = original_scope
+def check_image(path_or_buffer, size=(700, 500), format="PNG"):
+    if format == "PDF":
+        img = PdfReader(path_or_buffer)
+        # TODO: There is a conversion factor needed here
+        # In Kaleido v0 the conversion factor is 0.75
+        factor = 0.75
+        expected_size = tuple(int(s * factor) for s in size)
+        actual_size = tuple(int(s) for s in img.pages[0].MediaBox[2:])
+        assert actual_size == expected_size
+    else:
+        if isinstance(path_or_buffer, (str, Path)):
+            with open(path_or_buffer, "rb") as f:
+                img = Image.open(f)
+        else:
+            img = Image.open(path_or_buffer)
+        assert img.size == size
+        assert img.format == format
 
 
 def test_kaleido_engine_to_image_returns_bytes():
@@ -75,80 +40,66 @@ def test_kaleido_fulljson():
 
 
 def test_kaleido_engine_to_image():
-    with mocked_scope() as scope:
-        pio.to_image(fig, engine="kaleido", validate=False)
+    bytes = pio.to_image(fig, engine="kaleido", validate=False)
 
-    scope.transform.assert_called_with(
-        fig, format=None, width=None, height=None, scale=None
-    )
+    # Check that image dimensions match default dimensions (700x500)
+    # and format is default format (png)
+    check_image(BytesIO(bytes))
 
 
-def test_kaleido_engine_write_image():
-    for writeable_mock in make_writeable_mocks():
-        with mocked_scope() as scope:
-            pio.write_image(fig, writeable_mock, engine="kaleido", validate=False)
+def test_kaleido_engine_write_image(tmp_path):
+    path_str = tempfile.mkstemp(suffix=".png", dir=tmp_path)[1]
+    path_path = Path(tempfile.mkstemp(suffix=".png", dir=tmp_path)[1])
 
-        scope.transform.assert_called_with(
-            fig,
-            format=writeable_mock.expected_format,
-            width=None,
-            height=None,
-            scale=None,
-        )
-
-        assert writeable_mock.active_write_function.call_count == 1
+    for out_path in [path_str, path_path]:
+        pio.write_image(fig, out_path, engine="kaleido", validate=False)
+        check_image(out_path)
 
 
 def test_kaleido_engine_to_image_kwargs():
-    with mocked_scope() as scope:
-        pio.to_image(
+    bytes = pio.to_image(
+        fig,
+        format="pdf",
+        width=700,
+        height=600,
+        scale=2,
+        engine="kaleido",
+        validate=False,
+    )
+    check_image(BytesIO(bytes), size=(700 * 2, 600 * 2), format="PDF")
+
+
+def test_kaleido_engine_write_image_kwargs(tmp_path):
+    path_str = tempfile.mkstemp(suffix=".png", dir=tmp_path)[1]
+    path_path = Path(tempfile.mkstemp(suffix=".png", dir=tmp_path)[1])
+
+    for out_path in [path_str, path_path]:
+        pio.write_image(
             fig,
-            format="pdf",
+            out_path,
+            format="jpg",
             width=700,
             height=600,
             scale=2,
             engine="kaleido",
             validate=False,
         )
-
-    scope.transform.assert_called_with(
-        fig, format="pdf", width=700, height=600, scale=2
-    )
-
-
-def test_kaleido_engine_write_image_kwargs():
-    for writeable_mock in make_writeable_mocks():
-        with mocked_scope() as scope:
-            pio.write_image(
-                fig,
-                writeable_mock,
-                format="jpg",
-                width=700,
-                height=600,
-                scale=2,
-                engine="kaleido",
-                validate=False,
-            )
-
-        scope.transform.assert_called_with(
-            fig, format="jpg", width=700, height=600, scale=2
-        )
-
-        assert writeable_mock.active_write_function.call_count == 1
+        check_image(out_path, size=(700 * 2, 600 * 2), format="JPEG")
 
 
 def test_image_renderer():
-    with mocked_scope() as scope:
-        pio.show(fig, renderer="svg", engine="kaleido", validate=False)
+    # TODO: How to replicate this test using kaleido v1?
+    # with mocked_scope() as scope:
+    pio.show(fig, renderer="svg", engine="kaleido", validate=False)
 
     renderer = pio.renderers["svg"]
-    scope.transform.assert_called_with(
-        fig,
-        format="svg",
-        width=None,
-        height=None,
-        scale=renderer.scale,
-    )
+    # scope.transform.assert_called_with(
+    #     fig,
+    #     format="svg",
+    #     width=None,
+    #     height=None,
+    #     scale=renderer.scale,
+    # )
 
 
 def test_bytesio():

From 2e9c8affa3c4133c3ed91013af6de005c1c4ab4f Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 4 Mar 2025 12:27:28 -0500
Subject: [PATCH 03/39] update test requirements

---
 test_requirements/requirements_310_optional.txt          | 1 +
 test_requirements/requirements_311_optional.txt          | 1 +
 test_requirements/requirements_312_no_numpy_optional.txt | 1 +
 test_requirements/requirements_312_np2_optional.txt      | 1 +
 test_requirements/requirements_312_optional.txt          | 1 +
 test_requirements/requirements_38_optional.txt           | 1 +
 test_requirements/requirements_39_optional.txt           | 1 +
 test_requirements/requirements_39_pandas_2_optional.txt  | 1 +
 8 files changed, 8 insertions(+)

diff --git a/test_requirements/requirements_310_optional.txt b/test_requirements/requirements_310_optional.txt
index f61fef5a5dc..41eeb460980 100644
--- a/test_requirements/requirements_310_optional.txt
+++ b/test_requirements/requirements_310_optional.txt
@@ -22,3 +22,4 @@ polars[timezone]
 pyarrow
 narwhals>=1.15.1
 anywidget==0.9.13
+pdfrw
diff --git a/test_requirements/requirements_311_optional.txt b/test_requirements/requirements_311_optional.txt
index 505636afaa2..9ff2d68af55 100644
--- a/test_requirements/requirements_311_optional.txt
+++ b/test_requirements/requirements_311_optional.txt
@@ -23,3 +23,4 @@ polars[timezone]
 pyarrow
 kaleido
 plotly-geo
+pdfrw
diff --git a/test_requirements/requirements_312_no_numpy_optional.txt b/test_requirements/requirements_312_no_numpy_optional.txt
index 9786aea5f6a..482db76abeb 100644
--- a/test_requirements/requirements_312_no_numpy_optional.txt
+++ b/test_requirements/requirements_312_no_numpy_optional.txt
@@ -22,3 +22,4 @@ pyarrow
 narwhals>=1.15.1
 anywidget==0.9.13
 jupyter-console==6.4.4
+pdfrw
diff --git a/test_requirements/requirements_312_np2_optional.txt b/test_requirements/requirements_312_np2_optional.txt
index 1e02e3a8360..6d45375217c 100644
--- a/test_requirements/requirements_312_np2_optional.txt
+++ b/test_requirements/requirements_312_np2_optional.txt
@@ -23,3 +23,4 @@ polars[timezone]
 pyarrow
 narwhals>=1.15.1
 anywidget==0.9.13
+pdfrw
diff --git a/test_requirements/requirements_312_optional.txt b/test_requirements/requirements_312_optional.txt
index 0e85492bb86..20f9fa1ee15 100644
--- a/test_requirements/requirements_312_optional.txt
+++ b/test_requirements/requirements_312_optional.txt
@@ -24,3 +24,4 @@ narwhals>=1.15.1
 anywidget==0.9.13
 jupyter-console==6.4.4
 plotly-geo
+pdfrw
diff --git a/test_requirements/requirements_38_optional.txt b/test_requirements/requirements_38_optional.txt
index f62c6ad6560..92736431506 100644
--- a/test_requirements/requirements_38_optional.txt
+++ b/test_requirements/requirements_38_optional.txt
@@ -22,3 +22,4 @@ polars[timezone]
 pyarrow
 narwhals>=1.15.1
 anywidget==0.9.13
+pdfrw
diff --git a/test_requirements/requirements_39_optional.txt b/test_requirements/requirements_39_optional.txt
index 1a767fe4926..4ee17a1995b 100644
--- a/test_requirements/requirements_39_optional.txt
+++ b/test_requirements/requirements_39_optional.txt
@@ -23,3 +23,4 @@ polars[timezone]
 pyarrow
 narwhals>=1.15.1
 anywidget==0.9.13
+pdfrw
diff --git a/test_requirements/requirements_39_pandas_2_optional.txt b/test_requirements/requirements_39_pandas_2_optional.txt
index 214bb545c0b..242e51f3200 100644
--- a/test_requirements/requirements_39_pandas_2_optional.txt
+++ b/test_requirements/requirements_39_pandas_2_optional.txt
@@ -24,3 +24,4 @@ pyarrow
 narwhals>=1.15.1
 polars
 anywidget==0.9.13
+pdfrw

From 60bb7484047512f9259a3f58180072ea94c0b48c Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 4 Mar 2025 13:29:02 -0500
Subject: [PATCH 04/39] simplify to_image

---
 plotly/io/_kaleido.py | 15 +++++----------
 1 file changed, 5 insertions(+), 10 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 6709bb7f0c9..172560c6d8e 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -107,20 +107,15 @@ def to_image(
     # Request image bytes
     if kaleido_major > 0:
         # Kaleido v1
-        opts = {
-            k: v
-            for k, v in dict(
+        img_bytes = kaleido.calc_fig_sync(
+            fig_dict,
+            path=None,
+            opts=dict(
                 format=format,
                 width=width,
                 height=height,
                 scale=scale,
-            ).items()
-            if v is not None
-        }
-        img_bytes = kaleido.calc_fig_sync(
-            fig_dict,
-            path=None,
-            opts=opts,
+            ),
         )
     else:
         # Kaleido v0

From b87f752af9c52d56c77cd81fe1a043d811ccf94e Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 6 Mar 2025 13:03:58 -0500
Subject: [PATCH 05/39] use k.calc_fig() instead of k.write_fig() in
 pio.full_figure_for_development()

---
 plotly/io/_kaleido.py | 17 +++++------------
 1 file changed, 5 insertions(+), 12 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 172560c6d8e..759589c980c 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -313,18 +313,11 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
 
     if kaleido_major > 0:
         # Kaleido v1
-        try:
-            json_file = Path(tempfile.mkstemp(suffix=".json")[1])
-            kaleido.write_fig_sync(
-                fig,
-                json_file,
-                dict(format="json"),
-            )
-            with open(json_file, "r") as f:
-                fig = json.load(f)
-        finally:
-            # Cleanup: remove temp file
-            json_file.unlink()
+        bytes = kaleido.calc_fig_sync(
+            fig,
+            opts=dict(format="json"),
+        )
+        fig = json.loads(bytes.decode("utf-8"))
     else:
         # Kaleido v0
         fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))

From 1a195a725d3ba4a6c307e31e6eed449c84c5f3af Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 11 Mar 2025 14:38:01 -0400
Subject: [PATCH 06/39] add ci job to test with kaleido v1

---
 .circleci/config.yml | 45 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3b36cc48022..fe5371f176e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -117,6 +117,35 @@ commands:
             source .venv/bin/activate
             python -m pytest -x test_init/test_lazy_imports.py
 
+  test_io_kaleido_v1:
+    steps:
+      - checkout
+      - browser-tools/install-chrome
+      - browser-tools/install-chromedriver
+      - run:
+          name: Install dependencies
+          command: |
+            curl -LsSf https://astral.sh/uv/install.sh | sh
+            uv venv
+            source .venv/bin/activate
+            uv pip install .
+            uv pip install -r ./test_requirements/requirements_optional.txt
+            # Install Kaleido v1 instead of the default version
+            uv pip uninstall -y kaleido
+            uv pip install 'git+https://github.com/plotly/Kaleido.git@v1.0.0rc10#subdirectory=src/py'
+      - run:
+          name: List installed packages and python version
+          command: |
+            source .venv/bin/activate
+            uv pip list
+            python --version
+      - run:
+          name: Test plotly.io with Kaleido v1
+          command: |
+            source .venv/bin/activate
+            python -m pytest tests/test_io
+          no_output_timeout: 20m
+
 jobs:
   check-code-formatting:
     docker:
@@ -166,6 +195,17 @@ jobs:
           pandas_version: <<parameters.pandas_version>>
           numpy_version: <<parameters.numpy_version>>
 
+  test_kaleido_v1:
+    parameters:
+      python_version:
+        default: "3.12"
+        type: string
+    executor:
+      name: docker-container
+      python_version: <<parameters.python_version>>
+    steps:  
+      - test_io_kaleido_v1
+
   # Percy
   python_311_percy:
     docker:
@@ -448,5 +488,10 @@ workflows:
           python_version: "3.9"
           pandas_version: "1.2.4"
           numpy_version: "1.26.4"
+      - test_kaleido_v1:
+          matrix:
+            parameters:
+              python_version:
+                - "3.12"
       - python_311_percy
       - build-doc

From 577d3ca1afe418e80bbfa6d4d487485a361db4c3 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 11 Mar 2025 15:13:11 -0400
Subject: [PATCH 07/39] remove -y option

---
 .circleci/config.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index fe5371f176e..ae2a0b07157 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -131,7 +131,7 @@ commands:
             uv pip install .
             uv pip install -r ./test_requirements/requirements_optional.txt
             # Install Kaleido v1 instead of the default version
-            uv pip uninstall -y kaleido
+            uv pip uninstall kaleido
             uv pip install 'git+https://github.com/plotly/Kaleido.git@v1.0.0rc10#subdirectory=src/py'
       - run:
           name: List installed packages and python version

From 89209ad8b7a9383134809f25783c3661ba0d3713 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 11 Mar 2025 17:18:19 -0400
Subject: [PATCH 08/39] run test_kaleido instead of test_io

---
 .circleci/config.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index ae2a0b07157..ee1e3332782 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -140,10 +140,10 @@ commands:
             uv pip list
             python --version
       - run:
-          name: Test plotly.io with Kaleido v1
+          name: Test plotly.io image output with Kaleido v1
           command: |
             source .venv/bin/activate
-            python -m pytest tests/test_io
+            python -m pytest tests/test_optional/test_kaleido
           no_output_timeout: 20m
 
 jobs:

From 96bf9a04dc1112636641ca0acce86162161a461b Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 13 Mar 2025 10:40:35 -0400
Subject: [PATCH 09/39] re-add orca, add deprecation warnings for orca and
 kaleido-v0 (exact text TBD)

---
 plotly/io/_kaleido.py | 61 ++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 60 insertions(+), 1 deletion(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 759589c980c..7c8a172366b 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -3,7 +3,7 @@
 from pathlib import Path
 import importlib.metadata as importlib_metadata
 from packaging.version import Version
-import tempfile
+import warnings
 
 import plotly
 from plotly.io._utils import validate_coerce_fig_to_dict
@@ -91,6 +91,55 @@ def to_image(
         The image data
     """
 
+    # Handle engine
+    # -------------
+    if engine is not None:
+        warnings.warn(
+            "The 'engine' parameter is deprecated and will be removed in a future version.",
+            DeprecationWarning,
+        )
+        engine = "auto"
+
+    if engine == "auto":
+        if kaleido_available:
+            # Default to kaleido if available
+            engine = "kaleido"
+        else:
+            # See if orca is available
+            from ._orca import validate_executable
+
+            try:
+                validate_executable()
+                engine = "orca"
+            except:
+                # If orca not configured properly, make sure we display the error
+                # message advising the installation of kaleido
+                engine = "kaleido"
+
+    if engine == "orca":
+        warnings.warn(
+            "Support for the 'orca' engine is deprecated and will be removed in a future version. "
+            "Please use the 'kaleido' engine instead.",
+            DeprecationWarning,
+        )
+        # Fall back to legacy orca image export path
+        from ._orca import to_image as to_image_orca
+
+        return to_image_orca(
+            fig,
+            format=format,
+            width=width,
+            height=height,
+            scale=scale,
+            validate=validate,
+        )
+    elif engine != "kaleido":
+        raise ValueError(
+            "Invalid image export engine specified: {engine}".format(
+                engine=repr(engine)
+            )
+        )
+
     # Raise informative error message if Kaleido is not installed
     if not kaleido_available:
         raise ValueError(
@@ -119,6 +168,11 @@ def to_image(
         )
     else:
         # Kaleido v0
+        warnings.warn(
+            "Support for kaleido v0 is deprecated and will be removed in a future version. "
+            "Please upgrade to kaleido v1 by running `pip install kaleido>=1.0.0`.",
+            DeprecationWarning,
+        )
         img_bytes = scope.transform(
             fig_dict, format=format, width=width, height=height, scale=scale
         )
@@ -320,6 +374,11 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
         fig = json.loads(bytes.decode("utf-8"))
     else:
         # Kaleido v0
+        warnings.warn(
+            "Support for kaleido v0 is deprecated and will be removed in a future version. "
+            "Please upgrade to kaleido v1 by running `pip install kaleido>=1.0.0`.",
+            DeprecationWarning,
+        )
         fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))
 
     if as_dict:

From 350dd4816f41dfb99b60ebb55049c3e763bdbfe9 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 20 Mar 2025 15:59:26 -0400
Subject: [PATCH 10/39] error message for 'eps' with kaleido v1

---
 plotly/io/_kaleido.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 7c8a172366b..656fe051ab4 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -156,6 +156,15 @@ def to_image(
     # Request image bytes
     if kaleido_major > 0:
         # Kaleido v1
+        # Check if trying to export to EPS format, which is not supported in Kaleido v1
+        if format == 'eps':
+            raise ValueError(
+                """
+EPS export is not supported with Kaleido v1.
+Please downgrade to Kaleido v0 to use EPS export:
+    $ pip install kaleido==0.2.1
+"""
+            )
         img_bytes = kaleido.calc_fig_sync(
             fig_dict,
             path=None,

From 08c1d4ee5a3d05b0913c37c396354b776ee3f534 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 20 Mar 2025 18:30:38 -0400
Subject: [PATCH 11/39] pin kaleido v1 (prerelease) in test requirements

---
 .circleci/config.yml                        | 14 +++++++-------
 test_requirements/requirements_optional.txt |  3 ++-
 2 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index ee1e3332782..ac8cce9b8fe 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -117,7 +117,7 @@ commands:
             source .venv/bin/activate
             python -m pytest -x test_init/test_lazy_imports.py
 
-  test_io_kaleido_v1:
+  test_io_kaleido_v0:
     steps:
       - checkout
       - browser-tools/install-chrome
@@ -130,9 +130,9 @@ commands:
             source .venv/bin/activate
             uv pip install .
             uv pip install -r ./test_requirements/requirements_optional.txt
-            # Install Kaleido v1 instead of the default version
+            # Install Kaleido v0 instead of the v1 specified in requirements_optional.txt
             uv pip uninstall kaleido
-            uv pip install 'git+https://github.com/plotly/Kaleido.git@v1.0.0rc10#subdirectory=src/py'
+            uv pip install kaleido==0.2.1
       - run:
           name: List installed packages and python version
           command: |
@@ -140,7 +140,7 @@ commands:
             uv pip list
             python --version
       - run:
-          name: Test plotly.io image output with Kaleido v1
+          name: Test plotly.io image output with Kaleido v0
           command: |
             source .venv/bin/activate
             python -m pytest tests/test_optional/test_kaleido
@@ -195,7 +195,7 @@ jobs:
           pandas_version: <<parameters.pandas_version>>
           numpy_version: <<parameters.numpy_version>>
 
-  test_kaleido_v1:
+  test_kaleido_v0:
     parameters:
       python_version:
         default: "3.12"
@@ -204,7 +204,7 @@ jobs:
       name: docker-container
       python_version: <<parameters.python_version>>
     steps:  
-      - test_io_kaleido_v1
+      - test_io_kaleido_v0
 
   # Percy
   python_311_percy:
@@ -488,7 +488,7 @@ workflows:
           python_version: "3.9"
           pandas_version: "1.2.4"
           numpy_version: "1.26.4"
-      - test_kaleido_v1:
+      - test_kaleido_v0:
           matrix:
             parameters:
               python_version:
diff --git a/test_requirements/requirements_optional.txt b/test_requirements/requirements_optional.txt
index 811d93ce80c..ac18a3a86d5 100644
--- a/test_requirements/requirements_optional.txt
+++ b/test_requirements/requirements_optional.txt
@@ -17,7 +17,8 @@ pyshp
 matplotlib
 scikit-image
 psutil
-kaleido
+# kaleido>=1.0.0  # Uncomment and delete line below once Kaleido v1 is released
+git+https://github.com/plotly/Kaleido.git@v1.0.0rc10#subdirectory=src/py
 orjson
 polars[timezone]
 pyarrow

From 8b47a0a9c099cc2e12001d5c4a1d197a8e7fa6b8 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Fri, 21 Mar 2025 16:03:52 -0400
Subject: [PATCH 12/39] format

---
 plotly/io/_kaleido.py | 10 +++-------
 1 file changed, 3 insertions(+), 7 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 656fe051ab4..3cfd0cef1ad 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -157,7 +157,7 @@ def to_image(
     if kaleido_major > 0:
         # Kaleido v1
         # Check if trying to export to EPS format, which is not supported in Kaleido v1
-        if format == 'eps':
+        if format == "eps":
             raise ValueError(
                 """
 EPS export is not supported with Kaleido v1.
@@ -288,9 +288,7 @@ def write_image(
 
     >>> import plotly.io as pio
     >>> pio.write_image(fig, file_path, format='png')
-""".format(
-                    file=file
-                )
+""".format(file=file)
             )
 
     # Request image
@@ -319,9 +317,7 @@ def write_image(
         raise ValueError(
             """
 The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
-""".format(
-                file=file
-            )
+""".format(file=file)
         )
     else:
         # We previously succeeded in interpreting `file` as a pathlib object.

From 249cc9f1f559845dd18997b878a3d72b41cbb519 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 24 Mar 2025 10:59:52 -0400
Subject: [PATCH 13/39] deprecation warning wording

---
 plotly/io/_kaleido.py | 22 +++++++++++++---------
 1 file changed, 13 insertions(+), 9 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 3cfd0cef1ad..2d8133c3466 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -8,6 +8,8 @@
 import plotly
 from plotly.io._utils import validate_coerce_fig_to_dict
 
+ENGINE_SUPPORT_TIMELINE = "September 2025"
+
 try:
     import kaleido
 
@@ -95,7 +97,7 @@ def to_image(
     # -------------
     if engine is not None:
         warnings.warn(
-            "The 'engine' parameter is deprecated and will be removed in a future version.",
+            f"DeprecationWarning: The 'engine' argument is deprecated. Kaleido will be the only supported engine after {ENGINE_SUPPORT_TIMELINE}.",
             DeprecationWarning,
         )
         engine = "auto"
@@ -118,8 +120,8 @@ def to_image(
 
     if engine == "orca":
         warnings.warn(
-            "Support for the 'orca' engine is deprecated and will be removed in a future version. "
-            "Please use the 'kaleido' engine instead.",
+            f"Support for the orca engine is deprecated and  will be removed after {ENGINE_SUPPORT_TIMELINE}. "
+            + "Please install Kaleido (`pip install kaleido`) to use the Kaleido engine.",
             DeprecationWarning,
         )
         # Fall back to legacy orca image export path
@@ -178,8 +180,7 @@ def to_image(
     else:
         # Kaleido v0
         warnings.warn(
-            "Support for kaleido v0 is deprecated and will be removed in a future version. "
-            "Please upgrade to kaleido v1 by running `pip install kaleido>=1.0.0`.",
+            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
             DeprecationWarning,
         )
         img_bytes = scope.transform(
@@ -288,7 +289,9 @@ def write_image(
 
     >>> import plotly.io as pio
     >>> pio.write_image(fig, file_path, format='png')
-""".format(file=file)
+""".format(
+                    file=file
+                )
             )
 
     # Request image
@@ -317,7 +320,9 @@ def write_image(
         raise ValueError(
             """
 The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
-""".format(file=file)
+""".format(
+                file=file
+            )
         )
     else:
         # We previously succeeded in interpreting `file` as a pathlib object.
@@ -380,8 +385,7 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
     else:
         # Kaleido v0
         warnings.warn(
-            "Support for kaleido v0 is deprecated and will be removed in a future version. "
-            "Please upgrade to kaleido v1 by running `pip install kaleido>=1.0.0`.",
+            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
             DeprecationWarning,
         )
         fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))

From ad9dbd90125151926662cc3c5e21773fbff60ad2 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 24 Mar 2025 16:12:22 -0400
Subject: [PATCH 14/39] fix if/else for when engine not specified

---
 plotly/io/_kaleido.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 2d8133c3466..6e360a7cb8b 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -97,9 +97,10 @@ def to_image(
     # -------------
     if engine is not None:
         warnings.warn(
-            f"DeprecationWarning: The 'engine' argument is deprecated. Kaleido will be the only supported engine after {ENGINE_SUPPORT_TIMELINE}.",
+            f"The 'engine' argument is deprecated. Kaleido will be the only supported engine after {ENGINE_SUPPORT_TIMELINE}.",
             DeprecationWarning,
         )
+    else:
         engine = "auto"
 
     if engine == "auto":
@@ -180,7 +181,8 @@ def to_image(
     else:
         # Kaleido v0
         warnings.warn(
-            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
+            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. "
+            + "Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
             DeprecationWarning,
         )
         img_bytes = scope.transform(
@@ -385,7 +387,8 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
     else:
         # Kaleido v0
         warnings.warn(
-            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
+            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. "
+            + "Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
             DeprecationWarning,
         )
         fig = json.loads(scope.transform(fig, format="json").decode("utf-8"))

From e75a5dfeb57f29a417918ff32391aa169b59572b Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 24 Mar 2025 21:11:07 -0400
Subject: [PATCH 15/39] add flow and endpoint for installing chrome via plotly

---
 plotly/io/_kaleido.py | 61 ++++++++++++++++++++++++++++++++++++-------
 pyproject.toml        |  3 +++
 2 files changed, 54 insertions(+), 10 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 6e360a7cb8b..842fe73f5f1 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -168,16 +168,28 @@ def to_image(
     $ pip install kaleido==0.2.1
 """
             )
-        img_bytes = kaleido.calc_fig_sync(
-            fig_dict,
-            path=None,
-            opts=dict(
-                format=format,
-                width=width,
-                height=height,
-                scale=scale,
-            ),
-        )
+        import choreographer
+
+        try:
+            img_bytes = kaleido.calc_fig_sync(
+                fig_dict,
+                path=None,
+                opts=dict(
+                    format=format,
+                    width=width,
+                    height=height,
+                    scale=scale,
+                ),
+            )
+        except choreographer.errors.ChromeNotFoundError:
+            raise RuntimeError(
+                """
+
+Kaleido requires Google Chrome to be installed. Install it by running:
+    $ plotly_install_chrome
+"""
+            )
+
     else:
         # Kaleido v0
         warnings.warn(
@@ -192,6 +204,35 @@ def to_image(
     return img_bytes
 
 
+def install_chrome():
+    """
+    Install Google Chrome for Kaleido
+    This function can be run from the command line using the command plotly_install_chrome
+    defined in pyproject.toml
+    """
+    if not kaleido_available or kaleido_major < 1:
+        raise ValueError(
+            "This command requires Kaleido v1.0.0 or greater. Install it using `pip install kaleido`."
+        )
+    import choreographer
+    import sys
+
+    cli_yes = len(sys.argv) > 1 and sys.argv[1] == "-y"
+    if not cli_yes:
+        print(
+            "\nPlotly will install a copy of Google Chrome to be used for generating static images of plots.\n"
+        )
+        # TODO: Print path where Chrome will be installed
+        # print(f"Chrome will be installed at {chrome_download_path}\n")
+        response = input("Do you want to proceed? [y/n] ")
+        if not response or response[0].lower() != "y":
+            print("Cancelled")
+            return
+    print("Installing Chrome for Plotly...")
+    kaleido.get_chrome_sync()
+    print("Chrome installed successfully.")
+
+
 def write_image(
     fig,
     file,
diff --git a/pyproject.toml b/pyproject.toml
index e4afcdb57d4..3e36c5da1bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,9 @@ dependencies = [
 [project.optional-dependencies]
 express = ["numpy"]
 
+[project.scripts]
+plotly_install_chrome = "plotly.io._kaleido:install_chrome"
+
 [tool.setuptools.packages.find]
 where = ["."]
 include = ["plotly*", "_plotly*"]

From a2b4f3c6801db5277798713fbd35e3d6b5b9b8a9 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 25 Mar 2025 14:02:06 -0400
Subject: [PATCH 16/39] add write_images and to_images functions (not yet using
 shared Kaleido so they are not faster

---
 plotly/io/__init__.py |   6 +-
 plotly/io/_kaleido.py | 150 ++++++++++++++++++++++++++++++++----------
 plotly/io/_utils.py   |  36 ++++++++++
 3 files changed, 156 insertions(+), 36 deletions(-)

diff --git a/plotly/io/__init__.py b/plotly/io/__init__.py
index ef5b5ea05c7..1e261fd05ed 100644
--- a/plotly/io/__init__.py
+++ b/plotly/io/__init__.py
@@ -3,7 +3,7 @@
 from typing import TYPE_CHECKING
 
 if sys.version_info < (3, 7) or TYPE_CHECKING:
-    from ._kaleido import to_image, write_image, full_figure_for_development
+    from ._kaleido import to_image, write_image, to_images, write_images, full_figure_for_development
     from . import orca, kaleido
     from . import json
     from ._json import to_json, from_json, read_json, write_json
@@ -15,6 +15,8 @@
     __all__ = [
         "to_image",
         "write_image",
+        "to_images",
+        "write_images",
         "orca",
         "json",
         "to_json",
@@ -37,6 +39,8 @@
         [
             "._kaleido.to_image",
             "._kaleido.write_image",
+            "._kaleido.to_images",
+            "._kaleido.write_images",
             "._kaleido.full_figure_for_development",
             "._json.to_json",
             "._json.from_json",
diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 842fe73f5f1..c0280fe2ac0 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -6,7 +6,7 @@
 import warnings
 
 import plotly
-from plotly.io._utils import validate_coerce_fig_to_dict
+from plotly.io._utils import validate_coerce_fig_to_dict, as_individual_kwargs
 
 ENGINE_SUPPORT_TIMELINE = "September 2025"
 
@@ -29,6 +29,7 @@
             scope.mathjax = (
                 "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js"
             )
+
 except ImportError as e:
     kaleido_available = False
     kaleido_major = -1
@@ -37,7 +38,14 @@
 
 
 def to_image(
-    fig, format=None, width=None, height=None, scale=None, validate=True, engine=None
+    fig,
+    format=None,
+    width=None,
+    height=None,
+    scale=None,
+    validate=True,
+    engine=None,
+    kaleido_instance=None,
 ):
     """
     Convert a figure to a static image bytes string
@@ -87,6 +95,9 @@ def to_image(
     engine (deprecated): str
         No longer used. Kaleido is the only supported engine.
 
+    kaleido_instance: kaleido.Kaleido or None
+        An instance of the Kaleido class. If None, a new instance will be created.
+
     Returns
     -------
     bytes
@@ -162,15 +173,17 @@ def to_image(
         # Check if trying to export to EPS format, which is not supported in Kaleido v1
         if format == "eps":
             raise ValueError(
-                """
-EPS export is not supported with Kaleido v1.
-Please downgrade to Kaleido v0 to use EPS export:
-    $ pip install kaleido==0.2.1
+                f"""
+EPS export is not supported with Kaleido v1. Please use SVG or PDF instead.
+You can also downgrade to Kaleido v0, but support for v0 will be removed after {ENGINE_SUPPORT_TIMELINE}.
+To downgrade to Kaleido v0, run:
+    $ pip install kaleido<1.0.0
 """
             )
         import choreographer
 
         try:
+            # TODO: Actually use provided kaleido_instance here
             img_bytes = kaleido.calc_fig_sync(
                 fig_dict,
                 path=None,
@@ -204,35 +217,6 @@ def to_image(
     return img_bytes
 
 
-def install_chrome():
-    """
-    Install Google Chrome for Kaleido
-    This function can be run from the command line using the command plotly_install_chrome
-    defined in pyproject.toml
-    """
-    if not kaleido_available or kaleido_major < 1:
-        raise ValueError(
-            "This command requires Kaleido v1.0.0 or greater. Install it using `pip install kaleido`."
-        )
-    import choreographer
-    import sys
-
-    cli_yes = len(sys.argv) > 1 and sys.argv[1] == "-y"
-    if not cli_yes:
-        print(
-            "\nPlotly will install a copy of Google Chrome to be used for generating static images of plots.\n"
-        )
-        # TODO: Print path where Chrome will be installed
-        # print(f"Chrome will be installed at {chrome_download_path}\n")
-        response = input("Do you want to proceed? [y/n] ")
-        if not response or response[0].lower() != "y":
-            print("Cancelled")
-            return
-    print("Installing Chrome for Plotly...")
-    kaleido.get_chrome_sync()
-    print("Chrome installed successfully.")
-
-
 def write_image(
     fig,
     file,
@@ -242,6 +226,7 @@ def write_image(
     height=None,
     validate=True,
     engine="auto",
+    kaleido_instance=None,
 ):
     """
     Convert a figure to a static image and write it to a file or writeable
@@ -300,6 +285,9 @@ def write_image(
     engine (deprecated): str
         No longer used. Kaleido is the only supported engine.
 
+    kaleido_instance: kaleido.Kaleido or None
+        An instance of the Kaleido class. If None, a new instance will be created.
+
     Returns
     -------
     None
@@ -348,6 +336,7 @@ def write_image(
         height=height,
         validate=validate,
         engine=engine,
+        kaleido_instance=kaleido_instance,
     )
 
     # Open file
@@ -373,6 +362,69 @@ def write_image(
         path.write_bytes(img_data)
 
 
+def to_images(**kwargs):
+    """
+    Convert multiple figures to static images and return a list of image bytes
+
+    Parameters
+    ----------
+    Accepts the same parameters as pio.to_image(), but any parameter may be either
+    a single value or a list of values. If more than one parameter is a list,
+    all must be the same length.
+
+    Returns
+    -------
+    list of bytes
+        The image data
+    """
+    individual_kwargs = as_individual_kwargs(**kwargs)
+
+    if kaleido_available and kaleido_major > 0:
+        # Kaleido v1
+        # TODO: Use a single shared kaleido instance for all images
+        return [to_image(**kw) for kw in individual_kwargs]
+    else:
+        # Kaleido v0, or orca
+        return [to_image(**kw) for kw in individual_kwargs]
+
+
+def write_images(**kwargs):
+    """
+    Write multiple images to files or writeable objects. This is much faster than
+    calling write_image() multiple times.
+
+    Parameters
+    ----------
+    Accepts the same parameters as pio.write_image(), but any parameter may be either
+    a single value or a list of values. If more than one parameter is a list,
+    all must be the same length.
+
+    Returns
+    -------
+    None
+    """
+
+    if "file" not in kwargs:
+        raise ValueError("'file' argument is required")
+
+    # Get individual arguments, and separate out the 'file' argument
+    individual_kwargs = as_individual_kwargs(**kwargs)
+    files = [kw["file"] for kw in individual_kwargs]
+    individual_kwargs = [
+        {k: v for k, v in kw.items() if k != "file"} for kw in individual_kwargs
+    ]
+
+    if kaleido_available and kaleido_major > 0:
+        # Kaleido v1
+        # TODO: Use a single shared kaleido instance for all images
+        for f, kw in zip(files, individual_kwargs):
+            write_image(file=f, **kw)
+    else:
+        # Kaleido v0, or orca
+        for f, kw in zip(files, individual_kwargs):
+            write_image(file=f, **kw)
+
+
 def full_figure_for_development(fig, warn=True, as_dict=False):
     """
     Compute default values for all attributes not specified in the input figure and
@@ -442,4 +494,32 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
         return go.Figure(fig, skip_invalid=True)
 
 
+def install_chrome():
+    """
+    Install Google Chrome for Kaleido
+    This function can be run from the command line using the command `plotly_install_chrome`
+    defined in pyproject.toml
+    """
+    if not kaleido_available or kaleido_major < 1:
+        raise ValueError(
+            "This command requires Kaleido v1.0.0 or greater. Install it using `pip install kaleido`."
+        )
+    import sys
+
+    cli_yes = len(sys.argv) > 1 and sys.argv[1] == "-y"
+    if not cli_yes:
+        print(
+            "\nPlotly will install a copy of Google Chrome to be used for generating static images of plots.\n"
+        )
+        # TODO: Print path where Chrome will be installed
+        # print(f"Chrome will be installed at {chrome_download_path}\n")
+        response = input("Do you want to proceed? [y/n] ")
+        if not response or response[0].lower() != "y":
+            print("Cancelled")
+            return
+    print("Installing Chrome for Plotly...")
+    kaleido.get_chrome_sync()
+    print("Chrome installed successfully.")
+
+
 __all__ = ["to_image", "write_image", "scope", "full_figure_for_development"]
diff --git a/plotly/io/_utils.py b/plotly/io/_utils.py
index 658540ca71a..08a2ec195b2 100644
--- a/plotly/io/_utils.py
+++ b/plotly/io/_utils.py
@@ -43,6 +43,42 @@ def validate_coerce_output_type(output_type):
     return cls
 
 
+def as_individual_kwargs(**kwargs):
+    """
+    Given one or more keyword arguments which may be either a single value or a list of values,
+    return a list of dictionaries where each dictionary has only single values for each keyword
+    by expanding the single values into lists.
+    If more than one keyword is a list, all lists must be the same length.
+
+    Parameters
+    ----------
+    kwargs: dict
+        The keyword arguments
+
+    Returns
+    -------
+    list of dicts
+        A list of dictionaries
+    """
+    # Check that all list arguments have the same length,
+    # and find out what that length is
+    # If there are no list arguments, length is 1
+    list_lengths = [len(v) for v in kwargs.values() if isinstance(v, list)]
+    if list_lengths and len(set(list_lengths)) > 1:
+        raise ValueError("All list arguments must have the same length.")
+    list_length = list_lengths[0] if list_lengths else 1
+
+    # Expand all arguments to lists of the same length
+    expanded_kwargs = {
+        k: [v] * list_length if not isinstance(v, list) else v
+        for k, v in kwargs.items()
+    }
+
+    # Reshape into a list of dictionaries
+    # Each dictionary represents the arguments for a single call to to_image
+    return [{k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)]
+
+
 def plotly_cdn_url(cdn_ver=get_plotlyjs_version()):
     """Return a valid plotly CDN url."""
     return "https://cdn.plot.ly/plotly-{cdn_ver}.min.js".format(

From 254929980a72f119ddf0ce9cd99f3178abb9e3ed Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Tue, 25 Mar 2025 15:01:27 -0400
Subject: [PATCH 17/39] format

---
 plotly/io/__init__.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/plotly/io/__init__.py b/plotly/io/__init__.py
index 1e261fd05ed..eb69587b635 100644
--- a/plotly/io/__init__.py
+++ b/plotly/io/__init__.py
@@ -3,7 +3,13 @@
 from typing import TYPE_CHECKING
 
 if sys.version_info < (3, 7) or TYPE_CHECKING:
-    from ._kaleido import to_image, write_image, to_images, write_images, full_figure_for_development
+    from ._kaleido import (
+        to_image,
+        write_image,
+        to_images,
+        write_images,
+        full_figure_for_development,
+    )
     from . import orca, kaleido
     from . import json
     from ._json import to_json, from_json, read_json, write_json

From ab3b70062f73d48bbe803161996bf85b3c75c983 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Wed, 26 Mar 2025 22:59:30 -0400
Subject: [PATCH 18/39] fix test_image_renderer() test to work with both
 kaleido v0 and v1

---
 .../test_kaleido/test_kaleido.py              | 33 +++++++++++--------
 1 file changed, 20 insertions(+), 13 deletions(-)

diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index 436f2813b4b..37c1d843f6f 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -1,6 +1,8 @@
-from io import BytesIO
+from io import BytesIO, StringIO
 from pathlib import Path
 import tempfile
+from contextlib import redirect_stdout
+import base64
 
 from pdfrw import PdfReader
 from PIL import Image
@@ -88,18 +90,23 @@ def test_kaleido_engine_write_image_kwargs(tmp_path):
 
 
 def test_image_renderer():
-    # TODO: How to replicate this test using kaleido v1?
-    # with mocked_scope() as scope:
-    pio.show(fig, renderer="svg", engine="kaleido", validate=False)
-
-    renderer = pio.renderers["svg"]
-    # scope.transform.assert_called_with(
-    #     fig,
-    #     format="svg",
-    #     width=None,
-    #     height=None,
-    #     scale=renderer.scale,
-    # )
+    """Verify that the image renderer returns the expected mimebundle."""
+    with redirect_stdout(StringIO()) as f:
+        pio.show(fig, renderer="png", engine="kaleido", validate=False)
+    mimebundle = f.getvalue().strip()
+    mimebundle_expected = str(
+        {
+            "image/png": base64.b64encode(
+                pio.to_image(
+                    fig,
+                    format="png",
+                    engine="kaleido",
+                    validate=False,
+                )
+            ).decode("utf8")
+        }
+    )
+    assert mimebundle == mimebundle_expected
 
 
 def test_bytesio():

From de473e1eafbfbfbeb8a5f7e8fabfaa071dd2e0ad Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Wed, 26 Mar 2025 23:01:48 -0400
Subject: [PATCH 19/39] support pos args in to_images and write_images; rename
 plotly_install_chrome to plotly_get_chrome

---
 plotly/io/_kaleido.py | 37 +++++++++++++++----------------------
 plotly/io/_utils.py   | 29 +++++++++++++++++++++--------
 pyproject.toml        |  2 +-
 3 files changed, 37 insertions(+), 31 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index c0280fe2ac0..991919dc32d 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -6,7 +6,7 @@
 import warnings
 
 import plotly
-from plotly.io._utils import validate_coerce_fig_to_dict, as_individual_kwargs
+from plotly.io._utils import validate_coerce_fig_to_dict, as_individual_args
 
 ENGINE_SUPPORT_TIMELINE = "September 2025"
 
@@ -199,7 +199,7 @@ def to_image(
                 """
 
 Kaleido requires Google Chrome to be installed. Install it by running:
-    $ plotly_install_chrome
+    $ plotly_get_chrome
 """
             )
 
@@ -362,7 +362,7 @@ def write_image(
         path.write_bytes(img_data)
 
 
-def to_images(**kwargs):
+def to_images(*args, **kwargs):
     """
     Convert multiple figures to static images and return a list of image bytes
 
@@ -377,18 +377,18 @@ def to_images(**kwargs):
     list of bytes
         The image data
     """
-    individual_kwargs = as_individual_kwargs(**kwargs)
+    individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
 
     if kaleido_available and kaleido_major > 0:
         # Kaleido v1
         # TODO: Use a single shared kaleido instance for all images
-        return [to_image(**kw) for kw in individual_kwargs]
+        return [to_image(*a, **kw) for a, kw in zip(individual_args, individual_kwargs)]
     else:
         # Kaleido v0, or orca
-        return [to_image(**kw) for kw in individual_kwargs]
+        return [to_image(*a, **kw) for a, kw in zip(individual_args, individual_kwargs)]
 
 
-def write_images(**kwargs):
+def write_images(*args, **kwargs):
     """
     Write multiple images to files or writeable objects. This is much faster than
     calling write_image() multiple times.
@@ -404,25 +404,18 @@ def write_images(**kwargs):
     None
     """
 
-    if "file" not in kwargs:
-        raise ValueError("'file' argument is required")
-
-    # Get individual arguments, and separate out the 'file' argument
-    individual_kwargs = as_individual_kwargs(**kwargs)
-    files = [kw["file"] for kw in individual_kwargs]
-    individual_kwargs = [
-        {k: v for k, v in kw.items() if k != "file"} for kw in individual_kwargs
-    ]
+    # Get individual arguments
+    individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
 
     if kaleido_available and kaleido_major > 0:
         # Kaleido v1
         # TODO: Use a single shared kaleido instance for all images
-        for f, kw in zip(files, individual_kwargs):
-            write_image(file=f, **kw)
+        for a, kw in zip(individual_args, individual_kwargs):
+            write_image(**kw)
     else:
         # Kaleido v0, or orca
-        for f, kw in zip(files, individual_kwargs):
-            write_image(file=f, **kw)
+        for a, kw in zip(individual_args, individual_kwargs):
+            write_image(*a, **kw)
 
 
 def full_figure_for_development(fig, warn=True, as_dict=False):
@@ -494,10 +487,10 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
         return go.Figure(fig, skip_invalid=True)
 
 
-def install_chrome():
+def get_chrome():
     """
     Install Google Chrome for Kaleido
-    This function can be run from the command line using the command `plotly_install_chrome`
+    This function can be run from the command line using the command `plotly_get_chrome`
     defined in pyproject.toml
     """
     if not kaleido_available or kaleido_major < 1:
diff --git a/plotly/io/_utils.py b/plotly/io/_utils.py
index 08a2ec195b2..50a584beb64 100644
--- a/plotly/io/_utils.py
+++ b/plotly/io/_utils.py
@@ -43,40 +43,53 @@ def validate_coerce_output_type(output_type):
     return cls
 
 
-def as_individual_kwargs(**kwargs):
+def as_individual_args(*args, **kwargs):
     """
-    Given one or more keyword arguments which may be either a single value or a list of values,
-    return a list of dictionaries where each dictionary has only single values for each keyword
+    Given one or more positional or keyword arguments which may be either a single value
+    or a list of values, return a list of lists and a list of dictionaries
     by expanding the single values into lists.
-    If more than one keyword is a list, all lists must be the same length.
+    If more than one item in the input is a list, all lists must be the same length.
 
     Parameters
     ----------
-    kwargs: dict
+    *args: list
+        The positional arguments
+    **kwargs: dict
         The keyword arguments
 
     Returns
     -------
+    list of lists
+        A list of lists
     list of dicts
         A list of dictionaries
     """
     # Check that all list arguments have the same length,
     # and find out what that length is
     # If there are no list arguments, length is 1
-    list_lengths = [len(v) for v in kwargs.values() if isinstance(v, list)]
+    list_lengths = [len(v) for v in args + tuple(kwargs.values()) if isinstance(v, list)]
     if list_lengths and len(set(list_lengths)) > 1:
         raise ValueError("All list arguments must have the same length.")
     list_length = list_lengths[0] if list_lengths else 1
 
     # Expand all arguments to lists of the same length
+    expanded_args = [
+        [v] * list_length if not isinstance(v, list) else v for v in args
+    ]
     expanded_kwargs = {
         k: [v] * list_length if not isinstance(v, list) else v
         for k, v in kwargs.items()
     }
 
+    # Reshape into a list of lists
+    # Each list represents the positional arguments for a single function call
+    list_of_args = [[v[i] for v in expanded_args] for i in range(list_length)]
+
     # Reshape into a list of dictionaries
-    # Each dictionary represents the arguments for a single call to to_image
-    return [{k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)]
+    # Each dictionary represents the keyword arguments for a single function call
+    list_of_kwargs = [{k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)]
+
+    return list_of_args, list_of_kwargs
 
 
 def plotly_cdn_url(cdn_ver=get_plotlyjs_version()):
diff --git a/pyproject.toml b/pyproject.toml
index 3e36c5da1bc..a4e1c902361 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ dependencies = [
 express = ["numpy"]
 
 [project.scripts]
-plotly_install_chrome = "plotly.io._kaleido:install_chrome"
+plotly_get_chrome = "plotly.io._kaleido:get_chrome"
 
 [tool.setuptools.packages.find]
 where = ["."]

From 71696fe2bbb869eccb9d43f309d84e30c2e0a7e6 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 27 Mar 2025 00:01:29 -0400
Subject: [PATCH 20/39] mising pos args in write_images()

---
 plotly/io/_kaleido.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 991919dc32d..f01cf850912 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -186,7 +186,6 @@ def to_image(
             # TODO: Actually use provided kaleido_instance here
             img_bytes = kaleido.calc_fig_sync(
                 fig_dict,
-                path=None,
                 opts=dict(
                     format=format,
                     width=width,
@@ -411,7 +410,7 @@ def write_images(*args, **kwargs):
         # Kaleido v1
         # TODO: Use a single shared kaleido instance for all images
         for a, kw in zip(individual_args, individual_kwargs):
-            write_image(**kw)
+            write_image(*a, **kw)
     else:
         # Kaleido v0, or orca
         for a, kw in zip(individual_args, individual_kwargs):

From 100b95580cbf9c808996bad71cda1ccba4225edd Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 27 Mar 2025 00:01:54 -0400
Subject: [PATCH 21/39] add tests for write_images() and to_images()

---
 .../test_kaleido/test_kaleido.py              | 130 ++++++++++++++++++
 1 file changed, 130 insertions(+)

diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index 37c1d843f6f..66b815bab1e 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -3,6 +3,8 @@
 import tempfile
 from contextlib import redirect_stdout
 import base64
+import unittest
+from unittest import mock
 
 from pdfrw import PdfReader
 from PIL import Image
@@ -121,3 +123,131 @@ def test_bytesio():
     bio_bytes = bio.read()
     to_image_bytes = pio.to_image(fig, format="jpg", engine="kaleido", validate=False)
     assert bio_bytes == to_image_bytes
+
+
+@mock.patch("plotly.io._kaleido.to_image")
+def test_to_images_single(mock_to_image):
+    """Test to_images with a single figure"""
+    pio.to_images(
+        fig,
+        format="png",
+        width=800,
+        height=600,
+        scale=2,
+        validate=True,
+    )
+
+    # Verify that to_image was called once with the correct arguments
+    expected_calls = [
+        mock.call(
+            fig,
+            format="png",
+            width=800,
+            height=600,
+            scale=2,
+            validate=True,
+        )
+    ]
+    mock_to_image.assert_has_calls(expected_calls, any_order=False)
+    assert mock_to_image.call_count == 1
+
+
+@mock.patch("plotly.io._kaleido.to_image")
+def test_to_images_multiple(mock_to_image):
+    """Test to_images with lists"""
+    fig1 = {"data": [], "layout": {"title": {"text": "figure 1"}}}
+    fig2 = {"data": [], "layout": {"title": {"text": "figure 2"}}}
+    pio.to_images(
+        [fig1, fig2],
+        "png",
+        width=[800, 400],
+        height=600,
+        scale=[1, 2],
+        validate=True,
+    )
+
+    # Verify that to_image was called with the correct arguments in the correct order
+    expected_calls = [
+        mock.call(
+            fig1,
+            "png",
+            width=800,
+            height=600,
+            scale=1,
+            validate=True,
+        ),
+        mock.call(
+            fig2,
+            "png",
+            width=400,
+            height=600,
+            scale=2,
+            validate=True,
+        ),
+    ]
+    mock_to_image.assert_has_calls(expected_calls, any_order=False)
+    assert mock_to_image.call_count == 2
+
+
+@mock.patch("plotly.io._kaleido.write_image")
+def test_write_images_single(mock_write_image):
+    """Test write_images with only single arguments"""
+    pio.write_images(
+        fig,
+        "output.png",
+        format="png",
+        width=800,
+        height=600,
+        scale=2,
+    )
+
+    # Verify that write_image was called once with the correct arguments
+    expected_calls = [
+        mock.call(
+            fig,
+            "output.png",
+            format="png",
+            width=800,
+            height=600,
+            scale=2,
+        )
+    ]
+    mock_write_image.assert_has_calls(expected_calls, any_order=False)
+    assert mock_write_image.call_count == 1
+
+
+@mock.patch("plotly.io._kaleido.write_image")
+def test_write_images_multiple(mock_write_image):
+    """Test write_images with list arguments"""
+    fig1 = {"data": [], "layout": {"title": {"text": "figure 1"}}}
+    fig2 = {"data": [], "layout": {"title": {"text": "figure 2"}}}
+    pio.write_images(
+        [fig1, fig2],
+        ["output1.png", "output2.jpg"],
+        format=["png", "jpeg"],
+        width=800,
+        height=[600, 400],
+        scale=2,
+    )
+
+    # Verify that write_image was called with the correct arguments in the correct order
+    expected_calls = [
+        mock.call(
+            fig1,
+            "output1.png",
+            format="png",
+            width=800,
+            height=600,
+            scale=2,
+        ),
+        mock.call(
+            fig2,
+            "output2.jpg",
+            format="jpeg",
+            width=800,
+            height=400,
+            scale=2,
+        ),
+    ]
+    mock_write_image.assert_has_calls(expected_calls, any_order=False)
+    assert mock_write_image.call_count == 2

From 5541a7943123eff9e24bb061c5456ae67d572d7c Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 27 Mar 2025 03:11:56 -0400
Subject: [PATCH 22/39] add new API for setting image generation defaults

---
 plotly/io/__init__.py                         |   3 +
 plotly/io/_defaults.py                        |  18 +++
 plotly/io/_kaleido.py                         | 118 +++++++++++++++---
 plotly/io/kaleido.py                          |   2 +-
 .../test_kaleido/test_kaleido.py              |  13 ++
 5 files changed, 137 insertions(+), 17 deletions(-)
 create mode 100644 plotly/io/_defaults.py

diff --git a/plotly/io/__init__.py b/plotly/io/__init__.py
index eb69587b635..539313ce7cc 100644
--- a/plotly/io/__init__.py
+++ b/plotly/io/__init__.py
@@ -17,6 +17,7 @@
     from ._html import to_html, write_html
     from ._renderers import renderers, show
     from . import base_renderers
+    from ._defaults import defaults
 
     __all__ = [
         "to_image",
@@ -37,6 +38,7 @@
         "show",
         "base_renderers",
         "full_figure_for_development",
+        "defaults",
     ]
 else:
     __all__, __getattr__, __dir__ = relative_import(
@@ -58,6 +60,7 @@
             "._html.write_html",
             "._renderers.renderers",
             "._renderers.show",
+            "._defaults.defaults",
         ],
     )
 
diff --git a/plotly/io/_defaults.py b/plotly/io/_defaults.py
new file mode 100644
index 00000000000..5b0f3a054a3
--- /dev/null
+++ b/plotly/io/_defaults.py
@@ -0,0 +1,18 @@
+# Default settings for image generation
+
+
+class _Defaults(object):
+    """
+    Class to store default settings for image generation.
+    """
+    def __init__(self):
+        self.default_format = "png"
+        self.default_width = 700
+        self.default_height = 500
+        self.default_scale = 1
+
+defaults = _Defaults()
+
+
+
+
diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index f01cf850912..8c8c3793094 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -7,9 +7,32 @@
 
 import plotly
 from plotly.io._utils import validate_coerce_fig_to_dict, as_individual_args
+from plotly.io import defaults
 
 ENGINE_SUPPORT_TIMELINE = "September 2025"
 
+kaleido_scope_default_getwarning = (
+    lambda x: f"""
+Accessing plotly.io.kaleido.scope.{x} is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Please use plotly.io.defaults.{x} instead.
+"""
+)
+
+kaleido_scope_default_setwarning = (
+    lambda x: f"""
+Setting plotly.io.kaleido.scope.{x} is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. "
+Please set plotly.io.defaults.{x} instead.
+"""
+)
+
+bad_attribute_error = (
+    lambda x: f"""
+Attribute plotly.io.defaults.{x} is not valid.
+Also, plotly.io.kaleido.scope.* is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please use plotly.io.defaults.* instead.
+"""
+)
+
+
 try:
     import kaleido
 
@@ -20,7 +43,28 @@
         # Kaleido v0
         from kaleido.scopes.plotly import PlotlyScope
 
-        scope = PlotlyScope()
+        # Show a deprecation warning if the old method of setting defaults is used
+        class PlotlyScopeWithDeprecationWarnings(PlotlyScope):
+            def __setattr__(self, name, value):
+                if name in defaults.__dict__:
+                    warnings.warn(
+                        kaleido_scope_default_setwarning(name),
+                        DeprecationWarning,
+                        stacklevel=2,
+                    )
+                    setattr(defaults, name, value)
+                super(PlotlyScopeWithDeprecationWarnings, self).__setattr__(name, value)
+
+            def __getattr__(self, name):
+                if name in defaults.__dict__:
+                    warnings.warn(
+                        kaleido_scope_default_getwarning(name),
+                        DeprecationWarning,
+                        stacklevel=2,
+                    )
+                return super(PlotlyScopeWithDeprecationWarnings, self).__getattr__(name)
+
+        scope = PlotlyScopeWithDeprecationWarnings()
         # Compute absolute path to the 'plotly/package_data/' directory
         root_dir = os.path.dirname(os.path.abspath(plotly.__file__))
         package_dir = os.path.join(root_dir, "package_data")
@@ -29,6 +73,34 @@
             scope.mathjax = (
                 "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js"
             )
+    else:
+        # Kaleido v1
+
+        # Show a deprecation warning if the old method of setting defaults is used
+        class DefaultsDeprecationWarning:
+            def __getattr__(self, name):
+                if name in defaults.__dict__:
+                    warnings.warn(
+                        kaleido_scope_default_getwarning(name),
+                        DeprecationWarning,
+                        stacklevel=2,
+                    )
+                    return getattr(defaults, name)
+                else:
+                    raise AttributeError(bad_attribute_error(name))
+
+            def __setattr__(self, name, value):
+                if name in defaults.__dict__:
+                    warnings.warn(
+                        kaleido_scope_default_setwarning(name),
+                        DeprecationWarning,
+                        stacklevel=2,
+                    )
+                    setattr(defaults, name, value)
+                else:
+                    raise AttributeError(bad_attribute_error(name))
+
+        scope = DefaultsDeprecationWarning()
 
 except ImportError as e:
     kaleido_available = False
@@ -64,21 +136,27 @@ def to_image(
             - 'pdf'
             - 'eps' (Requires the poppler library to be installed and on the PATH)
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_format`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_format` if engine is "kaleido"
+            - `plotly.io.orca.config.default_format` if engine is "orca" (deprecated)
 
     width: int or None
         The width of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the width of the exported image
         in physical pixels.
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_width`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_width` if engine is "kaleido"
+            - `plotly.io.orca.config.default_width` if engine is "orca" (deprecated)
 
     height: int or None
         The height of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the height of the exported image
         in physical pixels.
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_height`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_height` if engine is "kaleido"
+            - `plotly.io.orca.config.default_height` if engine is "orca" (deprecated)
 
     scale: int or float or None
         The scale factor to use when exporting the figure. A scale factor
@@ -86,7 +164,9 @@ def to_image(
         to the figure's layout pixel dimensions. Whereas as scale factor of
         less than 1.0 will decrease the image resolution.
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_scale`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_scale` if engine is "kaliedo"
+            - `plotly.io.orca.config.default_scale` if engine is "orca" (deprecated)
 
     validate: bool
         True if the figure should be validated before being converted to
@@ -174,8 +254,8 @@ def to_image(
         if format == "eps":
             raise ValueError(
                 f"""
-EPS export is not supported with Kaleido v1. Please use SVG or PDF instead.
-You can also downgrade to Kaleido v0, but support for v0 will be removed after {ENGINE_SUPPORT_TIMELINE}.
+EPS export is not supported by Kaleido v1. Please use SVG or PDF instead.
+You can also downgrade to Kaleido v0, but support for Kaleido v0 will be removed after {ENGINE_SUPPORT_TIMELINE}.
 To downgrade to Kaleido v0, run:
     $ pip install kaleido<1.0.0
 """
@@ -187,10 +267,10 @@ def to_image(
             img_bytes = kaleido.calc_fig_sync(
                 fig_dict,
                 opts=dict(
-                    format=format,
-                    width=width,
-                    height=height,
-                    scale=scale,
+                    format=format or defaults.default_format,
+                    width=width or defaults.default_width,
+                    height=height or defaults.default_height,
+                    scale=scale or defaults.default_scale,
                 ),
             )
         except choreographer.errors.ChromeNotFoundError:
@@ -252,22 +332,26 @@ def write_image(
         If not specified and `file` is a string then this will default to the
         file extension. If not specified and `file` is not a string then this
         will default to:
-            - `plotly.io.kaleido.scope.default_format` if engine is "kaleido"
-            - `plotly.io.orca.config.default_format` if engine is "orca"
+            - `plotly.io.defaults.default_format` if engine is "kaleido"
+            - `plotly.io.orca.config.default_format` if engine is "orca" (deprecated)
 
     width: int or None
         The width of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the width of the exported image
         in physical pixels.
 
-        If not specified, will default to`plotly.io.kaleido.scope.default_width`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_width` if engine is "kaleido"
+            - `plotly.io.orca.config.default_width` if engine is "orca" (deprecated)
 
     height: int or None
         The height of the exported image in layout pixels. If the `scale`
         property is 1.0, this will also be the height of the exported image
         in physical pixels.
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_height`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_height` if engine is "kaleido"
+            - `plotly.io.orca.config.default_height` if engine is "orca" (deprecated)
 
     scale: int or float or None
         The scale factor to use when exporting the figure. A scale factor
@@ -275,7 +359,9 @@ def write_image(
         to the figure's layout pixel dimensions. Whereas as scale factor of
         less than 1.0 will decrease the image resolution.
 
-        If not specified, will default to `plotly.io.kaleido.scope.default_scale`
+        If not specified, will default to:
+            - `plotly.io.defaults.default_scale` if engine is "kaleido"
+            - `plotly.io.orca.config.default_scale` if engine is "orca" (deprecated)
 
     validate: bool
         True if the figure should be validated before being converted to
diff --git a/plotly/io/kaleido.py b/plotly/io/kaleido.py
index b9ff6c7582e..c14b315047b 100644
--- a/plotly/io/kaleido.py
+++ b/plotly/io/kaleido.py
@@ -1 +1 @@
-from ._kaleido import write_image, to_image
+from ._kaleido import to_image, write_image, scope
diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index 66b815bab1e..a66c12a7c0b 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -251,3 +251,16 @@ def test_write_images_multiple(mock_write_image):
     ]
     mock_write_image.assert_has_calls(expected_calls, any_order=False)
     assert mock_write_image.call_count == 2
+
+
+def test_defaults():
+    """Test that image output defaults can be set using pio.defaults.*"""
+    try:
+        assert pio.defaults.default_format == "png"
+        pio.defaults.default_format = "svg"
+        assert pio.defaults.default_format == "svg"
+        result = pio.to_image(fig, format="svg", validate=False)
+        assert result.startswith(b"<svg")
+    finally:
+        pio.defaults.default_format = "png"
+        assert pio.defaults.default_format == "png"

From 95a05db86d79f0d8684b6082f292e54036a3dc63 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Thu, 27 Mar 2025 11:47:29 -0400
Subject: [PATCH 23/39] format

---
 plotly/io/_defaults.py |  6 ++----
 plotly/io/_utils.py    | 12 +++++++-----
 2 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/plotly/io/_defaults.py b/plotly/io/_defaults.py
index 5b0f3a054a3..84426e87d8d 100644
--- a/plotly/io/_defaults.py
+++ b/plotly/io/_defaults.py
@@ -5,14 +5,12 @@ class _Defaults(object):
     """
     Class to store default settings for image generation.
     """
+
     def __init__(self):
         self.default_format = "png"
         self.default_width = 700
         self.default_height = 500
         self.default_scale = 1
 
-defaults = _Defaults()
-
-
-
 
+defaults = _Defaults()
diff --git a/plotly/io/_utils.py b/plotly/io/_utils.py
index 50a584beb64..ea0caed4473 100644
--- a/plotly/io/_utils.py
+++ b/plotly/io/_utils.py
@@ -67,15 +67,15 @@ def as_individual_args(*args, **kwargs):
     # Check that all list arguments have the same length,
     # and find out what that length is
     # If there are no list arguments, length is 1
-    list_lengths = [len(v) for v in args + tuple(kwargs.values()) if isinstance(v, list)]
+    list_lengths = [
+        len(v) for v in args + tuple(kwargs.values()) if isinstance(v, list)
+    ]
     if list_lengths and len(set(list_lengths)) > 1:
         raise ValueError("All list arguments must have the same length.")
     list_length = list_lengths[0] if list_lengths else 1
 
     # Expand all arguments to lists of the same length
-    expanded_args = [
-        [v] * list_length if not isinstance(v, list) else v for v in args
-    ]
+    expanded_args = [[v] * list_length if not isinstance(v, list) else v for v in args]
     expanded_kwargs = {
         k: [v] * list_length if not isinstance(v, list) else v
         for k, v in kwargs.items()
@@ -87,7 +87,9 @@ def as_individual_args(*args, **kwargs):
 
     # Reshape into a list of dictionaries
     # Each dictionary represents the keyword arguments for a single function call
-    list_of_kwargs = [{k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)]
+    list_of_kwargs = [
+        {k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)
+    ]
 
     return list_of_args, list_of_kwargs
 

From 4d3dd563fdf6e52acf62a9f55dbd060ef0fab7e8 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Fri, 28 Mar 2025 11:29:56 -0400
Subject: [PATCH 24/39] install kaleido 1.0.0rc11 from PyPI

---
 test_requirements/requirements_optional.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test_requirements/requirements_optional.txt b/test_requirements/requirements_optional.txt
index ac18a3a86d5..d98b883afcc 100644
--- a/test_requirements/requirements_optional.txt
+++ b/test_requirements/requirements_optional.txt
@@ -18,7 +18,7 @@ matplotlib
 scikit-image
 psutil
 # kaleido>=1.0.0  # Uncomment and delete line below once Kaleido v1 is released
-git+https://github.com/plotly/Kaleido.git@v1.0.0rc10#subdirectory=src/py
+kaleido==1.0.0rc11
 orjson
 polars[timezone]
 pyarrow

From d871e74323998702e3d3316fcde795ee7da9dce3 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Fri, 28 Mar 2025 12:35:52 -0400
Subject: [PATCH 25/39] remove extra import

---
 plotly/io/_kaleido.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 8c8c3793094..4cc03feecf3 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -540,8 +540,6 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
         )
 
     if warn:
-        import warnings
-
         warnings.warn(
             "full_figure_for_development is not recommended or necessary for "
             "production use in most circumstances. \n"

From b3e8d365299b6ea1f008ea44dfcca5d6a5d70be5 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 11:22:36 -0400
Subject: [PATCH 26/39] add [kaleido] install extra to pyproject.toml to help
 with installing compatible kaleido version

---
 pyproject.toml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/pyproject.toml b/pyproject.toml
index e6932df651f..594ee9e2956 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ dependencies = [
 
 [project.optional-dependencies]
 express = ["numpy"]
+kaleido = ["kaleido==1.0.0rc11"]
 
 [project.scripts]
 plotly_get_chrome = "plotly.io._kaleido:get_chrome"

From ef5f520907ababc086fc22ab9fdfd157544cfa80 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 12:28:21 -0400
Subject: [PATCH 27/39] fix deprecation warnings

---
 plotly/basedatatypes.py |  98 ++++++++++++++++++-----
 plotly/io/_kaleido.py   | 171 +++++++++++++++++++++++-----------------
 plotly/io/kaleido.py    |  11 ++-
 3 files changed, 186 insertions(+), 94 deletions(-)

diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py
index a3044f6763a..805ecf97162 100644
--- a/plotly/basedatatypes.py
+++ b/plotly/basedatatypes.py
@@ -3718,23 +3718,29 @@ def to_image(self, *args, **kwargs):
               - 'webp'
               - 'svg'
               - 'pdf'
-              - 'eps' (Requires the poppler library to be installed)
+              - 'eps' (deprecated) (Requires the poppler library to be installed)
 
-            If not specified, will default to `plotly.io.config.default_format`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_format` if engine is "kaleido"
+                - `plotly.io.orca.config.default_format` if engine is "orca" (deprecated)
 
         width: int or None
             The width of the exported image in layout pixels. If the `scale`
             property is 1.0, this will also be the width of the exported image
             in physical pixels.
 
-            If not specified, will default to `plotly.io.config.default_width`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_width` if engine is "kaleido"
+                - `plotly.io.orca.config.default_width` if engine is "orca" (deprecated)
 
         height: int or None
             The height of the exported image in layout pixels. If the `scale`
             property is 1.0, this will also be the height of the exported image
             in physical pixels.
 
-            If not specified, will default to `plotly.io.config.default_height`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_height` if engine is "kaleido"
+                - `plotly.io.orca.config.default_height` if engine is "orca" (deprecated)
 
         scale: int or float or None
             The scale factor to use when exporting the figure. A scale factor
@@ -3742,17 +3748,20 @@ def to_image(self, *args, **kwargs):
             to the figure's layout pixel dimensions. Whereas as scale factor of
             less than 1.0 will decrease the image resolution.
 
-            If not specified, will default to `plotly.io.config.default_scale`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_scale` if engine is "kaliedo"
+                - `plotly.io.orca.config.default_scale` if engine is "orca" (deprecated)
 
         validate: bool
             True if the figure should be validated before being converted to
             an image, False otherwise.
 
-        engine: str
-            Image export engine to use:
-             - "kaleido": Use Kaleido for image export
-             - "orca": Use Orca for image export
-             - "auto" (default): Use Kaleido if installed, otherwise use orca
+        engine (deprecated): str
+            Image export engine to use. This parameter is deprecated and Orca engine support will be
+            dropped in the next major Plotly version. Until then, the following values are supported:
+            - "kaleido": Use Kaleido for image export
+            - "orca": Use Orca for image export
+            - "auto" (default): Use Kaleido if installed, otherwise use Orca
 
         Returns
         -------
@@ -3760,6 +3769,26 @@ def to_image(self, *args, **kwargs):
             The image data
         """
         import plotly.io as pio
+        from plotly.io.kaleido import (
+            kaleido_available,
+            kaleido_major,
+            KALEIDO_DEPRECATION_MSG,
+            ORCA_DEPRECATION_MSG,
+            ENGINE_PARAM_DEPRECATION_MSG,
+        )
+
+        if (
+            kwargs.get("engine", None) in {None, "auto", "kaleido"}
+            and kaleido_available()
+            and kaleido_major() < 1
+        ):
+            warnings.warn(KALEIDO_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+        if kwargs.get("engine", None) == "orca":
+            warnings.warn(ORCA_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+        if kwargs.get("engine", None):
+            warnings.warn(
+                ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2
+            )
 
         return pio.to_image(self, *args, **kwargs)
 
@@ -3781,25 +3810,31 @@ def write_image(self, *args, **kwargs):
               - 'webp'
               - 'svg'
               - 'pdf'
-              - 'eps' (Requires the poppler library to be installed)
+              - 'eps' (deprecated) (Requires the poppler library to be installed)
 
             If not specified and `file` is a string then this will default to the
             file extension. If not specified and `file` is not a string then this
-            will default to `plotly.io.config.default_format`
+            will default to:
+                - `plotly.io.defaults.default_format` if engine is "kaleido"
+                - `plotly.io.orca.config.default_format` if engine is "orca" (deprecated)
 
         width: int or None
             The width of the exported image in layout pixels. If the `scale`
             property is 1.0, this will also be the width of the exported image
             in physical pixels.
 
-            If not specified, will default to `plotly.io.config.default_width`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_width` if engine is "kaleido"
+                - `plotly.io.orca.config.default_width` if engine is "orca" (deprecated)
 
         height: int or None
             The height of the exported image in layout pixels. If the `scale`
             property is 1.0, this will also be the height of the exported image
             in physical pixels.
 
-            If not specified, will default to `plotly.io.config.default_height`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_height` if engine is "kaleido"
+                - `plotly.io.orca.config.default_height` if engine is "orca" (deprecated)
 
         scale: int or float or None
             The scale factor to use when exporting the figure. A scale factor
@@ -3807,23 +3842,46 @@ def write_image(self, *args, **kwargs):
             to the figure's layout pixel dimensions. Whereas as scale factor of
             less than 1.0 will decrease the image resolution.
 
-            If not specified, will default to `plotly.io.config.default_scale`
+            If not specified, will default to:
+                - `plotly.io.defaults.default_scale` if engine is "kaleido"
+                - `plotly.io.orca.config.default_scale` if engine is "orca" (deprecated)
 
         validate: bool
             True if the figure should be validated before being converted to
             an image, False otherwise.
 
-        engine: str
-            Image export engine to use:
-             - "kaleido": Use Kaleido for image export
-             - "orca": Use Orca for image export
-             - "auto" (default): Use Kaleido if installed, otherwise use orca
+        engine (deprecated): str
+            Image export engine to use. This parameter is deprecated and Orca engine support will be
+            dropped in the next major Plotly version. Until then, the following values are supported:
+            - "kaleido": Use Kaleido for image export
+            - "orca": Use Orca for image export
+            - "auto" (default): Use Kaleido if installed, otherwise use Orca
+
         Returns
         -------
         None
         """
         import plotly.io as pio
+        from plotly.io.kaleido import (
+            kaleido_available,
+            kaleido_major,
+            KALEIDO_DEPRECATION_MSG,
+            ORCA_DEPRECATION_MSG,
+            ENGINE_PARAM_DEPRECATION_MSG,
+        )
 
+        if (
+            kwargs.get("engine", None) in {None, "auto", "kaleido"}
+            and kaleido_available()
+            and kaleido_major() < 1
+        ):
+            warnings.warn(KALEIDO_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+        if kwargs.get("engine", None) == "orca":
+            warnings.warn(ORCA_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+        if kwargs.get("engine", None):
+            warnings.warn(
+                ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2
+            )
         return pio.write_image(self, *args, **kwargs)
 
     # Static helpers
diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 4cc03feecf3..113e6322455 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -11,36 +11,69 @@
 
 ENGINE_SUPPORT_TIMELINE = "September 2025"
 
-kaleido_scope_default_getwarning = (
-    lambda x: f"""
-Accessing plotly.io.kaleido.scope.{x} is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
-Please use plotly.io.defaults.{x} instead.
+
+# TODO: Remove --pre flag once Kaleido v1 full release is available
+KALEIDO_DEPRECATION_MSG = f"""
+Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`).
 """
-)
+ORCA_DEPRECATION_MSG = f"""
+Support for the orca engine is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Please install Kaleido (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`) to use the Kaleido engine.
+"""
+ENGINE_PARAM_DEPRECATION_MSG = f"""
+Support for the 'engine' argument is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Kaleido will be the only supported engine at that time.
+Please install Kaleido (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`) to use the Kaleido engine.
+"""
+
+_KALEIDO_AVAILABLE = None
+_KALEIDO_MAJOR = None
 
-kaleido_scope_default_setwarning = (
+kaleido_scope_default_warning_func = (
     lambda x: f"""
-Setting plotly.io.kaleido.scope.{x} is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. "
-Please set plotly.io.defaults.{x} instead.
+Use of plotly.io.kaleido.scope.{x} is deprecated and support will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Please use plotly.io.defaults.{x} instead.
 """
 )
-
-bad_attribute_error = (
+bad_attribute_error_msg_func = (
     lambda x: f"""
 Attribute plotly.io.defaults.{x} is not valid.
-Also, plotly.io.kaleido.scope.* is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. Please use plotly.io.defaults.* instead.
+Also, use of plotly.io.kaleido.scope.* is deprecated and support will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Please use plotly.io.defaults.* instead.
 """
 )
 
 
-try:
-    import kaleido
+def kaleido_available():
+    global _KALEIDO_AVAILABLE
+    global _KALEIDO_MAJOR
+    if _KALEIDO_AVAILABLE is not None:
+        return _KALEIDO_AVAILABLE
+    try:
+        import kaleido
 
-    kaleido_available = True
-    kaleido_major = Version(importlib_metadata.version("kaleido")).major
+        _KALEIDO_AVAILABLE = True
+    except ImportError as e:
+        _KALEIDO_AVAILABLE = False
+    return _KALEIDO_AVAILABLE
 
-    if kaleido_major < 1:
+
+def kaleido_major():
+    global _KALEIDO_MAJOR
+    if _KALEIDO_MAJOR is not None:
+        return _KALEIDO_MAJOR
+    if not kaleido_available():
+        raise ValueError("Kaleido is not installed.")
+    else:
+        _KALEIDO_MAJOR = Version(importlib_metadata.version("kaleido")).major
+    return _KALEIDO_MAJOR
+
+
+try:
+    if kaleido_available() and kaleido_major() < 1:
         # Kaleido v0
+        import kaleido
         from kaleido.scopes.plotly import PlotlyScope
 
         # Show a deprecation warning if the old method of setting defaults is used
@@ -48,7 +81,7 @@ class PlotlyScopeWithDeprecationWarnings(PlotlyScope):
             def __setattr__(self, name, value):
                 if name in defaults.__dict__:
                     warnings.warn(
-                        kaleido_scope_default_setwarning(name),
+                        kaleido_scope_default_warning_func(name),
                         DeprecationWarning,
                         stacklevel=2,
                     )
@@ -58,7 +91,7 @@ def __setattr__(self, name, value):
             def __getattr__(self, name):
                 if name in defaults.__dict__:
                     warnings.warn(
-                        kaleido_scope_default_getwarning(name),
+                        kaleido_scope_default_warning_func(name),
                         DeprecationWarning,
                         stacklevel=2,
                     )
@@ -75,36 +108,35 @@ def __getattr__(self, name):
             )
     else:
         # Kaleido v1
+        import kaleido
 
         # Show a deprecation warning if the old method of setting defaults is used
         class DefaultsDeprecationWarning:
             def __getattr__(self, name):
                 if name in defaults.__dict__:
                     warnings.warn(
-                        kaleido_scope_default_getwarning(name),
+                        kaleido_scope_default_warning_func(name),
                         DeprecationWarning,
                         stacklevel=2,
                     )
                     return getattr(defaults, name)
                 else:
-                    raise AttributeError(bad_attribute_error(name))
+                    raise AttributeError(bad_attribute_error_msg_func(name))
 
             def __setattr__(self, name, value):
                 if name in defaults.__dict__:
                     warnings.warn(
-                        kaleido_scope_default_setwarning(name),
+                        kaleido_scope_default_warning_func(name),
                         DeprecationWarning,
                         stacklevel=2,
                     )
                     setattr(defaults, name, value)
                 else:
-                    raise AttributeError(bad_attribute_error(name))
+                    raise AttributeError(bad_attribute_error_msg_func(name))
 
         scope = DefaultsDeprecationWarning()
 
 except ImportError as e:
-    kaleido_available = False
-    kaleido_major = -1
     PlotlyScope = None
     scope = None
 
@@ -117,7 +149,6 @@ def to_image(
     scale=None,
     validate=True,
     engine=None,
-    kaleido_instance=None,
 ):
     """
     Convert a figure to a static image bytes string
@@ -134,7 +165,7 @@ def to_image(
             - 'webp'
             - 'svg'
             - 'pdf'
-            - 'eps' (Requires the poppler library to be installed and on the PATH)
+            - 'eps' (deprecated) (Requires the poppler library to be installed and on the PATH)
 
         If not specified, will default to:
             - `plotly.io.defaults.default_format` if engine is "kaleido"
@@ -173,10 +204,11 @@ def to_image(
         an image, False otherwise.
 
     engine (deprecated): str
-        No longer used. Kaleido is the only supported engine.
-
-    kaleido_instance: kaleido.Kaleido or None
-        An instance of the Kaleido class. If None, a new instance will be created.
+        Image export engine to use. This parameter is deprecated and Orca engine support will be
+        dropped in the next major Plotly version. Until then, the following values are supported:
+          - "kaleido": Use Kaleido for image export
+          - "orca": Use Orca for image export
+          - "auto" (default): Use Kaleido if installed, otherwise use Orca
 
     Returns
     -------
@@ -187,15 +219,12 @@ def to_image(
     # Handle engine
     # -------------
     if engine is not None:
-        warnings.warn(
-            f"The 'engine' argument is deprecated. Kaleido will be the only supported engine after {ENGINE_SUPPORT_TIMELINE}.",
-            DeprecationWarning,
-        )
+        warnings.warn(ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
     else:
         engine = "auto"
 
     if engine == "auto":
-        if kaleido_available:
+        if kaleido_available():
             # Default to kaleido if available
             engine = "kaleido"
         else:
@@ -211,11 +240,7 @@ def to_image(
                 engine = "kaleido"
 
     if engine == "orca":
-        warnings.warn(
-            f"Support for the orca engine is deprecated and  will be removed after {ENGINE_SUPPORT_TIMELINE}. "
-            + "Please install Kaleido (`pip install kaleido`) to use the Kaleido engine.",
-            DeprecationWarning,
-        )
+        warnings.warn(ORCA_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
         # Fall back to legacy orca image export path
         from ._orca import to_image as to_image_orca
 
@@ -228,14 +253,10 @@ def to_image(
             validate=validate,
         )
     elif engine != "kaleido":
-        raise ValueError(
-            "Invalid image export engine specified: {engine}".format(
-                engine=repr(engine)
-            )
-        )
+        raise ValueError(f"Invalid image export engine specified: {repr(engine)}")
 
     # Raise informative error message if Kaleido is not installed
-    if not kaleido_available:
+    if not kaleido_available():
         raise ValueError(
             """
 Image export using the "kaleido" engine requires the kaleido package,
@@ -248,7 +269,7 @@ def to_image(
     fig_dict = validate_coerce_fig_to_dict(fig, validate)
 
     # Request image bytes
-    if kaleido_major > 0:
+    if kaleido_major() > 0:
         # Kaleido v1
         # Check if trying to export to EPS format, which is not supported in Kaleido v1
         if format == "eps":
@@ -263,7 +284,7 @@ def to_image(
         import choreographer
 
         try:
-            # TODO: Actually use provided kaleido_instance here
+            # TODO: Refactor to make it possible to use a shared Kaleido instance here
             img_bytes = kaleido.calc_fig_sync(
                 fig_dict,
                 opts=dict(
@@ -284,11 +305,7 @@ def to_image(
 
     else:
         # Kaleido v0
-        warnings.warn(
-            f"Support for Kaleido versions less than 1.0.0 is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}. "
-            + "Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade kaleido`).",
-            DeprecationWarning,
-        )
+        warnings.warn(KALEIDO_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
         img_bytes = scope.transform(
             fig_dict, format=format, width=width, height=height, scale=scale
         )
@@ -305,7 +322,6 @@ def write_image(
     height=None,
     validate=True,
     engine="auto",
-    kaleido_instance=None,
 ):
     """
     Convert a figure to a static image and write it to a file or writeable
@@ -327,7 +343,7 @@ def write_image(
           - 'webp'
           - 'svg'
           - 'pdf'
-          - 'eps' (Requires the poppler library to be installed and on the PATH)
+          - 'eps' (deprecated) (Requires the poppler library to be installed and on the PATH)
 
         If not specified and `file` is a string then this will default to the
         file extension. If not specified and `file` is not a string then this
@@ -368,15 +384,29 @@ def write_image(
         an image, False otherwise.
 
     engine (deprecated): str
-        No longer used. Kaleido is the only supported engine.
-
-    kaleido_instance: kaleido.Kaleido or None
-        An instance of the Kaleido class. If None, a new instance will be created.
+        Image export engine to use. This parameter is deprecated and Orca engine support will be
+        dropped in the next major Plotly version. Until then, the following values are supported:
+          - "kaleido": Use Kaleido for image export
+          - "orca": Use Orca for image export
+          - "auto" (default): Use Kaleido if installed, otherwise use Orca
 
     Returns
     -------
     None
     """
+    # Show Kaleido deprecation warning if needed
+    # ------------------------------------------
+    if (
+        engine in {None, "auto", "kaleido"}
+        and kaleido_available()
+        and kaleido_major() < 1
+    ):
+        warnings.warn(KALEIDO_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+    if engine == "orca":
+        warnings.warn(ORCA_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+    if engine not in {None, "auto"}:
+        warnings.warn(ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
+
     # Try to cast `file` as a pathlib object `path`.
     # ----------------------------------------------
     if isinstance(file, str):
@@ -398,16 +428,14 @@ def write_image(
             format = ext.lstrip(".")
         else:
             raise ValueError(
-                """
+                f"""
 Cannot infer image type from output path '{file}'.
 Please add a file extension or specify the type using the format parameter.
 For example:
 
     >>> import plotly.io as pio
     >>> pio.write_image(fig, file_path, format='png')
-""".format(
-                    file=file
-                )
+"""
             )
 
     # Request image
@@ -421,7 +449,6 @@ def write_image(
         height=height,
         validate=validate,
         engine=engine,
-        kaleido_instance=kaleido_instance,
     )
 
     # Open file
@@ -435,11 +462,9 @@ def write_image(
         except AttributeError:
             pass
         raise ValueError(
-            """
+            f"""
 The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
-""".format(
-                file=file
-            )
+"""
         )
     else:
         # We previously succeeded in interpreting `file` as a pathlib object.
@@ -464,7 +489,7 @@ def to_images(*args, **kwargs):
     """
     individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
 
-    if kaleido_available and kaleido_major > 0:
+    if kaleido_available() and kaleido_major() > 0:
         # Kaleido v1
         # TODO: Use a single shared kaleido instance for all images
         return [to_image(*a, **kw) for a, kw in zip(individual_args, individual_kwargs)]
@@ -492,7 +517,7 @@ def write_images(*args, **kwargs):
     # Get individual arguments
     individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
 
-    if kaleido_available and kaleido_major > 0:
+    if kaleido_available() and kaleido_major() > 0:
         # Kaleido v1
         # TODO: Use a single shared kaleido instance for all images
         for a, kw in zip(individual_args, individual_kwargs):
@@ -530,7 +555,7 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
     """
 
     # Raise informative error message if Kaleido is not installed
-    if not kaleido_available:
+    if not kaleido_available():
         raise ValueError(
             """
 Full figure generation requires the kaleido package,
@@ -546,7 +571,7 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
             "To suppress this warning, set warn=False"
         )
 
-    if kaleido_major > 0:
+    if kaleido_available() and kaleido_major() > 0:
         # Kaleido v1
         bytes = kaleido.calc_fig_sync(
             fig,
@@ -576,7 +601,7 @@ def get_chrome():
     This function can be run from the command line using the command `plotly_get_chrome`
     defined in pyproject.toml
     """
-    if not kaleido_available or kaleido_major < 1:
+    if not kaleido_available() or kaleido_major() < 1:
         raise ValueError(
             "This command requires Kaleido v1.0.0 or greater. Install it using `pip install kaleido`."
         )
diff --git a/plotly/io/kaleido.py b/plotly/io/kaleido.py
index c14b315047b..e4df9f53c71 100644
--- a/plotly/io/kaleido.py
+++ b/plotly/io/kaleido.py
@@ -1 +1,10 @@
-from ._kaleido import to_image, write_image, scope
+from ._kaleido import (
+    to_image,
+    write_image,
+    scope,
+    kaleido_available,
+    kaleido_major,
+    KALEIDO_DEPRECATION_MSG,
+    ORCA_DEPRECATION_MSG,
+    ENGINE_PARAM_DEPRECATION_MSG,
+)

From c92a1eea384fe5f87d9c1ec7f682b9440ec614f7 Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 13:38:17 -0400
Subject: [PATCH 28/39] small updates to deprecation warning text

---
 plotly/io/_kaleido.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 113e6322455..9f7ba604b85 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -18,13 +18,12 @@
 Please upgrade Kaleido to version 1.0.0 or greater (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`).
 """
 ORCA_DEPRECATION_MSG = f"""
-Support for the orca engine is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
+Support for the Orca engine is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
 Please install Kaleido (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`) to use the Kaleido engine.
 """
 ENGINE_PARAM_DEPRECATION_MSG = f"""
 Support for the 'engine' argument is deprecated and will be removed after {ENGINE_SUPPORT_TIMELINE}.
 Kaleido will be the only supported engine at that time.
-Please install Kaleido (`pip install --upgrade --pre kaleido` or `pip install plotly[kaleido]`) to use the Kaleido engine.
 """
 
 _KALEIDO_AVAILABLE = None

From c01cb8a5adcafbf1c627c21c648757cab4d8a9bc Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 14:28:14 -0400
Subject: [PATCH 29/39] add missing changelog entries

---
 CHANGELOG.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89da1e55f17..bdf3b897a91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ## Unreleased
 
 ### Fixed
-- Fix third-party widget display issues in v6 [[#5102]https://github.com/plotly/plotly.py/pull/5102]
+- Fix third-party widget display issues in v6 [[#5102](https://github.com/plotly/plotly.py/pull/5102)]\
+- Add handling for case where `jupyterlab` or `notebook` is not installed [[#5104](https://github.com/plotly/plotly.py/pull/5104/files)]
+- Fix issue causing Plotly.js script to be embedded multiple times in Jupyter notebooks [[#5112](https://github.com/plotly/plotly.py/pull/5112)]
 
 ## [6.0.1] - 2025-03-14
 

From bcd40f303391ac3ba928509fd50521a946c2a8ad Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 14:29:52 -0400
Subject: [PATCH 30/39] update changelog for 6.1.0b0

---
 CHANGELOG.md | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdf3b897a91..8b00373c3a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,13 @@
 All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](http://semver.org/).
 
-## Unreleased
+## [6.1.0b0] - 2025-03-31
+
+### Updated
+- Add support for Kaleido >= v1.0.0 for image generation, and deprecate support for Kaleido<1 and Orca [[#5062](https://github.com/plotly/plotly.py/pull/5062)]
 
 ### Fixed
-- Fix third-party widget display issues in v6 [[#5102](https://github.com/plotly/plotly.py/pull/5102)]\
+- Fix third-party widget display issues in v6 [[#5102](https://github.com/plotly/plotly.py/pull/5102)]
 - Add handling for case where `jupyterlab` or `notebook` is not installed [[#5104](https://github.com/plotly/plotly.py/pull/5104/files)]
 - Fix issue causing Plotly.js script to be embedded multiple times in Jupyter notebooks [[#5112](https://github.com/plotly/plotly.py/pull/5112)]
 

From 54985b81f0134093a2ff564539d7f67d7c5d030e Mon Sep 17 00:00:00 2001
From: Emily Kellison-Linn <4672118+emilykl@users.noreply.github.com>
Date: Mon, 31 Mar 2025 14:30:20 -0400
Subject: [PATCH 31/39] update pyproject.toml for 6.1.0b0

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 594ee9e2956..a87691ea481 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,7 @@ classifiers = [
 ]
 requires-python = ">=3.8"
 license = {file="LICENSE.txt"}
-version = "6.0.1"
+version = "6.1.0b0"
 dependencies = [
     "narwhals>=1.15.1",
     "packaging"

From 0f61cc3d0eb6171d54929f3e7f76f3492bbaea12 Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Sun, 13 Apr 2025 18:33:03 -0400
Subject: [PATCH 32/39] remove to_images tests

---
 .../test_kaleido/test_kaleido.py              | 64 -------------------
 1 file changed, 64 deletions(-)

diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index a66c12a7c0b..a53a48cf577 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -125,70 +125,6 @@ def test_bytesio():
     assert bio_bytes == to_image_bytes
 
 
-@mock.patch("plotly.io._kaleido.to_image")
-def test_to_images_single(mock_to_image):
-    """Test to_images with a single figure"""
-    pio.to_images(
-        fig,
-        format="png",
-        width=800,
-        height=600,
-        scale=2,
-        validate=True,
-    )
-
-    # Verify that to_image was called once with the correct arguments
-    expected_calls = [
-        mock.call(
-            fig,
-            format="png",
-            width=800,
-            height=600,
-            scale=2,
-            validate=True,
-        )
-    ]
-    mock_to_image.assert_has_calls(expected_calls, any_order=False)
-    assert mock_to_image.call_count == 1
-
-
-@mock.patch("plotly.io._kaleido.to_image")
-def test_to_images_multiple(mock_to_image):
-    """Test to_images with lists"""
-    fig1 = {"data": [], "layout": {"title": {"text": "figure 1"}}}
-    fig2 = {"data": [], "layout": {"title": {"text": "figure 2"}}}
-    pio.to_images(
-        [fig1, fig2],
-        "png",
-        width=[800, 400],
-        height=600,
-        scale=[1, 2],
-        validate=True,
-    )
-
-    # Verify that to_image was called with the correct arguments in the correct order
-    expected_calls = [
-        mock.call(
-            fig1,
-            "png",
-            width=800,
-            height=600,
-            scale=1,
-            validate=True,
-        ),
-        mock.call(
-            fig2,
-            "png",
-            width=400,
-            height=600,
-            scale=2,
-            validate=True,
-        ),
-    ]
-    mock_to_image.assert_has_calls(expected_calls, any_order=False)
-    assert mock_to_image.call_count == 2
-
-
 @mock.patch("plotly.io._kaleido.write_image")
 def test_write_images_single(mock_write_image):
     """Test write_images with only single arguments"""

From fe12aec3bfcd3407d91e78806315be43c835ca1e Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Sun, 13 Apr 2025 18:35:11 -0400
Subject: [PATCH 33/39] remove to_images function

---
 plotly/io/__init__.py |  3 ---
 plotly/io/_kaleido.py | 26 --------------------------
 2 files changed, 29 deletions(-)

diff --git a/plotly/io/__init__.py b/plotly/io/__init__.py
index 539313ce7cc..c014437a47c 100644
--- a/plotly/io/__init__.py
+++ b/plotly/io/__init__.py
@@ -6,7 +6,6 @@
     from ._kaleido import (
         to_image,
         write_image,
-        to_images,
         write_images,
         full_figure_for_development,
     )
@@ -22,7 +21,6 @@
     __all__ = [
         "to_image",
         "write_image",
-        "to_images",
         "write_images",
         "orca",
         "json",
@@ -47,7 +45,6 @@
         [
             "._kaleido.to_image",
             "._kaleido.write_image",
-            "._kaleido.to_images",
             "._kaleido.write_images",
             "._kaleido.full_figure_for_development",
             "._json.to_json",
diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 9f7ba604b85..15107ea75f1 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -471,32 +471,6 @@ def write_image(
         path.write_bytes(img_data)
 
 
-def to_images(*args, **kwargs):
-    """
-    Convert multiple figures to static images and return a list of image bytes
-
-    Parameters
-    ----------
-    Accepts the same parameters as pio.to_image(), but any parameter may be either
-    a single value or a list of values. If more than one parameter is a list,
-    all must be the same length.
-
-    Returns
-    -------
-    list of bytes
-        The image data
-    """
-    individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
-
-    if kaleido_available() and kaleido_major() > 0:
-        # Kaleido v1
-        # TODO: Use a single shared kaleido instance for all images
-        return [to_image(*a, **kw) for a, kw in zip(individual_args, individual_kwargs)]
-    else:
-        # Kaleido v0, or orca
-        return [to_image(*a, **kw) for a, kw in zip(individual_args, individual_kwargs)]
-
-
 def write_images(*args, **kwargs):
     """
     Write multiple images to files or writeable objects. This is much faster than

From 692842f9b4383589e87865f36fb290d31e2c39ce Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Mon, 14 Apr 2025 09:05:13 -0400
Subject: [PATCH 34/39] refactor write_images to use kaleido write_fig function
 (WIP)

---
 plotly/io/_kaleido.py | 192 +++++++++++++++++++++++++++++++-----------
 1 file changed, 144 insertions(+), 48 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 15107ea75f1..0ce548aeeab 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -140,6 +140,44 @@ def __setattr__(self, name, value):
     scope = None
 
 
+def as_path_object(file: str | Path) -> Path | None:
+    """
+    Cast the `file` argument, which may be either a string or a Path object,
+    to a Path object.
+    If `file` is neither a string nor a Path object, None will be returned.
+    """
+    if isinstance(file, str):
+        # Use the standard Path constructor to make a pathlib object.
+        path = Path(file)
+    elif isinstance(file, Path):
+        # `file` is already a Path object.
+        path = file
+    else:
+        # We could not make a Path object out of file. Either `file` is an open file
+        # descriptor with a `write()` method or it's an invalid object.
+        path = None
+    return path
+
+def infer_format(path: Path | None, format: str | None) -> str | None:
+    if path is not None and format is None:
+        ext = path.suffix
+        if ext:
+            format = ext.lstrip(".")
+        else:
+            raise ValueError(
+                f"""
+Cannot infer image type from output path '{path}'.
+Please specify the type using the format parameter, or add a file extension.
+For example:
+
+    >>> import plotly.io as pio
+    >>> pio.write_image(fig, file_path, format='png')
+"""
+            )
+    return format
+
+
+
 def to_image(
     fig,
     format=None,
@@ -216,7 +254,6 @@ def to_image(
     """
 
     # Handle engine
-    # -------------
     if engine is not None:
         warnings.warn(ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
     else:
@@ -394,7 +431,6 @@ def write_image(
     None
     """
     # Show Kaleido deprecation warning if needed
-    # ------------------------------------------
     if (
         engine in {None, "auto", "kaleido"}
         and kaleido_available()
@@ -407,38 +443,12 @@ def write_image(
         warnings.warn(ENGINE_PARAM_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
 
     # Try to cast `file` as a pathlib object `path`.
-    # ----------------------------------------------
-    if isinstance(file, str):
-        # Use the standard Path constructor to make a pathlib object.
-        path = Path(file)
-    elif isinstance(file, Path):
-        # `file` is already a Path object.
-        path = file
-    else:
-        # We could not make a Path object out of file. Either `file` is an open file
-        # descriptor with a `write()` method or it's an invalid object.
-        path = None
+    path = as_path_object(file)
 
-    # Infer format if not specified
-    # -----------------------------
-    if path is not None and format is None:
-        ext = path.suffix
-        if ext:
-            format = ext.lstrip(".")
-        else:
-            raise ValueError(
-                f"""
-Cannot infer image type from output path '{file}'.
-Please add a file extension or specify the type using the format parameter.
-For example:
-
-    >>> import plotly.io as pio
-    >>> pio.write_image(fig, file_path, format='png')
-"""
-            )
+    # Infer image format if not specified
+    format = infer_format(path, format)
 
     # Request image
-    # -------------
     # Do this first so we don't create a file if image conversion fails
     img_data = to_image(
         fig,
@@ -451,7 +461,6 @@ def write_image(
     )
 
     # Open file
-    # ---------
     if path is None:
         # We previously failed to make sense of `file` as a pathlib object.
         # Attempt to write to `file` as an open file descriptor.
@@ -471,34 +480,121 @@ def write_image(
         path.write_bytes(img_data)
 
 
-def write_images(*args, **kwargs):
+def write_images(
+    figs,
+    file,
+    format=None,
+    scale=None,
+    width=None,
+    height=None,
+    validate=True,
+):
     """
     Write multiple images to files or writeable objects. This is much faster than
-    calling write_image() multiple times.
+    calling write_image() multiple times. This function can only be used with the Kaleido
+    engine, v1.0.0 or greater.
 
     Parameters
     ----------
-    Accepts the same parameters as pio.write_image(), but any parameter may be either
-    a single value or a list of values. If more than one parameter is a list,
-    all must be the same length.
+    figs:
+        Iterable of figure objects or dicts representing a figure
+
+    directory: str or writeable
+        A string or pathlib.Path object representing a local directory path.
+
+    format: str or None
+        The desired image format. One of
+          - 'png'
+          - 'jpg' or 'jpeg'
+          - 'webp'
+          - 'svg'
+          - 'pdf'
+
+        If not specified, this will default to `plotly.io.defaults.default_format`.
+
+    width: int or None
+        The width of the exported image in layout pixels. If the `scale`
+        property is 1.0, this will also be the width of the exported image
+        in physical pixels.
+
+        If not specified, will default to `plotly.io.defaults.default_width`.
+
+    height: int or None
+        The height of the exported image in layout pixels. If the `scale`
+        property is 1.0, this will also be the height of the exported image
+        in physical pixels.
+
+        If not specified, will default to `plotly.io.defaults.default_height`.
+
+    scale: int or float or None
+        The scale factor to use when exporting the figure. A scale factor
+        larger than 1.0 will increase the image resolution with respect
+        to the figure's layout pixel dimensions. Whereas as scale factor of
+        less than 1.0 will decrease the image resolution.
+
+        If not specified, will default to `plotly.io.defaults.default_scale`.
+
+    validate: bool
+        True if the figure should be validated before being converted to
+        an image, False otherwise.
 
     Returns
     -------
     None
     """
 
-    # Get individual arguments
-    individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
+    # Raise informative error message if Kaleido v1 is not installed
+    if not kaleido_available():
+        raise ValueError(
+            """
+The `write_images()` function requires the kaleido package,
+which can be installed using pip:
+    $ pip install -U kaleido
+"""
+        )
+    elif kaleido_major() < 1:
+        raise ValueError(
+            f"""
+You have Kaleido version {Version(importlib_metadata.version("kaleido"))} installed.
+The `write_images()` function requires the kaleido package version 1 or greater,
+which can be installed using pip:
+    $ pip install -U 'kaleido>=1.0.0'
+"""
+        )
 
-    if kaleido_available() and kaleido_major() > 0:
-        # Kaleido v1
-        # TODO: Use a single shared kaleido instance for all images
-        for a, kw in zip(individual_args, individual_kwargs):
-            write_image(*a, **kw)
-    else:
-        # Kaleido v0, or orca
-        for a, kw in zip(individual_args, individual_kwargs):
-            write_image(*a, **kw)
+    # Try to cast `file` as a pathlib object `path`.
+    path = as_path_object(file)
+
+    # Infer image format if not specified
+    format = infer_format(path, format)
+
+    # Convert figures to dicts (and validate if requested)
+    # TODO: Keep same iterable type
+    fig_dicts = [validate_coerce_fig_to_dict(fig, validate) for fig in figs]    
+
+    kaleido.write_fig_sync(
+        fig_dicts,
+        directory=path,
+        opts=dict(
+            format=format or defaults.default_format,
+            width=width or defaults.default_width,
+            height=height or defaults.default_height,
+            scale=scale or defaults.default_scale,
+        ),
+    )
+
+    # # Get individual arguments
+    # individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
+
+    # if kaleido_available() and kaleido_major() > 0:
+    #     # Kaleido v1
+    #     # TODO: Use a single shared kaleido instance for all images
+    #     for a, kw in zip(individual_args, individual_kwargs):
+    #         write_image(*a, **kw)
+    # else:
+    #     # Kaleido v0, or orca
+    #     for a, kw in zip(individual_args, individual_kwargs):
+    #         write_image(*a, **kw)
 
 
 def full_figure_for_development(fig, warn=True, as_dict=False):

From aac9c203d5172b9f88c8c2ef0d63a7dbccd8d3cf Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Mon, 14 Apr 2025 16:38:08 -0400
Subject: [PATCH 35/39] refactor write_images() to use
 kaleido.write_fig_from_object_sync()

---
 plotly/io/_kaleido.py | 101 ++++++++++++++++++++++++------------------
 plotly/io/_utils.py   |  28 +++++-------
 2 files changed, 67 insertions(+), 62 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 0ce548aeeab..7ec3adaf786 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -6,11 +6,17 @@
 import warnings
 
 import plotly
-from plotly.io._utils import validate_coerce_fig_to_dict, as_individual_args
+from plotly.io._utils import validate_coerce_fig_to_dict, broadcast_args_to_dicts
 from plotly.io import defaults
 
 ENGINE_SUPPORT_TIMELINE = "September 2025"
 
+PLOTLY_GET_CHROME_ERROR_MSG = """
+
+Kaleido requires Google Chrome to be installed. Install it by running:
+    $ plotly_get_chrome
+"""
+
 
 # TODO: Remove --pre flag once Kaleido v1 full release is available
 KALEIDO_DEPRECATION_MSG = f"""
@@ -158,6 +164,7 @@ def as_path_object(file: str | Path) -> Path | None:
         path = None
     return path
 
+
 def infer_format(path: Path | None, format: str | None) -> str | None:
     if path is not None and format is None:
         ext = path.suffix
@@ -177,7 +184,6 @@ def infer_format(path: Path | None, format: str | None) -> str | None:
     return format
 
 
-
 def to_image(
     fig,
     format=None,
@@ -331,13 +337,7 @@ def to_image(
                 ),
             )
         except choreographer.errors.ChromeNotFoundError:
-            raise RuntimeError(
-                """
-
-Kaleido requires Google Chrome to be installed. Install it by running:
-    $ plotly_get_chrome
-"""
-            )
+            raise RuntimeError(PLOTLY_GET_CHROME_ERROR_MSG)
 
     else:
         # Kaleido v0
@@ -481,7 +481,7 @@ def write_image(
 
 
 def write_images(
-    figs,
+    fig,
     file,
     format=None,
     scale=None,
@@ -494,13 +494,17 @@ def write_images(
     calling write_image() multiple times. This function can only be used with the Kaleido
     engine, v1.0.0 or greater.
 
+    This function accepts the same arguments as write_image() (minus the `engine` argument),
+    except that any of the arguments may be either a single value or an iterable of values.
+    If multiple arguments are iterable, they must all have the same length.
+
     Parameters
     ----------
-    figs:
+    fig:
         Iterable of figure objects or dicts representing a figure
 
-    directory: str or writeable
-        A string or pathlib.Path object representing a local directory path.
+    file: str or writeable
+        Iterables of strings or pathlib.Path objects representing local file paths to write to.
 
     format: str or None
         The desired image format. One of
@@ -562,39 +566,48 @@ def write_images(
 """
         )
 
-    # Try to cast `file` as a pathlib object `path`.
-    path = as_path_object(file)
-
-    # Infer image format if not specified
-    format = infer_format(path, format)
-
-    # Convert figures to dicts (and validate if requested)
-    # TODO: Keep same iterable type
-    fig_dicts = [validate_coerce_fig_to_dict(fig, validate) for fig in figs]    
-
-    kaleido.write_fig_sync(
-        fig_dicts,
-        directory=path,
-        opts=dict(
-            format=format or defaults.default_format,
-            width=width or defaults.default_width,
-            height=height or defaults.default_height,
-            scale=scale or defaults.default_scale,
-        ),
+    # Broadcast arguments into correct format for passing to Kaleido
+    arg_dicts = broadcast_args_to_dicts(
+        fig=fig,
+        file=file,
+        format=format,
+        scale=scale,
+        width=width,
+        height=height,
+        validate=validate,
     )
 
-    # # Get individual arguments
-    # individual_args, individual_kwargs = as_individual_args(*args, **kwargs)
-
-    # if kaleido_available() and kaleido_major() > 0:
-    #     # Kaleido v1
-    #     # TODO: Use a single shared kaleido instance for all images
-    #     for a, kw in zip(individual_args, individual_kwargs):
-    #         write_image(*a, **kw)
-    # else:
-    #     # Kaleido v0, or orca
-    #     for a, kw in zip(individual_args, individual_kwargs):
-    #         write_image(*a, **kw)
+    # For each dict:
+    #   - convert figures to dicts (and validate if requested)
+    #   - try to cast `file` as a Path object
+    for d in arg_dicts:
+        d["fig"] = validate_coerce_fig_to_dict(d["fig"], d["validate"])
+        d["file"] = as_path_object(d["file"])
+
+    # Reshape arg_dicts into correct format for passing to Kaleido
+    # We call infer_format() here rather than above so that the `file` argument
+    # has already been cast to a Path object.
+    # Also insert defaults for any missing arguments as needed
+    kaleido_specs = [
+        {
+            "fig": d["fig"],
+            "path": d["file"],
+            "opts": dict(
+                format=infer_format(d["file"], d["format"]) or defaults.default_format,
+                width=d["width"] or defaults.default_width,
+                height=d["height"] or defaults.default_height,
+                scale=d["scale"] or defaults.default_scale,
+            ),
+        }
+        for d in arg_dicts
+    ]
+
+    import choreographer
+
+    try:
+        kaleido.write_fig_from_object_sync(kaleido_specs)
+    except choreographer.errors.ChromeNotFoundError:
+        raise RuntimeError(PLOTLY_GET_CHROME_ERROR_MSG)
 
 
 def full_figure_for_development(fig, warn=True, as_dict=False):
diff --git a/plotly/io/_utils.py b/plotly/io/_utils.py
index ea0caed4473..5591558f8da 100644
--- a/plotly/io/_utils.py
+++ b/plotly/io/_utils.py
@@ -43,55 +43,47 @@ def validate_coerce_output_type(output_type):
     return cls
 
 
-def as_individual_args(*args, **kwargs):
+def broadcast_args_to_dicts(**kwargs):
     """
-    Given one or more positional or keyword arguments which may be either a single value
-    or a list of values, return a list of lists and a list of dictionaries
-    by expanding the single values into lists.
+    Given one or more keyword arguments which may be either a single value or a list of values,
+    return a list of keyword dictionaries by broadcasting the single valuesacross all the dicts.
     If more than one item in the input is a list, all lists must be the same length.
 
     Parameters
     ----------
-    *args: list
-        The positional arguments
     **kwargs: dict
         The keyword arguments
 
     Returns
     -------
-    list of lists
-        A list of lists
     list of dicts
         A list of dictionaries
+
+    Raises
+    ------
+    ValueError
+        If any of the input lists are not the same length
     """
     # Check that all list arguments have the same length,
     # and find out what that length is
     # If there are no list arguments, length is 1
-    list_lengths = [
-        len(v) for v in args + tuple(kwargs.values()) if isinstance(v, list)
-    ]
+    list_lengths = [len(v) for v in tuple(kwargs.values()) if isinstance(v, list)]
     if list_lengths and len(set(list_lengths)) > 1:
         raise ValueError("All list arguments must have the same length.")
     list_length = list_lengths[0] if list_lengths else 1
 
     # Expand all arguments to lists of the same length
-    expanded_args = [[v] * list_length if not isinstance(v, list) else v for v in args]
     expanded_kwargs = {
         k: [v] * list_length if not isinstance(v, list) else v
         for k, v in kwargs.items()
     }
-
-    # Reshape into a list of lists
-    # Each list represents the positional arguments for a single function call
-    list_of_args = [[v[i] for v in expanded_args] for i in range(list_length)]
-
     # Reshape into a list of dictionaries
     # Each dictionary represents the keyword arguments for a single function call
     list_of_kwargs = [
         {k: v[i] for k, v in expanded_kwargs.items()} for i in range(list_length)
     ]
 
-    return list_of_args, list_of_kwargs
+    return list_of_kwargs
 
 
 def plotly_cdn_url(cdn_ver=get_plotlyjs_version()):

From ad5703104928a2f4de6c7a7da04f4f01406591f9 Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Mon, 14 Apr 2025 16:39:13 -0400
Subject: [PATCH 36/39] update kaleido tests

---
 .../test_kaleido/test_kaleido.py              | 92 ++++++-------------
 1 file changed, 26 insertions(+), 66 deletions(-)

diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py
index a53a48cf577..9a9d414dc92 100644
--- a/tests/test_optional/test_kaleido/test_kaleido.py
+++ b/tests/test_optional/test_kaleido/test_kaleido.py
@@ -3,12 +3,12 @@
 import tempfile
 from contextlib import redirect_stdout
 import base64
-import unittest
-from unittest import mock
 
 from pdfrw import PdfReader
 from PIL import Image
 import plotly.io as pio
+from plotly.io.kaleido import kaleido_available, kaleido_major
+import pytest
 
 fig = {"data": [], "layout": {"title": {"text": "figure title"}}}
 
@@ -91,6 +91,30 @@ def test_kaleido_engine_write_image_kwargs(tmp_path):
         check_image(out_path, size=(700 * 2, 600 * 2), format="JPEG")
 
 
+@pytest.mark.skipif(
+    not kaleido_available() or kaleido_major() < 1,
+    reason="requires Kaleido v1.0.0 or higher",
+)
+def test_kaleido_engine_write_images(tmp_path):
+    fig1 = {"data": [], "layout": {"title": {"text": "figure 1"}}}
+    fig2 = {"data": [], "layout": {"title": {"text": "figure 2"}}}
+
+    path_str = tempfile.mkstemp(suffix=".png", dir=tmp_path)[1]
+    path_path = Path(tempfile.mkstemp(suffix=".png", dir=tmp_path)[1])
+
+    pio.write_images(
+        [fig1, fig2],
+        [path_str, path_path],
+        format=["jpg", "png"],
+        width=[700, 900],
+        height=600,
+        scale=2,
+        validate=False,
+    )
+    check_image(path_str, size=(700 * 2, 600 * 2), format="JPEG")
+    check_image(str(path_path), size=(900 * 2, 600 * 2), format="PNG")
+
+
 def test_image_renderer():
     """Verify that the image renderer returns the expected mimebundle."""
     with redirect_stdout(StringIO()) as f:
@@ -125,70 +149,6 @@ def test_bytesio():
     assert bio_bytes == to_image_bytes
 
 
-@mock.patch("plotly.io._kaleido.write_image")
-def test_write_images_single(mock_write_image):
-    """Test write_images with only single arguments"""
-    pio.write_images(
-        fig,
-        "output.png",
-        format="png",
-        width=800,
-        height=600,
-        scale=2,
-    )
-
-    # Verify that write_image was called once with the correct arguments
-    expected_calls = [
-        mock.call(
-            fig,
-            "output.png",
-            format="png",
-            width=800,
-            height=600,
-            scale=2,
-        )
-    ]
-    mock_write_image.assert_has_calls(expected_calls, any_order=False)
-    assert mock_write_image.call_count == 1
-
-
-@mock.patch("plotly.io._kaleido.write_image")
-def test_write_images_multiple(mock_write_image):
-    """Test write_images with list arguments"""
-    fig1 = {"data": [], "layout": {"title": {"text": "figure 1"}}}
-    fig2 = {"data": [], "layout": {"title": {"text": "figure 2"}}}
-    pio.write_images(
-        [fig1, fig2],
-        ["output1.png", "output2.jpg"],
-        format=["png", "jpeg"],
-        width=800,
-        height=[600, 400],
-        scale=2,
-    )
-
-    # Verify that write_image was called with the correct arguments in the correct order
-    expected_calls = [
-        mock.call(
-            fig1,
-            "output1.png",
-            format="png",
-            width=800,
-            height=600,
-            scale=2,
-        ),
-        mock.call(
-            fig2,
-            "output2.jpg",
-            format="jpeg",
-            width=800,
-            height=400,
-            scale=2,
-        ),
-    ]
-    mock_write_image.assert_has_calls(expected_calls, any_order=False)
-    assert mock_write_image.call_count == 2
-
-
 def test_defaults():
     """Test that image output defaults can be set using pio.defaults.*"""
     try:

From 720ada58fb71bf59fa85d5d7a7680f229abe0c29 Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Mon, 14 Apr 2025 17:11:13 -0400
Subject: [PATCH 37/39] add and fix type hints

---
 plotly/io/_kaleido.py | 72 ++++++++++++++++++++++++++-----------------
 plotly/io/_utils.py   |  4 ++-
 2 files changed, 46 insertions(+), 30 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index 7ec3adaf786..b96c33e4e29 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -1,6 +1,7 @@
 import os
 import json
 from pathlib import Path
+from typing import Union, List
 import importlib.metadata as importlib_metadata
 from packaging.version import Version
 import warnings
@@ -50,7 +51,10 @@
 )
 
 
-def kaleido_available():
+def kaleido_available() -> bool:
+    """
+    Returns True if any version of Kaleido is installed, otherwise False.
+    """
     global _KALEIDO_AVAILABLE
     global _KALEIDO_MAJOR
     if _KALEIDO_AVAILABLE is not None:
@@ -64,7 +68,11 @@ def kaleido_available():
     return _KALEIDO_AVAILABLE
 
 
-def kaleido_major():
+def kaleido_major() -> int:
+    """
+    Returns the major version number of Kaleido if it is installed, 
+    otherwise raises a ValueError.
+    """
     global _KALEIDO_MAJOR
     if _KALEIDO_MAJOR is not None:
         return _KALEIDO_MAJOR
@@ -146,7 +154,7 @@ def __setattr__(self, name, value):
     scope = None
 
 
-def as_path_object(file: str | Path) -> Path | None:
+def as_path_object(file: Union[str, Path]) -> Union[Path, None]:
     """
     Cast the `file` argument, which may be either a string or a Path object,
     to a Path object.
@@ -185,14 +193,15 @@ def infer_format(path: Path | None, format: str | None) -> str | None:
 
 
 def to_image(
-    fig,
-    format=None,
-    width=None,
-    height=None,
-    scale=None,
-    validate=True,
-    engine=None,
-):
+    fig: Union[dict, plotly.graph_objects.Figure],
+    format: Union[str, None]=None,
+    width: Union[int, None]=None,
+    height: Union[int, None]=None,
+    scale: Union[int, float, None]=None,
+    validate: bool=True,
+    # Deprecated
+    engine: Union[str, None]=None,
+) -> bytes:
     """
     Convert a figure to a static image bytes string
 
@@ -350,14 +359,15 @@ def to_image(
 
 
 def write_image(
-    fig,
-    file,
-    format=None,
-    scale=None,
-    width=None,
-    height=None,
-    validate=True,
-    engine="auto",
+    fig: Union[dict, plotly.graph_objects.Figure],
+    file: Union[str, Path],
+    format: Union[str, None]=None,
+    scale: Union[int, float, None]=None,
+    width: Union[int, None]=None,
+    height: Union[int, None]=None,
+    validate: bool=True,
+    # Deprecated
+    engine: Union[str, None]="auto",
 ):
     """
     Convert a figure to a static image and write it to a file or writeable
@@ -481,14 +491,14 @@ def write_image(
 
 
 def write_images(
-    fig,
-    file,
-    format=None,
-    scale=None,
-    width=None,
-    height=None,
-    validate=True,
-):
+    fig: Union[List[Union[dict, plotly.graph_objects.Figure]], Union[dict, plotly.graph_objects.Figure]],
+    file: Union[List[Union[str, Path]], Union[str, Path]],
+    format: Union[List[Union[str, None]], Union[str, None]] = None,
+    scale: Union[List[Union[int, float, None]], Union[int, float, None]] = None,
+    width: Union[List[Union[int, None]], Union[int, None]] = None,
+    height: Union[List[Union[int, None]], Union[int, None]] = None,
+    validate: Union[List[bool], bool] = True,
+) -> None:
     """
     Write multiple images to files or writeable objects. This is much faster than
     calling write_image() multiple times. This function can only be used with the Kaleido
@@ -610,7 +620,11 @@ def write_images(
         raise RuntimeError(PLOTLY_GET_CHROME_ERROR_MSG)
 
 
-def full_figure_for_development(fig, warn=True, as_dict=False):
+def full_figure_for_development(
+    fig: Union[dict, plotly.graph_objects.Figure],
+    warn: bool=True,
+    as_dict: bool=False,
+) -> Union[plotly.graph_objects.Figure, dict]:
     """
     Compute default values for all attributes not specified in the input figure and
     returns the output as a "full" figure. This function calls Plotly.js via Kaleido
@@ -677,7 +691,7 @@ def full_figure_for_development(fig, warn=True, as_dict=False):
         return go.Figure(fig, skip_invalid=True)
 
 
-def get_chrome():
+def get_chrome() -> None:
     """
     Install Google Chrome for Kaleido
     This function can be run from the command line using the command `plotly_get_chrome`
diff --git a/plotly/io/_utils.py b/plotly/io/_utils.py
index 5591558f8da..0dc84d8d07a 100644
--- a/plotly/io/_utils.py
+++ b/plotly/io/_utils.py
@@ -1,3 +1,5 @@
+from typing import List
+
 import plotly
 import plotly.graph_objs as go
 from plotly.offline import get_plotlyjs_version
@@ -43,7 +45,7 @@ def validate_coerce_output_type(output_type):
     return cls
 
 
-def broadcast_args_to_dicts(**kwargs):
+def broadcast_args_to_dicts(**kwargs: dict) -> List[dict]:
     """
     Given one or more keyword arguments which may be either a single value or a list of values,
     return a list of keyword dictionaries by broadcasting the single valuesacross all the dicts.

From 53d486a843eddf70f518ec3fde9c9e6c0043885d Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Tue, 15 Apr 2025 11:16:10 -0400
Subject: [PATCH 38/39] add mathjax and topojson config options

---
 plotly/io/_defaults.py | 2 ++
 plotly/io/_kaleido.py  | 6 +++++-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/plotly/io/_defaults.py b/plotly/io/_defaults.py
index 84426e87d8d..a2a98884c9c 100644
--- a/plotly/io/_defaults.py
+++ b/plotly/io/_defaults.py
@@ -11,6 +11,8 @@ def __init__(self):
         self.default_width = 700
         self.default_height = 500
         self.default_scale = 1
+        self.mathjax = None
+        self.topojson = None
 
 
 defaults = _Defaults()
diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index b96c33e4e29..dbb7fa565d5 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -173,7 +173,7 @@ def as_path_object(file: Union[str, Path]) -> Union[Path, None]:
     return path
 
 
-def infer_format(path: Path | None, format: str | None) -> str | None:
+def infer_format(path: Union[Path, None], format: Union[str, None]) -> Union[str, None]:
     if path is not None and format is None:
         ext = path.suffix
         if ext:
@@ -344,6 +344,8 @@ def to_image(
                     height=height or defaults.default_height,
                     scale=scale or defaults.default_scale,
                 ),
+                topojson=Path(defaults.topojson).as_uri() if defaults.topojson else None,
+                # mathjax=Path(defaults.mathjax).as_uri() if defaults.mathjax else None,
             )
         except choreographer.errors.ChromeNotFoundError:
             raise RuntimeError(PLOTLY_GET_CHROME_ERROR_MSG)
@@ -608,6 +610,8 @@ def write_images(
                 height=d["height"] or defaults.default_height,
                 scale=d["scale"] or defaults.default_scale,
             ),
+            "topojson": Path(defaults.topojson).as_uri() if defaults.topojson else None,
+            # "mathjax": Path(defaults.mathjax).as_uri() if defaults.mathjax else None,
         }
         for d in arg_dicts
     ]

From c99073627719ddc818fe01c2d223f451dfa02f53 Mon Sep 17 00:00:00 2001
From: Emily KL <4672118+emilykl@users.noreply.github.com>
Date: Tue, 15 Apr 2025 12:15:43 -0400
Subject: [PATCH 39/39] format

---
 plotly/io/_kaleido.py | 39 ++++++++++++++++++++++-----------------
 1 file changed, 22 insertions(+), 17 deletions(-)

diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py
index dbb7fa565d5..0bf193a8e74 100644
--- a/plotly/io/_kaleido.py
+++ b/plotly/io/_kaleido.py
@@ -70,7 +70,7 @@ def kaleido_available() -> bool:
 
 def kaleido_major() -> int:
     """
-    Returns the major version number of Kaleido if it is installed, 
+    Returns the major version number of Kaleido if it is installed,
     otherwise raises a ValueError.
     """
     global _KALEIDO_MAJOR
@@ -194,13 +194,13 @@ def infer_format(path: Union[Path, None], format: Union[str, None]) -> Union[str
 
 def to_image(
     fig: Union[dict, plotly.graph_objects.Figure],
-    format: Union[str, None]=None,
-    width: Union[int, None]=None,
-    height: Union[int, None]=None,
-    scale: Union[int, float, None]=None,
-    validate: bool=True,
+    format: Union[str, None] = None,
+    width: Union[int, None] = None,
+    height: Union[int, None] = None,
+    scale: Union[int, float, None] = None,
+    validate: bool = True,
     # Deprecated
-    engine: Union[str, None]=None,
+    engine: Union[str, None] = None,
 ) -> bytes:
     """
     Convert a figure to a static image bytes string
@@ -344,7 +344,9 @@ def to_image(
                     height=height or defaults.default_height,
                     scale=scale or defaults.default_scale,
                 ),
-                topojson=Path(defaults.topojson).as_uri() if defaults.topojson else None,
+                topojson=Path(defaults.topojson).as_uri()
+                if defaults.topojson
+                else None,
                 # mathjax=Path(defaults.mathjax).as_uri() if defaults.mathjax else None,
             )
         except choreographer.errors.ChromeNotFoundError:
@@ -363,13 +365,13 @@ def to_image(
 def write_image(
     fig: Union[dict, plotly.graph_objects.Figure],
     file: Union[str, Path],
-    format: Union[str, None]=None,
-    scale: Union[int, float, None]=None,
-    width: Union[int, None]=None,
-    height: Union[int, None]=None,
-    validate: bool=True,
+    format: Union[str, None] = None,
+    scale: Union[int, float, None] = None,
+    width: Union[int, None] = None,
+    height: Union[int, None] = None,
+    validate: bool = True,
     # Deprecated
-    engine: Union[str, None]="auto",
+    engine: Union[str, None] = "auto",
 ):
     """
     Convert a figure to a static image and write it to a file or writeable
@@ -493,7 +495,10 @@ def write_image(
 
 
 def write_images(
-    fig: Union[List[Union[dict, plotly.graph_objects.Figure]], Union[dict, plotly.graph_objects.Figure]],
+    fig: Union[
+        List[Union[dict, plotly.graph_objects.Figure]],
+        Union[dict, plotly.graph_objects.Figure],
+    ],
     file: Union[List[Union[str, Path]], Union[str, Path]],
     format: Union[List[Union[str, None]], Union[str, None]] = None,
     scale: Union[List[Union[int, float, None]], Union[int, float, None]] = None,
@@ -626,8 +631,8 @@ def write_images(
 
 def full_figure_for_development(
     fig: Union[dict, plotly.graph_objects.Figure],
-    warn: bool=True,
-    as_dict: bool=False,
+    warn: bool = True,
+    as_dict: bool = False,
 ) -> Union[plotly.graph_objects.Figure, dict]:
     """
     Compute default values for all attributes not specified in the input figure and