Skip to content

Support Kaleido v1 in Plotly.py #5062

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: main
Choose a base branch
from
Open
Changes from 6 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
f0a78a6
small io refactor to support kaleido v1
emilykl Mar 4, 2025
a681c84
update kaleido tests to support kaleido v1
emilykl Mar 4, 2025
a7a6f24
Merge branch 'main' into upgrade-kaleido
emilykl Mar 4, 2025
2e9c8af
update test requirements
emilykl Mar 4, 2025
60bb748
simplify to_image
emilykl Mar 4, 2025
b87f752
use k.calc_fig() instead of k.write_fig() in pio.full_figure_for_deve…
emilykl Mar 6, 2025
e203623
merge main
emilykl Mar 7, 2025
8054331
merge main
emilykl Mar 11, 2025
1a195a7
add ci job to test with kaleido v1
emilykl Mar 11, 2025
577d3ca
remove -y option
emilykl Mar 11, 2025
89209ad
run test_kaleido instead of test_io
emilykl Mar 11, 2025
96bf9a0
re-add orca, add deprecation warnings for orca and kaleido-v0 (exact …
emilykl Mar 13, 2025
350dd48
error message for 'eps' with kaleido v1
emilykl Mar 20, 2025
08c1d4e
pin kaleido v1 (prerelease) in test requirements
emilykl Mar 20, 2025
8b47a0a
format
emilykl Mar 21, 2025
249cc9f
deprecation warning wording
emilykl Mar 24, 2025
ad9dbd9
fix if/else for when engine not specified
emilykl Mar 24, 2025
e75a5df
add flow and endpoint for installing chrome via plotly
emilykl Mar 25, 2025
a2b4f3c
add write_images and to_images functions (not yet using shared Kaleid…
emilykl Mar 25, 2025
2549299
format
emilykl Mar 25, 2025
ab3b700
fix test_image_renderer() test to work with both kaleido v0 and v1
emilykl Mar 27, 2025
de473e1
support pos args in to_images and write_images; rename plotly_install…
emilykl Mar 27, 2025
71696fe
mising pos args in write_images()
emilykl Mar 27, 2025
100b955
add tests for write_images() and to_images()
emilykl Mar 27, 2025
5541a79
add new API for setting image generation defaults
emilykl Mar 27, 2025
95a05db
format
emilykl Mar 27, 2025
0a73d0e
Merge branch 'main' into upgrade-kaleido
emilykl Mar 27, 2025
4d3dd56
install kaleido 1.0.0rc11 from PyPI
emilykl Mar 28, 2025
d871e74
remove extra import
emilykl Mar 28, 2025
b3e8d36
add [kaleido] install extra to pyproject.toml to help with installing…
emilykl Mar 31, 2025
ef5f520
fix deprecation warnings
emilykl Mar 31, 2025
611e2e4
Merge branch 'main' into upgrade-kaleido
emilykl Mar 31, 2025
c92a1ee
small updates to deprecation warning text
emilykl Mar 31, 2025
b56d5ec
Merge branch 'upgrade-kaleido' of https://github.com/plotly/plotly.py…
emilykl Mar 31, 2025
c01cb8a
add missing changelog entries
emilykl Mar 31, 2025
bcd40f3
update changelog for 6.1.0b0
emilykl Mar 31, 2025
54985b8
update pyproject.toml for 6.1.0b0
emilykl Mar 31, 2025
b4af0d5
Merge pull request #5121 from plotly/release-6.1.0b0
emilykl Mar 31, 2025
0f61cc3
remove to_images tests
emilykl Apr 13, 2025
fe12aec
remove to_images function
emilykl Apr 13, 2025
692842f
refactor write_images to use kaleido write_fig function (WIP)
emilykl Apr 14, 2025
aac9c20
refactor write_images() to use kaleido.write_fig_from_object_sync()
emilykl Apr 14, 2025
ad57031
update kaleido tests
emilykl Apr 14, 2025
b9e5f7a
Merge branch 'main' into upgrade-kaleido
emilykl Apr 14, 2025
720ada5
add and fix type hints
emilykl Apr 14, 2025
53d486a
add mathjax and topojson config options
emilykl Apr 15, 2025
c990736
format
emilykl Apr 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 76 additions & 96 deletions plotly/io/_kaleido.py
Original file line number Diff line number Diff line change
@@ -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,100 +47,52 @@ 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we still handle EPS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gvwilson Following up on this — Kaleido v1 does not support EPS yet. So either we drop support for EPS entirely, or document that EPS is only available with Kaleido v0 and add an informative error message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please do the latter - thanks

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
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.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we print a deprecation warning if this argument is used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, assuming we are in agreement about removing this argument, I'll add one

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in agreement that we should remove this argument

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,27 @@ 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
img_bytes = kaleido.calc_fig_sync(
fig_dict,
path=None,
opts=dict(
format=format,
width=width,
height=height,
scale=scale,
),
)
else:
# Kaleido v0
img_bytes = scope.transform(
fig_dict, format=format, width=width, height=height, scale=scale
)

return img_bytes

@@ -190,38 +169,29 @@ 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
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.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 +293,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 +311,17 @@ 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
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"))

if as_dict:
return fig
else:
2 changes: 1 addition & 1 deletion plotly/io/kaleido.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from ._kaleido import to_image, write_image, scope
from ._kaleido import write_image, to_image
1 change: 1 addition & 0 deletions test_requirements/requirements_310_optional.txt
Original file line number Diff line number Diff line change
@@ -22,3 +22,4 @@ polars[timezone]
pyarrow
narwhals>=1.15.1
anywidget==0.9.13
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_311_optional.txt
Original file line number Diff line number Diff line change
@@ -23,3 +23,4 @@ polars[timezone]
pyarrow
kaleido
plotly-geo
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_312_no_numpy_optional.txt
Original file line number Diff line number Diff line change
@@ -22,3 +22,4 @@ pyarrow
narwhals>=1.15.1
anywidget==0.9.13
jupyter-console==6.4.4
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_312_np2_optional.txt
Original file line number Diff line number Diff line change
@@ -23,3 +23,4 @@ polars[timezone]
pyarrow
narwhals>=1.15.1
anywidget==0.9.13
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_312_optional.txt
Original file line number Diff line number Diff line change
@@ -24,3 +24,4 @@ narwhals>=1.15.1
anywidget==0.9.13
jupyter-console==6.4.4
plotly-geo
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_38_optional.txt
Original file line number Diff line number Diff line change
@@ -22,3 +22,4 @@ polars[timezone]
pyarrow
narwhals>=1.15.1
anywidget==0.9.13
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_39_optional.txt
Original file line number Diff line number Diff line change
@@ -23,3 +23,4 @@ polars[timezone]
pyarrow
narwhals>=1.15.1
anywidget==0.9.13
pdfrw
1 change: 1 addition & 0 deletions test_requirements/requirements_39_pandas_2_optional.txt
Original file line number Diff line number Diff line change
@@ -24,3 +24,4 @@ pyarrow
narwhals>=1.15.1
polars
anywidget==0.9.13
pdfrw
175 changes: 63 additions & 112 deletions tests/test_optional/test_kaleido/test_kaleido.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there percy tests that use kaleido to make sure that the actual images generated are correct?

Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of the TODO is for me to educate myself on how Plotly currently determines PDF size when writing to PDF. :)

But the size argument is measured in pixels (at least for raster file types) and PDFs have no concept of pixels so I wouldn't expect the PDF to report the same "size" as passed in the argument necessarily.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will also take a looksy on that issue, lets say for this and the above tomorrow. I am flying today.

# 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():