diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b36cc48022..ac8cce9b8fe 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_v0: + 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 v0 instead of the v1 specified in requirements_optional.txt + uv pip uninstall kaleido + uv pip install kaleido==0.2.1 + - run: + name: List installed packages and python version + command: | + source .venv/bin/activate + uv pip list + python --version + - run: + name: Test plotly.io image output with Kaleido v0 + command: | + source .venv/bin/activate + python -m pytest tests/test_optional/test_kaleido + no_output_timeout: 20m + jobs: check-code-formatting: docker: @@ -166,6 +195,17 @@ jobs: pandas_version: <> numpy_version: <> + test_kaleido_v0: + parameters: + python_version: + default: "3.12" + type: string + executor: + name: docker-container + python_version: <> + steps: + - test_io_kaleido_v0 + # 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_v0: + matrix: + parameters: + python_version: + - "3.12" - python_311_percy - build-doc diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py index 029b79f1029..2d8133c3466 100644 --- a/plotly/io/_kaleido.py +++ b/plotly/io/_kaleido.py @@ -1,29 +1,43 @@ import os import json from pathlib import Path +import importlib.metadata as importlib_metadata +from packaging.version import Version +import warnings + import plotly from plotly.io._utils import validate_coerce_fig_to_dict -try: - from kaleido.scopes.plotly import PlotlyScope - - scope = PlotlyScope() +ENGINE_SUPPORT_TIMELINE = "September 2025" - # 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: +try: + 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 +49,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,30 +78,32 @@ 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 is not None: + warnings.warn( + f"DeprecationWarning: The 'engine' argument is deprecated. Kaleido will be the only supported engine after {ENGINE_SUPPORT_TIMELINE}.", + DeprecationWarning, + ) + engine = "auto" + if engine == "auto": - if scope is not None: + if kaleido_available: # Default to kaleido if available engine = "kaleido" else: @@ -109,6 +119,11 @@ 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, + ) # Fall back to legacy orca image export path from ._orca import to_image as to_image_orca @@ -128,7 +143,7 @@ def to_image( ) # 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 +152,40 @@ 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 + # 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, + opts=dict( + format=format, + width=width, + height=height, + scale=scale, + ), + ) + 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, + ) + img_bytes = scope.transform( + fig_dict, format=format, width=width, height=height, scale=scale + ) return img_bytes @@ -190,18 +233,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 +248,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 +357,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 +375,21 @@ 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 + 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, + ) + 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 diff --git a/test_requirements/requirements_optional.txt b/test_requirements/requirements_optional.txt index a48fe001d56..ac18a3a86d5 100644 --- a/test_requirements/requirements_optional.txt +++ b/test_requirements/requirements_optional.txt @@ -17,9 +17,11 @@ 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 plotly-geo vaex;python_version<="3.9" +pdfrw 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():