Skip to content

Commit e69c31e

Browse files
committed
Handle pathlib.Path in write_image
1 parent b343fcc commit e69c31e

File tree

3 files changed

+142
-40
lines changed

3 files changed

+142
-40
lines changed

Diff for: packages/python/plotly/plotly/io/_kaleido.py

+34-10
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from six import string_types
33
import os
44
import json
5+
from pathlib import Path, PurePath
56
import plotly
67
from plotly.io._utils import validate_coerce_fig_to_dict
78

@@ -169,7 +170,7 @@ def write_image(
169170
170171
file: str or writeable
171172
A string representing a local file path or a writeable object
172-
(e.g. an open file descriptor)
173+
(e.g. a pathlib.Path object or an open file descriptor)
173174
174175
format: str or None
175176
The desired image format. One of
@@ -228,14 +229,23 @@ def write_image(
228229
-------
229230
None
230231
"""
231-
# Check if file is a string
232-
# -------------------------
233-
file_is_str = isinstance(file, string_types)
232+
# Try to cast `file` as a pathlib object `path`.
233+
# ----------------------------------------------
234+
if isinstance(file, string_types):
235+
# Use the standard pathlib constructor to make a pathlib object.
236+
path = Path(file)
237+
elif isinstance(file, PurePath): # PurePath is the most general pathlib object.
238+
# `file` is already a pathlib object.
239+
path = file
240+
else:
241+
# We could not make a pathlib object out of file. Either `file` is an open file
242+
# descriptor with a `write()` method or it's an invalid object.
243+
path = None
234244

235245
# Infer format if not specified
236246
# -----------------------------
237-
if file_is_str and format is None:
238-
_, ext = os.path.splitext(file)
247+
if path is not None and format is None:
248+
ext = path.suffix
239249
if ext:
240250
format = ext.lstrip(".")
241251
else:
@@ -267,11 +277,25 @@ def write_image(
267277

268278
# Open file
269279
# ---------
270-
if file_is_str:
271-
with open(file, "wb") as f:
272-
f.write(img_data)
280+
if path is None:
281+
# We previously failed to make sense of `file` as a pathlib object.
282+
# Attempt to write to `file` as an open file descriptor.
283+
try:
284+
file.write(img_data)
285+
return
286+
except AttributeError:
287+
pass
288+
raise ValueError(
289+
"""
290+
The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
291+
""".format(
292+
file=file
293+
)
294+
)
273295
else:
274-
file.write(img_data)
296+
# We previously succeeded in interpreting `file` as a pathlib object.
297+
# Now we can use `write_bytes()`.
298+
path.write_bytes(img_data)
275299

276300

277301
def full_figure_for_development(fig, warn=True, as_dict=False):

Diff for: packages/python/plotly/plotly/tests/test_optional/test_kaleido/test_kaleido.py

+87-29
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,56 @@
11
import plotly.io as pio
22
import plotly.io.kaleido
3-
import sys
43
from contextlib import contextmanager
5-
6-
if sys.version_info >= (3, 3):
7-
from unittest.mock import Mock
8-
else:
9-
from mock import Mock
4+
from io import BytesIO
5+
from pathlib import Path
6+
from unittest.mock import Mock
107

118
fig = {"layout": {"title": {"text": "figure title"}}}
129

1310

11+
def make_writeable_mocks():
12+
"""Produce some mocks which we will use for testing the `write_image()` function.
13+
14+
These mocks should be passed as the `file=` argument to `write_image()`.
15+
16+
The tests should verify that the method specified in the `active_write_function`
17+
attribute is called once, and that scope.transform is called with the `format=`
18+
argument specified by the `.expected_format` attribute.
19+
20+
In total we provide two mocks: one for a writable file descriptor, and other for a
21+
pathlib.Path object.
22+
"""
23+
24+
# Part 1: A mock for a file descriptor
25+
# ------------------------------------
26+
mock_file_descriptor = Mock()
27+
28+
# A file descriptor has no write_bytes method, unlike a pathlib Path.
29+
del mock_file_descriptor.write_bytes
30+
31+
# The expected write method for a file descriptor is .write
32+
mock_file_descriptor.active_write_function = mock_file_descriptor.write
33+
34+
# Since there is no filename, there should be no format detected.
35+
mock_file_descriptor.expected_format = None
36+
37+
# Part 2: A mock for a pathlib path
38+
# ---------------------------------
39+
mock_pathlib_path = Mock(spec=Path)
40+
41+
# A pathlib Path object has no write method, unlike a file descriptor.
42+
del mock_pathlib_path.write
43+
44+
# The expected write method for a pathlib Path is .write_bytes
45+
mock_pathlib_path.active_write_function = mock_pathlib_path.write_bytes
46+
47+
# Mock a path with PNG suffix
48+
mock_pathlib_path.suffix = ".png"
49+
mock_pathlib_path.expected_format = "png"
50+
51+
return mock_file_descriptor, mock_pathlib_path
52+
53+
1454
@contextmanager
1555
def mocked_scope():
1656
# Code to acquire resource, e.g.:
@@ -44,15 +84,19 @@ def test_kaleido_engine_to_image():
4484

4585

4686
def test_kaleido_engine_write_image():
47-
writeable_mock = Mock()
48-
with mocked_scope() as scope:
49-
pio.write_image(fig, writeable_mock, engine="kaleido", validate=False)
87+
for writeable_mock in make_writeable_mocks():
88+
with mocked_scope() as scope:
89+
pio.write_image(fig, writeable_mock, engine="kaleido", validate=False)
5090

51-
scope.transform.assert_called_with(
52-
fig, format=None, width=None, height=None, scale=None
53-
)
91+
scope.transform.assert_called_with(
92+
fig,
93+
format=writeable_mock.expected_format,
94+
width=None,
95+
height=None,
96+
scale=None,
97+
)
5498

55-
assert writeable_mock.write.call_count == 1
99+
assert writeable_mock.active_write_function.call_count == 1
56100

57101

58102
def test_kaleido_engine_to_image_kwargs():
@@ -73,24 +117,24 @@ def test_kaleido_engine_to_image_kwargs():
73117

74118

75119
def test_kaleido_engine_write_image_kwargs():
76-
writeable_mock = Mock()
77-
with mocked_scope() as scope:
78-
pio.write_image(
79-
fig,
80-
writeable_mock,
81-
format="jpg",
82-
width=700,
83-
height=600,
84-
scale=2,
85-
engine="kaleido",
86-
validate=False,
120+
for writeable_mock in make_writeable_mocks():
121+
with mocked_scope() as scope:
122+
pio.write_image(
123+
fig,
124+
writeable_mock,
125+
format="jpg",
126+
width=700,
127+
height=600,
128+
scale=2,
129+
engine="kaleido",
130+
validate=False,
131+
)
132+
133+
scope.transform.assert_called_with(
134+
fig, format="jpg", width=700, height=600, scale=2
87135
)
88136

89-
scope.transform.assert_called_with(
90-
fig, format="jpg", width=700, height=600, scale=2
91-
)
92-
93-
assert writeable_mock.write.call_count == 1
137+
assert writeable_mock.active_write_function.call_count == 1
94138

95139

96140
def test_image_renderer():
@@ -105,3 +149,17 @@ def test_image_renderer():
105149
height=renderer.height,
106150
scale=renderer.scale,
107151
)
152+
153+
154+
def test_bytesio():
155+
"""Verify that writing to a BytesIO object contains the same data as to_image().
156+
157+
The goal of this test is to ensure that Plotly correctly handles a writable buffer
158+
which doesn't correspond to a filesystem path.
159+
"""
160+
bio = BytesIO()
161+
pio.write_image(fig, bio, format="jpg", engine="kaleido", validate=False)
162+
bio.seek(0) # Rewind to the beginning of the buffer, otherwise read() returns b''.
163+
bio_bytes = bio.read()
164+
to_image_bytes = pio.to_image(fig, format="jpg", engine="kaleido", validate=False)
165+
assert bio_bytes == to_image_bytes

Diff for: packages/python/plotly/plotly/tests/test_orca/test_to_image.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77
import sys
88
import pandas as pd
9+
from io import BytesIO
910

1011
if sys.version_info >= (3, 3):
1112
from unittest.mock import MagicMock
@@ -234,7 +235,12 @@ def test_write_image_writeable(fig1, format):
234235
fig1, mock_file, format=format, width=700, height=500, engine="orca"
235236
)
236237

237-
mock_file.write.assert_called_once_with(expected_bytes)
238+
if mock_file.write_bytes.called:
239+
mock_file.write_bytes.assert_called_once_with(expected_bytes)
240+
elif mock_file.write.called:
241+
mock_file.write.assert_called_once_with(expected_bytes)
242+
else:
243+
assert "Neither write nor write_bytes was called."
238244

239245

240246
def test_write_image_string_format_inference(fig1, format):
@@ -347,3 +353,17 @@ def test_invalid_figure_json():
347353
)
348354

349355
assert "400: invalid or malformed request syntax" in str(err.value)
356+
357+
358+
def test_bytesio(fig1):
359+
"""Verify that writing to a BytesIO object contains the same data as to_image().
360+
361+
The goal of this test is to ensure that Plotly correctly handles a writable buffer
362+
which doesn't correspond to a filesystem path.
363+
"""
364+
bio = BytesIO()
365+
pio.write_image(fig1, bio, format="jpg", validate=False)
366+
bio.seek(0)
367+
bio_bytes = bio.read()
368+
to_image_bytes = pio.to_image(fig1, format="jpg", validate=False)
369+
assert bio_bytes == to_image_bytes

0 commit comments

Comments
 (0)