Skip to content

Commit 9c8e1f5

Browse files
authored
Handle pathlib.Path in pio (#2974)
* Handle pathlib.Path in write_image * Allow pathlib.Path in write_html * Make file URI from absolute path (bugfix) * pathlib for orca * pathlib.Path support for read_json and write_json
1 parent 3fc9c82 commit 9c8e1f5

File tree

9 files changed

+346
-89
lines changed

9 files changed

+346
-89
lines changed

Diff for: packages/python/plotly/plotly/basedatatypes.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -3578,7 +3578,7 @@ def write_html(self, *args, **kwargs):
35783578
----------
35793579
file: str or writeable
35803580
A string representing a local file path or a writeable object
3581-
(e.g. an open file descriptor)
3581+
(e.g. a pathlib.Path object or an open file descriptor)
35823582
config: dict or None (default None)
35833583
Plotly.js figure config options
35843584
auto_play: bool (default=True)
@@ -3751,7 +3751,7 @@ def write_image(self, *args, **kwargs):
37513751
----------
37523752
file: str or writeable
37533753
A string representing a local file path or a writeable object
3754-
(e.g. an open file descriptor)
3754+
(e.g. a pathlib.Path object or an open file descriptor)
37553755
37563756
format: str or None
37573757
The desired image format. One of

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

+20-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import uuid
22
import json
33
import os
4+
from pathlib import Path
45
import webbrowser
56

67
import six
@@ -401,7 +402,7 @@ def write_html(
401402
Figure object or dict representing a figure
402403
file: str or writeable
403404
A string representing a local file path or a writeable object
404-
(e.g. an open file descriptor)
405+
(e.g. a pathlib.Path object or an open file descriptor)
405406
config: dict or None (default None)
406407
Plotly.js figure config options
407408
auto_play: bool (default=True)
@@ -520,24 +521,31 @@ def write_html(
520521
)
521522

522523
# Check if file is a string
523-
file_is_str = isinstance(file, six.string_types)
524+
if isinstance(file, six.string_types):
525+
# Use the standard pathlib constructor to make a pathlib object.
526+
path = Path(file)
527+
elif isinstance(file, Path): # PurePath is the most general pathlib object.
528+
# `file` is already a pathlib object.
529+
path = file
530+
else:
531+
# We could not make a pathlib object out of file. Either `file` is an open file
532+
# descriptor with a `write()` method or it's an invalid object.
533+
path = None
524534

525535
# Write HTML string
526-
if file_is_str:
527-
with open(file, "w") as f:
528-
f.write(html_str)
536+
if path is not None:
537+
path.write_text(html_str)
529538
else:
530539
file.write(html_str)
531540

532541
# Check if we should copy plotly.min.js to output directory
533-
if file_is_str and full_html and include_plotlyjs == "directory":
534-
bundle_path = os.path.join(os.path.dirname(file), "plotly.min.js")
542+
if path is not None and full_html and include_plotlyjs == "directory":
543+
bundle_path = path.parent / "plotly.min.js"
535544

536-
if not os.path.exists(bundle_path):
537-
with open(bundle_path, "w") as f:
538-
f.write(get_plotlyjs())
545+
if not bundle_path.exists():
546+
bundle_path.write_text(get_plotlyjs())
539547

540548
# Handle auto_open
541-
if file_is_str and full_html and auto_open:
542-
url = "file://" + os.path.abspath(file)
549+
if path is not None and full_html and auto_open:
550+
url = path.absolute().as_uri()
543551
webbrowser.open(url)

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

+47-16
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from six import string_types
44
import json
5-
5+
from pathlib import Path
66

77
from plotly.io._utils import validate_coerce_fig_to_dict, validate_coerce_output_type
88

@@ -68,7 +68,7 @@ def write_json(fig, file, validate=True, pretty=False, remove_uids=True):
6868
6969
file: str or writeable
7070
A string representing a local file path or a writeable object
71-
(e.g. an open file descriptor)
71+
(e.g. a pathlib.Path object or an open file descriptor)
7272
7373
pretty: bool (default False)
7474
True if JSON representation should be pretty-printed, False if
@@ -87,17 +87,40 @@ def write_json(fig, file, validate=True, pretty=False, remove_uids=True):
8787
# Pass through validate argument and let to_json handle validation logic
8888
json_str = to_json(fig, validate=validate, pretty=pretty, remove_uids=remove_uids)
8989

90-
# Check if file is a string
91-
# -------------------------
92-
file_is_str = isinstance(file, string_types)
90+
# Try to cast `file` as a pathlib object `path`.
91+
# ----------------------------------------------
92+
if isinstance(file, string_types):
93+
# Use the standard Path constructor to make a pathlib object.
94+
path = Path(file)
95+
elif isinstance(file, Path):
96+
# `file` is already a Path object.
97+
path = file
98+
else:
99+
# We could not make a Path object out of file. Either `file` is an open file
100+
# descriptor with a `write()` method or it's an invalid object.
101+
path = None
93102

94103
# Open file
95104
# ---------
96-
if file_is_str:
97-
with open(file, "w") as f:
98-
f.write(json_str)
105+
if path is None:
106+
# We previously failed to make sense of `file` as a pathlib object.
107+
# Attempt to write to `file` as an open file descriptor.
108+
try:
109+
file.write(json_str)
110+
return
111+
except AttributeError:
112+
pass
113+
raise ValueError(
114+
"""
115+
The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
116+
""".format(
117+
file=file
118+
)
119+
)
99120
else:
100-
file.write(json_str)
121+
# We previously succeeded in interpreting `file` as a pathlib object.
122+
# Now we can use `write_bytes()`.
123+
path.write_text(json_str)
101124

102125

103126
def from_json(value, output_type="Figure", skip_invalid=False):
@@ -162,7 +185,7 @@ def read_json(file, output_type="Figure", skip_invalid=False):
162185
----------
163186
file: str or readable
164187
A string containing the path to a local file or a read-able Python
165-
object (e.g. an open file descriptor)
188+
object (e.g. a pathlib.Path object or an open file descriptor)
166189
167190
output_type: type or str (default 'Figure')
168191
The output figure type or type name.
@@ -177,17 +200,25 @@ def read_json(file, output_type="Figure", skip_invalid=False):
177200
Figure or FigureWidget
178201
"""
179202

180-
# Check if file is a string
203+
# Try to cast `file` as a pathlib object `path`.
181204
# -------------------------
182-
# If it's a string we assume it's a local file path. If it's not a string
183-
# then we assume it's a read-able Python object
205+
# ----------------------------------------------
184206
file_is_str = isinstance(file, string_types)
207+
if isinstance(file, string_types):
208+
# Use the standard Path constructor to make a pathlib object.
209+
path = Path(file)
210+
elif isinstance(file, Path):
211+
# `file` is already a Path object.
212+
path = file
213+
else:
214+
# We could not make a Path object out of file. Either `file` is an open file
215+
# descriptor with a `write()` method or it's an invalid object.
216+
path = None
185217

186218
# Read file contents into JSON string
187219
# -----------------------------------
188-
if file_is_str:
189-
with open(file, "r") as f:
190-
json_str = f.read()
220+
if path is not None:
221+
json_str = path.read_text()
191222
else:
192223
json_str = file.read()
193224

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
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 Path constructor to make a pathlib object.
236+
path = Path(file)
237+
elif isinstance(file, Path):
238+
# `file` is already a Path object.
239+
path = file
240+
else:
241+
# We could not make a Path 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/io/_orca.py

+35-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import warnings
1111
from copy import copy
1212
from contextlib import contextmanager
13+
from pathlib import Path
1314

1415
import tenacity
1516
from six import string_types
@@ -1695,7 +1696,7 @@ def write_image(
16951696
16961697
file: str or writeable
16971698
A string representing a local file path or a writeable object
1698-
(e.g. an open file descriptor)
1699+
(e.g. a pathlib.Path object or an open file descriptor)
16991700
17001701
format: str or None
17011702
The desired image format. One of
@@ -1741,16 +1742,25 @@ def write_image(
17411742
None
17421743
"""
17431744

1744-
# Check if file is a string
1745-
# -------------------------
1746-
file_is_str = isinstance(file, string_types)
1745+
# Try to cast `file` as a pathlib object `path`.
1746+
# ----------------------------------------------
1747+
if isinstance(file, string_types):
1748+
# Use the standard Path constructor to make a pathlib object.
1749+
path = Path(file)
1750+
elif isinstance(file, Path):
1751+
# `file` is already a Path object.
1752+
path = file
1753+
else:
1754+
# We could not make a Path object out of file. Either `file` is an open file
1755+
# descriptor with a `write()` method or it's an invalid object.
1756+
path = None
17471757

17481758
# Infer format if not specified
17491759
# -----------------------------
1750-
if file_is_str and format is None:
1751-
_, ext = os.path.splitext(file)
1760+
if path is not None and format is None:
1761+
ext = path.suffix
17521762
if ext:
1753-
format = validate_coerce_format(ext)
1763+
format = ext.lstrip(".")
17541764
else:
17551765
raise ValueError(
17561766
"""
@@ -1774,8 +1784,22 @@ def write_image(
17741784

17751785
# Open file
17761786
# ---------
1777-
if file_is_str:
1778-
with open(file, "wb") as f:
1779-
f.write(img_data)
1787+
if path is None:
1788+
# We previously failed to make sense of `file` as a pathlib object.
1789+
# Attempt to write to `file` as an open file descriptor.
1790+
try:
1791+
file.write(img_data)
1792+
return
1793+
except AttributeError:
1794+
pass
1795+
raise ValueError(
1796+
"""
1797+
The 'file' argument '{file}' is not a string, pathlib.Path object, or file descriptor.
1798+
""".format(
1799+
file=file
1800+
)
1801+
)
17801802
else:
1781-
file.write(img_data)
1803+
# We previously succeeded in interpreting `file` as a pathlib object.
1804+
# Now we can use `write_bytes()`.
1805+
path.write_bytes(img_data)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Test compatibility with pathlib.Path.
2+
3+
See also relevant tests in
4+
packages/python/plotly/plotly/tests/test_optional/test_kaleido/test_kaleido.py
5+
"""
6+
7+
from unittest import mock
8+
import plotly.io as pio
9+
from io import StringIO
10+
from pathlib import Path
11+
import re
12+
from unittest.mock import Mock
13+
14+
fig = {"layout": {"title": {"text": "figure title"}}}
15+
16+
17+
def test_write_html():
18+
"""Verify that various methods for producing HTML have equivalent results.
19+
20+
The results will not be identical because the div id is pseudorandom. Thus
21+
we compare the results after replacing the div id.
22+
23+
We test the results of
24+
- pio.to_html
25+
- pio.write_html with a StringIO buffer
26+
- pio.write_html with a mock pathlib Path
27+
- pio.write_html with a mock file descriptor
28+
"""
29+
# Test pio.to_html
30+
html = pio.to_html(fig)
31+
32+
# Test pio.write_html with a StringIO buffer
33+
sio = StringIO()
34+
pio.write_html(fig, sio)
35+
sio.seek(0) # Rewind to the beginning of the buffer, otherwise read() returns ''.
36+
sio_html = sio.read()
37+
assert replace_div_id(html) == replace_div_id(sio_html)
38+
39+
# Test pio.write_html with a mock pathlib Path
40+
mock_pathlib_path = Mock(spec=Path)
41+
pio.write_html(fig, mock_pathlib_path)
42+
mock_pathlib_path.write_text.assert_called_once()
43+
(pl_html,) = mock_pathlib_path.write_text.call_args[0]
44+
assert replace_div_id(html) == replace_div_id(pl_html)
45+
46+
# Test pio.write_html with a mock file descriptor
47+
mock_file_descriptor = Mock()
48+
del mock_file_descriptor.write_bytes
49+
pio.write_html(fig, mock_file_descriptor)
50+
mock_file_descriptor.write.assert_called_once()
51+
(fd_html,) = mock_file_descriptor.write.call_args[0]
52+
assert replace_div_id(html) == replace_div_id(fd_html)
53+
54+
55+
def replace_div_id(s):
56+
uuid = re.search(r'<div id="([^"]*)"', s).groups()[0]
57+
return s.replace(uuid, "XXXX")

0 commit comments

Comments
 (0)