Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ce2e1b5

Browse files
authoredFeb 20, 2019
create_animations improvements, progress towards removing clientresp (plotly#1432)
* Automatically extract inline data arrays from figure in create_animations. Auto upload extracted grid * Don't extract columns inside object arrays since chart studio doens't support this yet * Add DeprecationWarning to plotly.plotly.plot when fileopt parameter is specified with a non-default value. This will allow us to drop this parameter and the clientresp API in plotly.py version 4 * Explicitly enable Deprecation warnings in test case. Seems nose filters them out by default so nothing is caught
1 parent 331c85f commit ce2e1b5

File tree

2 files changed

+365
-92
lines changed

2 files changed

+365
-92
lines changed
 

‎plotly/plotly/plotly.py

Lines changed: 320 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,28 @@
1616
"""
1717
from __future__ import absolute_import
1818

19+
import base64
1920
import copy
2021
import json
2122
import os
2223
import time
24+
import uuid
2325
import warnings
2426
import webbrowser
2527

2628
import six
2729
import six.moves
2830
from requests.compat import json as _json
2931

32+
from _plotly_utils.basevalidators import CompoundValidator, is_array
3033
from plotly import exceptions, files, session, tools, utils
3134
from plotly.api import v1, v2
3235
from plotly.basedatatypes import BaseTraceType, BaseFigure, BaseLayoutType
3336
from plotly.plotly import chunked_requests
3437

35-
from plotly.graph_objs import Scatter
38+
from plotly.graph_objs import Figure
3639

37-
from plotly.grid_objs import Grid, Column
40+
from plotly.grid_objs import Grid
3841
from plotly.dashboard_objs import dashboard_objs as dashboard
3942

4043
# This is imported like this for backwards compat. Careful if changing.
@@ -52,6 +55,10 @@
5255
'sharing': files.FILE_CONTENT[files.CONFIG_FILE]['sharing']
5356
}
5457

58+
warnings.filterwarnings(
59+
'default', r'The fileopt parameter is deprecated .*', DeprecationWarning
60+
)
61+
5562
SHARING_ERROR_MSG = (
5663
"Whoops, sharing can only be set to either 'public', 'private', or "
5764
"'secret'."
@@ -71,7 +78,7 @@ def sign_in(username, api_key, **kwargs):
7178
update_plot_options = session.update_session_plot_options
7279

7380

74-
def _plot_option_logic(plot_options_from_call_signature):
81+
def _plot_option_logic(plot_options_from_args):
7582
"""
7683
Given some plot_options as part of a plot call, decide on final options.
7784
Precedence:
@@ -84,10 +91,21 @@ def _plot_option_logic(plot_options_from_call_signature):
8491
default_plot_options = copy.deepcopy(DEFAULT_PLOT_OPTIONS)
8592
file_options = tools.get_config_file()
8693
session_options = session.get_session_plot_options()
87-
plot_options_from_call_signature = copy.deepcopy(plot_options_from_call_signature)
94+
plot_options_from_args = copy.deepcopy(plot_options_from_args)
95+
96+
# fileopt deprecation warnings
97+
fileopt_warning = ('The fileopt parameter is deprecated '
98+
'and will be removed in plotly.py version 4')
99+
if ('filename' in plot_options_from_args and
100+
plot_options_from_args.get('fileopt', 'overwrite') != 'overwrite'):
101+
warnings.warn(fileopt_warning, DeprecationWarning)
102+
103+
if ('filename' not in plot_options_from_args and
104+
plot_options_from_args.get('fileopt', 'new') != 'new'):
105+
warnings.warn(fileopt_warning, DeprecationWarning)
88106

89107
# Validate options and fill in defaults w world_readable and sharing
90-
for option_set in [plot_options_from_call_signature,
108+
for option_set in [plot_options_from_args,
91109
session_options, file_options]:
92110
utils.validate_world_readable_and_sharing_settings(option_set)
93111
utils.set_sharing_and_world_readable(option_set)
@@ -101,7 +119,7 @@ def _plot_option_logic(plot_options_from_call_signature):
101119
user_plot_options.update(default_plot_options)
102120
user_plot_options.update(file_options)
103121
user_plot_options.update(session_options)
104-
user_plot_options.update(plot_options_from_call_signature)
122+
user_plot_options.update(plot_options_from_args)
105123
user_plot_options = {k: v for k, v in user_plot_options.items()
106124
if k in default_plot_options}
107125

@@ -881,6 +899,20 @@ def mkdirs(cls, folder_path):
881899
response = v2.folders.create({'path': folder_path})
882900
return response.status_code
883901

902+
@classmethod
903+
def ensure_dirs(cls, folder_path):
904+
"""
905+
Create folder(s) if they don't exist, but unlike mkdirs, doesn't
906+
raise an error if folder path already exist
907+
"""
908+
try:
909+
cls.mkdirs(folder_path)
910+
except exceptions.PlotlyRequestError as e:
911+
if 'already exists' in e.message:
912+
pass
913+
else:
914+
raise e
915+
884916

885917
class grid_ops:
886918
"""
@@ -919,7 +951,7 @@ def ensure_uploaded(fid):
919951
)
920952

921953
@classmethod
922-
def upload(cls, grid, filename,
954+
def upload(cls, grid, filename=None,
923955
world_readable=True, auto_open=True, meta=None):
924956
"""
925957
Upload a grid to your Plotly account with the specified filename.
@@ -933,7 +965,8 @@ def upload(cls, grid, filename,
933965
separated by backslashes (`/`).
934966
If a grid, plot, or folder already exists with the same
935967
filename, a `plotly.exceptions.RequestError` will be
936-
thrown with status_code 409
968+
thrown with status_code 409. If filename is None,
969+
and randomly generated filename will be used.
937970
938971
Optional keyword arguments:
939972
- world_readable (default=True): make this grid publically (True)
@@ -979,23 +1012,31 @@ def upload(cls, grid, filename,
9791012
```
9801013
9811014
"""
982-
# Make a folder path
983-
if filename[-1] == '/':
984-
filename = filename[0:-1]
985-
986-
paths = filename.split('/')
987-
parent_path = '/'.join(paths[0:-1])
988-
989-
filename = paths[-1]
990-
991-
if parent_path != '':
992-
file_ops.mkdirs(parent_path)
993-
9941015
# transmorgify grid object into plotly's format
9951016
grid_json = grid._to_plotly_grid_json()
9961017
if meta is not None:
9971018
grid_json['metadata'] = meta
9981019

1020+
# Make a folder path
1021+
if filename:
1022+
if filename[-1] == '/':
1023+
filename = filename[0:-1]
1024+
1025+
paths = filename.split('/')
1026+
parent_path = '/'.join(paths[0:-1])
1027+
filename = paths[-1]
1028+
1029+
if parent_path != '':
1030+
file_ops.ensure_dirs(parent_path)
1031+
else:
1032+
# Create anonymous grid name
1033+
hash_val = hash(json.dumps(grid_json, sort_keys=True))
1034+
id = base64.urlsafe_b64encode(str(hash_val).encode('utf8'))
1035+
id_str = id.decode(encoding='utf8').replace('=', '')
1036+
filename = 'grid_' + id_str
1037+
# filename = 'grid_' + str(hash_val)
1038+
parent_path = ''
1039+
9991040
payload = {
10001041
'filename': filename,
10011042
'data': grid_json,
@@ -1005,12 +1046,11 @@ def upload(cls, grid, filename,
10051046
if parent_path != '':
10061047
payload['parent_path'] = parent_path
10071048

1008-
response = v2.grids.create(payload)
1049+
file_info = _create_or_update(payload, 'grid')
10091050

1010-
parsed_content = response.json()
1011-
cols = parsed_content['file']['cols']
1012-
fid = parsed_content['file']['fid']
1013-
web_url = parsed_content['file']['web_url']
1051+
cols = file_info['cols']
1052+
fid = file_info['fid']
1053+
web_url = file_info['web_url']
10141054

10151055
# mutate the grid columns with the id's returned from the server
10161056
cls._fill_in_response_column_ids(grid, cols, fid)
@@ -1373,6 +1413,66 @@ def get_grid(grid_url, raw=False):
13731413
return Grid(parsed_content, fid)
13741414

13751415

1416+
def _create_or_update(data, filetype):
1417+
"""
1418+
Create or update (if file exists) and grid, plot, spectacle, or dashboard
1419+
object
1420+
1421+
Parameters
1422+
----------
1423+
data: dict
1424+
update/create API payload
1425+
filetype: str
1426+
One of 'plot', 'grid', 'spectacle_presentation', or 'dashboard'
1427+
1428+
Returns
1429+
-------
1430+
dict
1431+
File info from API response
1432+
"""
1433+
api_module = getattr(v2, filetype + 's')
1434+
1435+
# lookup if pre-existing filename already exists
1436+
if 'parent_path' in data:
1437+
filename = data['parent_path'] + '/' + data['filename']
1438+
else:
1439+
filename = data.get('filename', None)
1440+
1441+
if filename:
1442+
try:
1443+
lookup_res = v2.files.lookup(filename)
1444+
matching_file = json.loads(lookup_res.content)
1445+
1446+
if matching_file['filetype'] == filetype:
1447+
fid = matching_file['fid']
1448+
res = api_module.update(fid, data)
1449+
else:
1450+
raise exceptions.PlotlyError("""
1451+
'{filename}' is already a {other_filetype} in your account.
1452+
While you can overwrite {filetype}s with the same name, you can't overwrite
1453+
files with a different type. Try deleting '{filename}' in your account or
1454+
changing the filename.""".format(
1455+
filename=filename,
1456+
filetype=filetype,
1457+
other_filetype=matching_file['filetype']
1458+
)
1459+
)
1460+
1461+
except exceptions.PlotlyRequestError:
1462+
res = api_module.create(data)
1463+
else:
1464+
res = api_module.create(data)
1465+
1466+
# Check response
1467+
res.raise_for_status()
1468+
1469+
# Get resulting file content
1470+
file_info = res.json()
1471+
file_info = file_info.get('file', file_info)
1472+
1473+
return file_info
1474+
1475+
13761476
class dashboard_ops:
13771477
"""
13781478
Interface to Plotly's Dashboards API.
@@ -1453,37 +1553,15 @@ def upload(cls, dashboard, filename, sharing='public', auto_open=True):
14531553
'world_readable': world_readable
14541554
}
14551555

1456-
# lookup if pre-existing filename already exists
1457-
try:
1458-
lookup_res = v2.files.lookup(filename)
1459-
matching_file = json.loads(lookup_res.content)
1556+
file_info = _create_or_update(data, 'dashboard')
14601557

1461-
if matching_file['filetype'] == 'dashboard':
1462-
old_fid = matching_file['fid']
1463-
res = v2.dashboards.update(old_fid, data)
1464-
else:
1465-
raise exceptions.PlotlyError(
1466-
"'{filename}' is already a {filetype} in your account. "
1467-
"While you can overwrite dashboards with the same name, "
1468-
"you can't change overwrite files with a different type. "
1469-
"Try deleting '{filename}' in your account or changing "
1470-
"the filename.".format(
1471-
filename=filename,
1472-
filetype=matching_file['filetype']
1473-
)
1474-
)
1475-
1476-
except exceptions.PlotlyRequestError:
1477-
res = v2.dashboards.create(data)
1478-
res.raise_for_status()
1479-
1480-
url = res.json()['web_url']
1558+
url = file_info['web_url']
14811559

14821560
if sharing == 'secret':
14831561
url = add_share_key_to_url(url)
14841562

14851563
if auto_open:
1486-
webbrowser.open_new(res.json()['web_url'])
1564+
webbrowser.open_new(file_info['web_url'])
14871565

14881566
return url
14891567

@@ -1573,40 +1651,164 @@ def upload(cls, presentation, filename, sharing='public', auto_open=True):
15731651
'world_readable': world_readable
15741652
}
15751653

1576-
# lookup if pre-existing filename already exists
1577-
try:
1578-
lookup_res = v2.files.lookup(filename)
1579-
lookup_res.raise_for_status()
1580-
matching_file = json.loads(lookup_res.content)
1581-
1582-
if matching_file['filetype'] != 'spectacle_presentation':
1583-
raise exceptions.PlotlyError(
1584-
"'{filename}' is already a {filetype} in your account. "
1585-
"You can't overwrite a file that is not a spectacle_"
1586-
"presentation. Please pick another filename.".format(
1587-
filename=filename,
1588-
filetype=matching_file['filetype']
1589-
)
1590-
)
1591-
else:
1592-
old_fid = matching_file['fid']
1593-
res = v2.spectacle_presentations.update(old_fid, data)
1654+
file_info = _create_or_update(data, 'spectacle_presentation')
15941655

1595-
except exceptions.PlotlyRequestError:
1596-
res = v2.spectacle_presentations.create(data)
1597-
res.raise_for_status()
1598-
1599-
url = res.json()['web_url']
1656+
url = file_info['web_url']
16001657

16011658
if sharing == 'secret':
16021659
url = add_share_key_to_url(url)
16031660

16041661
if auto_open:
1605-
webbrowser.open_new(res.json()['web_url'])
1662+
webbrowser.open_new(file_info['web_url'])
16061663

16071664
return url
16081665

16091666

1667+
def _extract_grid_graph_obj(obj_dict, reference_obj, grid, path):
1668+
"""
1669+
Extract inline data arrays from a graph_obj instance and place them in
1670+
a grid
1671+
1672+
Parameters
1673+
----------
1674+
obj_dict: dict
1675+
dict representing a graph object that may contain inline arrays
1676+
reference_obj: BasePlotlyType
1677+
An empty instance of a `graph_obj` with type corresponding to obj_dict
1678+
grid: Grid
1679+
Grid to extract data arrays too
1680+
path: str
1681+
Path string of the location of `obj_dict` in the figure
1682+
1683+
Returns
1684+
-------
1685+
None
1686+
Function modifies obj_dict and grid in-place
1687+
"""
1688+
1689+
from plotly.grid_objs import Column
1690+
1691+
for prop in list(obj_dict.keys()):
1692+
propsrc = '{}src'.format(prop)
1693+
if propsrc in reference_obj:
1694+
val = obj_dict[prop]
1695+
if is_array(val):
1696+
column = Column(val, path + prop)
1697+
grid.append(column)
1698+
obj_dict[propsrc] = 'TBD'
1699+
del obj_dict[prop]
1700+
1701+
elif prop in reference_obj:
1702+
prop_validator = reference_obj._validators[prop]
1703+
if isinstance(prop_validator, CompoundValidator):
1704+
# Recurse on compound child
1705+
_extract_grid_graph_obj(
1706+
obj_dict[prop],
1707+
reference_obj[prop],
1708+
grid,
1709+
'{path}{prop}.'.format(path=path, prop=prop))
1710+
1711+
# Chart studio doesn't handle links to columns inside object
1712+
# arrays, so we don't extract them for now. Logic below works
1713+
# and should be reinstated if chart studio gets this capability
1714+
#
1715+
# elif isinstance(prop_validator, CompoundArrayValidator):
1716+
# # Recurse on elements of object arary
1717+
# reference_element = prop_validator.validate_coerce([{}])[0]
1718+
# for i, element_dict in enumerate(obj_dict[prop]):
1719+
# _extract_grid_graph_obj(
1720+
# element_dict,
1721+
# reference_element,
1722+
# grid,
1723+
# '{path}{prop}.{i}.'.format(path=path, prop=prop, i=i)
1724+
# )
1725+
1726+
1727+
def _extract_grid_from_fig_like(fig, grid=None, path=''):
1728+
"""
1729+
Extract inline data arrays from a figure and place them in a grid
1730+
1731+
Parameters
1732+
----------
1733+
fig: dict
1734+
A dict representing a figure or a frame
1735+
grid: Grid or None (default None)
1736+
The grid to place the extracted columns in. If None, a new grid will
1737+
be constructed
1738+
path: str (default '')
1739+
Parent path, set to `frames` for use with frame objects
1740+
Returns
1741+
-------
1742+
(dict, Grid)
1743+
* dict: Figure dict with data arrays removed
1744+
* Grid: Grid object containing one column for each removed data array.
1745+
Columns are named with the path the corresponding data array
1746+
(e.g. 'data.0.marker.size')
1747+
"""
1748+
1749+
if grid is None:
1750+
# If not grid, this is top-level call so deep copy figure
1751+
copy_fig = True
1752+
grid = Grid([])
1753+
else:
1754+
# Grid passed in so this is recursive call, don't copy figure
1755+
copy_fig = False
1756+
1757+
if isinstance(fig, BaseFigure):
1758+
fig_dict = fig.to_dict()
1759+
elif isinstance(fig, dict):
1760+
fig_dict = copy.deepcopy(fig) if copy_fig else fig
1761+
else:
1762+
raise ValueError('Invalid figure type {}'.format(type(fig)))
1763+
1764+
# Process traces
1765+
reference_fig = Figure()
1766+
reference_traces = {}
1767+
for i, trace_dict in enumerate(fig_dict.get('data', [])):
1768+
trace_type = trace_dict.get('type', 'scatter')
1769+
if trace_type not in reference_traces:
1770+
reference_traces[trace_type] = reference_fig.add_trace(
1771+
{'type': trace_type})
1772+
1773+
reference_trace = reference_traces[trace_type]
1774+
_extract_grid_graph_obj(
1775+
trace_dict, reference_trace, grid, path + 'data.{}.'.format(i))
1776+
1777+
# Process frames
1778+
if 'frames' in fig_dict:
1779+
for i, frame_dict in enumerate(fig_dict['frames']):
1780+
_extract_grid_from_fig_like(
1781+
frame_dict, grid, 'frames.{}.'.format(i))
1782+
1783+
return fig_dict, grid
1784+
1785+
1786+
def _set_grid_column_references(figure, grid):
1787+
"""
1788+
Populate *src columns in a figure from uploaded grid
1789+
1790+
Parameters
1791+
----------
1792+
figure: dict
1793+
Figure dict that previously had inline data arrays extracted
1794+
grid: Grid
1795+
Grid that was created by extracting inline data arrays from figure
1796+
using the _extract_grid_from_fig_like function
1797+
1798+
Returns
1799+
-------
1800+
None
1801+
Function modifies figure in-place
1802+
"""
1803+
for col in grid:
1804+
prop_path = BaseFigure._str_to_dict_path(col.name)
1805+
prop_parent = figure
1806+
for prop in prop_path[:-1]:
1807+
prop_parent = prop_parent[prop]
1808+
1809+
prop_parent[prop_path[-1] + 'src'] = col.id
1810+
1811+
16101812
def create_animations(figure, filename=None, sharing='public', auto_open=True):
16111813
"""
16121814
BETA function that creates plots with animations via `frames`.
@@ -1773,43 +1975,69 @@ def create_animations(figure, filename=None, sharing='public', auto_open=True):
17731975
py.create_animations(figure, 'growing_circles')
17741976
```
17751977
"""
1776-
body = {
1978+
payload = {
17771979
'figure': figure,
17781980
'world_readable': True
17791981
}
17801982

17811983
# set filename if specified
17821984
if filename:
1783-
# warn user that creating folders isn't support in this version
1784-
if '/' in filename:
1785-
warnings.warn(
1786-
"This BETA version of 'create_animations' does not support "
1787-
"automatic folder creation. This means a filename of the form "
1788-
"'name1/name2' will just create the plot with that name only."
1789-
)
1790-
body['filename'] = filename
1985+
# Strip trailing slash
1986+
if filename[-1] == '/':
1987+
filename = filename[0:-1]
1988+
1989+
# split off any parent directory
1990+
paths = filename.split('/')
1991+
parent_path = '/'.join(paths[0:-1])
1992+
filename = paths[-1]
1993+
1994+
# Create parent directory
1995+
if parent_path != '':
1996+
file_ops.ensure_dirs(parent_path)
1997+
payload['parent_path'] = parent_path
1998+
1999+
payload['filename'] = filename
2000+
else:
2001+
parent_path = ''
17912002

17922003
# set sharing
17932004
if sharing == 'public':
1794-
body['world_readable'] = True
2005+
payload['world_readable'] = True
17952006
elif sharing == 'private':
1796-
body['world_readable'] = False
2007+
payload['world_readable'] = False
17972008
elif sharing == 'secret':
1798-
body['world_readable'] = False
1799-
body['share_key_enabled'] = True
2009+
payload['world_readable'] = False
2010+
payload['share_key_enabled'] = True
18002011
else:
18012012
raise exceptions.PlotlyError(
18022013
SHARING_ERROR_MSG
18032014
)
18042015

1805-
response = v2.plots.create(body)
1806-
parsed_content = response.json()
2016+
# Extract grid
2017+
figure, grid = _extract_grid_from_fig_like(figure)
2018+
2019+
if len(grid) > 0:
2020+
if not filename:
2021+
grid_filename = None
2022+
elif parent_path:
2023+
grid_filename = parent_path + '/' + filename + '_grid'
2024+
else:
2025+
grid_filename = filename + '_grid'
2026+
2027+
grid_ops.upload(grid=grid,
2028+
filename=grid_filename,
2029+
world_readable=payload['world_readable'],
2030+
auto_open=False)
2031+
_set_grid_column_references(figure, grid)
2032+
payload['figure'] = figure
2033+
2034+
file_info = _create_or_update(payload, 'plot')
18072035

18082036
if sharing == 'secret':
1809-
web_url = (parsed_content['file']['web_url'][:-1] +
1810-
'?share_key=' + parsed_content['file']['share_key'])
2037+
web_url = (file_info['web_url'][:-1] +
2038+
'?share_key=' + file_info['share_key'])
18112039
else:
1812-
web_url = parsed_content['file']['web_url']
2040+
web_url = file_info['web_url']
18132041

18142042
if auto_open:
18152043
_open_url(web_url)

‎plotly/tests/test_plot_ly/test_plotly/test_plot.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import six
1212
import sys
1313
from requests.compat import json as _json
14+
import warnings
1415

1516
from nose.plugins.attrib import attr
1617

@@ -162,6 +163,50 @@ def test_plot_option_logic_only_sharing_given(self):
162163
'sharing': 'private'}
163164
self.assertEqual(plot_option_logic, expected_plot_option_logic)
164165

166+
def test_plot_option_fileopt_deprecations(self):
167+
168+
# Make sure DeprecationWarnings aren't filtered out by nose
169+
warnings.filterwarnings('default', category=DeprecationWarning)
170+
171+
# If filename is not given and fileopt is not 'new',
172+
# raise a deprecation warning
173+
kwargs = {'auto_open': True,
174+
'fileopt': 'overwrite',
175+
'validate': True,
176+
'sharing': 'private'}
177+
178+
with warnings.catch_warnings(record=True) as w:
179+
plot_option_logic = py._plot_option_logic(kwargs)
180+
assert w[0].category == DeprecationWarning
181+
182+
expected_plot_option_logic = {'filename': 'plot from API',
183+
'auto_open': True,
184+
'fileopt': 'overwrite',
185+
'validate': True,
186+
'world_readable': False,
187+
'sharing': 'private'}
188+
self.assertEqual(plot_option_logic, expected_plot_option_logic)
189+
190+
# If filename is given and fileopt is not 'overwrite',
191+
# raise a depreacation warning
192+
kwargs = {'filename': 'test',
193+
'auto_open': True,
194+
'fileopt': 'append',
195+
'validate': True,
196+
'sharing': 'private'}
197+
198+
with warnings.catch_warnings(record=True) as w:
199+
plot_option_logic = py._plot_option_logic(kwargs)
200+
assert w[0].category == DeprecationWarning
201+
202+
expected_plot_option_logic = {'filename': 'test',
203+
'auto_open': True,
204+
'fileopt': 'append',
205+
'validate': True,
206+
'world_readable': False,
207+
'sharing': 'private'}
208+
self.assertEqual(plot_option_logic, expected_plot_option_logic)
209+
165210
@attr('slow')
166211
def test_plot_url_given_sharing_key(self):
167212

0 commit comments

Comments
 (0)
Please sign in to comment.