diff --git a/circle.yml b/circle.yml index 3c146d51ecc..aecf5f3b62f 100644 --- a/circle.yml +++ b/circle.yml @@ -16,7 +16,7 @@ dependencies: - bash circle/setup.sh # install testing tools for circle's version of things - - pip install nose coverage + - pip install nose coverage mock - pip install -I . # we need to cd out of the project root to ensure the install worked diff --git a/optional-requirements.txt b/optional-requirements.txt index 22342b1be71..4aadc396630 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -12,6 +12,7 @@ numpy # matplotlib==1.3.1 ## testing dependencies ## +mock==2.0.0 nose==1.3.3 ## ipython dependencies ## diff --git a/plotly/api/__init__.py b/plotly/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plotly/api/utils.py b/plotly/api/utils.py new file mode 100644 index 00000000000..d9d1d21f504 --- /dev/null +++ b/plotly/api/utils.py @@ -0,0 +1,41 @@ +from base64 import b64encode + +from requests.compat import builtin_str, is_py2 + + +def _to_native_string(string, encoding): + if isinstance(string, builtin_str): + return string + if is_py2: + return string.encode(encoding) + return string.decode(encoding) + + +def to_native_utf8_string(string): + return _to_native_string(string, 'utf-8') + + +def to_native_ascii_string(string): + return _to_native_string(string, 'ascii') + + +def basic_auth(username, password): + """ + Creates the basic auth value to be used in an authorization header. + + This is mostly copied from the requests library. + + :param (str) username: A Plotly username. + :param (str) password: The password for the given Plotly username. + :returns: (str) An 'authorization' header for use in a request header. + + """ + if isinstance(username, str): + username = username.encode('latin1') + + if isinstance(password, str): + password = password.encode('latin1') + + return 'Basic ' + to_native_ascii_string( + b64encode(b':'.join((username, password))).strip() + ) diff --git a/plotly/api/v1/__init__.py b/plotly/api/v1/__init__.py new file mode 100644 index 00000000000..a43ff61f4c8 --- /dev/null +++ b/plotly/api/v1/__init__.py @@ -0,0 +1,3 @@ +from __future__ import absolute_import + +from plotly.api.v1.clientresp import clientresp diff --git a/plotly/api/v1/clientresp.py b/plotly/api/v1/clientresp.py new file mode 100644 index 00000000000..c3af66c6b1c --- /dev/null +++ b/plotly/api/v1/clientresp.py @@ -0,0 +1,44 @@ +"""Interface to deprecated /clientresp API. Subject to deletion.""" +from __future__ import absolute_import + +import warnings + +from requests.compat import json as _json + +from plotly import config, utils, version +from plotly.api.v1.utils import request + + +def clientresp(data, **kwargs): + """ + Deprecated endpoint, still used because it can parse data out of a plot. + + When we get around to forcing users to create grids and then create plots, + we can finally get rid of this. + + :param (list) data: The data array from a figure. + + """ + creds = config.get_credentials() + cfg = config.get_config() + + dumps_kwargs = {'sort_keys': True, 'cls': utils.PlotlyJSONEncoder} + + payload = { + 'platform': 'python', 'version': version.__version__, + 'args': _json.dumps(data, **dumps_kwargs), + 'un': creds['username'], 'key': creds['api_key'], 'origin': 'plot', + 'kwargs': _json.dumps(kwargs, **dumps_kwargs) + } + + url = '{plotly_domain}/clientresp'.format(**cfg) + response = request('post', url, data=payload) + + # Old functionality, just keeping it around. + parsed_content = response.json() + if parsed_content.get('warning'): + warnings.warn(parsed_content['warning']) + if parsed_content.get('message'): + print(parsed_content['message']) + + return response diff --git a/plotly/api/v1/utils.py b/plotly/api/v1/utils.py new file mode 100644 index 00000000000..abfdf745c3e --- /dev/null +++ b/plotly/api/v1/utils.py @@ -0,0 +1,87 @@ +from __future__ import absolute_import + +import requests +from requests.exceptions import RequestException + +from plotly import config, exceptions +from plotly.api.utils import basic_auth + + +def validate_response(response): + """ + Raise a helpful PlotlyRequestError for failed requests. + + :param (requests.Response) response: A Response object from an api request. + :raises: (PlotlyRequestError) If the request failed for any reason. + :returns: (None) + + """ + content = response.content + status_code = response.status_code + try: + parsed_content = response.json() + except ValueError: + message = content if content else 'No Content' + raise exceptions.PlotlyRequestError(message, status_code, content) + + message = '' + if isinstance(parsed_content, dict): + error = parsed_content.get('error') + if error: + message = error + else: + if response.ok: + return + if not message: + message = content if content else 'No Content' + + raise exceptions.PlotlyRequestError(message, status_code, content) + + +def get_headers(): + """ + Using session credentials/config, get headers for a v1 API request. + + Users may have their own proxy layer and so we free up the `authorization` + header for this purpose (instead adding the user authorization in a new + `plotly-authorization` header). See pull #239. + + :returns: (dict) Headers to add to a requests.request call. + + """ + headers = {} + creds = config.get_credentials() + proxy_auth = basic_auth(creds['proxy_username'], creds['proxy_password']) + + if config.get_config()['plotly_proxy_authorization']: + headers['authorization'] = proxy_auth + + return headers + + +def request(method, url, **kwargs): + """ + Central place to make any v1 api request. + + :param (str) method: The request method ('get', 'put', 'delete', ...). + :param (str) url: The full api url to make the request to. + :param kwargs: These are passed along to requests. + :return: (requests.Response) The response directly from requests. + + """ + if kwargs.get('json', None) is not None: + # See plotly.api.v2.utils.request for examples on how to do this. + raise exceptions.PlotlyError('V1 API does not handle arbitrary json.') + kwargs['headers'] = dict(kwargs.get('headers', {}), **get_headers()) + kwargs['verify'] = config.get_config()['plotly_ssl_verification'] + try: + response = requests.request(method, url, **kwargs) + except RequestException as e: + # The message can be an exception. E.g., MaxRetryError. + message = str(getattr(e, 'message', 'No message')) + response = getattr(e, 'response', None) + status_code = response.status_code if response else None + content = response.content if response else 'No content' + raise exceptions.PlotlyRequestError(message, status_code, content) + validate_response(response) + return response diff --git a/plotly/api/v2/__init__.py b/plotly/api/v2/__init__.py new file mode 100644 index 00000000000..95c8a84e4d3 --- /dev/null +++ b/plotly/api/v2/__init__.py @@ -0,0 +1,3 @@ +from __future__ import absolute_import + +from plotly.api.v2 import files, folders, grids, images, plot_schema, plots diff --git a/plotly/api/v2/files.py b/plotly/api/v2/files.py new file mode 100644 index 00000000000..650ab48fc85 --- /dev/null +++ b/plotly/api/v2/files.py @@ -0,0 +1,85 @@ +"""Interface to Plotly's /v2/files endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, make_params, request + +RESOURCE = 'files' + + +def retrieve(fid, share_key=None): + """ + Retrieve a general file from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + params = make_params(share_key=share_key) + return request('get', url, params=params) + + +def update(fid, body): + """ + Update a general file from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + return request('put', url, json=body) + + +def trash(fid): + """ + Soft-delete a general file from Plotly. (Can be undone with 'restore'). + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='trash') + return request('post', url) + + +def restore(fid): + """ + Restore a trashed, general file from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='restore') + return request('post', url) + + +def permanent_delete(fid): + """ + Permanently delete a trashed, general file from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='permanent_delete') + return request('delete', url) + + +def lookup(path, parent=None, user=None, exists=None): + """ + Retrieve a general file from Plotly without needing a fid. + + :param (str) path: The '/'-delimited path specifying the file location. + :param (int) parent: Parent id, an integer, which the path is relative to. + :param (str) user: The username to target files for. Defaults to requestor. + :param (bool) exists: If True, don't return the full file, just a flag. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, route='lookup') + params = make_params(path=path, parent=parent, user=user, exists=exists) + return request('get', url, params=params) diff --git a/plotly/api/v2/folders.py b/plotly/api/v2/folders.py new file mode 100644 index 00000000000..2dcf84670e7 --- /dev/null +++ b/plotly/api/v2/folders.py @@ -0,0 +1,103 @@ +"""Interface to Plotly's /v2/folders endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, make_params, request + +RESOURCE = 'folders' + + +def create(body): + """ + Create a new folder. + + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE) + return request('post', url, json=body) + + +def retrieve(fid, share_key=None): + """ + Retrieve a folder from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + params = make_params(share_key=share_key) + return request('get', url, params=params) + + +def update(fid, body): + """ + Update a folder from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + return request('put', url, json=body) + + +def trash(fid): + """ + Soft-delete a folder from Plotly. (Can be undone with 'restore'). + + This action is recursively done on files inside the folder. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='trash') + return request('post', url) + + +def restore(fid): + """ + Restore a trashed folder from Plotly. See 'trash'. + + This action is recursively done on files inside the folder. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='restore') + return request('post', url) + + +def permanent_delete(fid): + """ + Permanently delete a trashed folder file from Plotly. See 'trash'. + + This action is recursively done on files inside the folder. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='permanent_delete') + return request('delete', url) + + +def lookup(path, parent=None, user=None, exists=None): + """ + Retrieve a folder file from Plotly without needing a fid. + + :param (str) path: The '/'-delimited path specifying the file location. + :param (int) parent: Parent id, an integer, which the path is relative to. + :param (str) user: The username to target files for. Defaults to requestor. + :param (bool) exists: If True, don't return the full file, just a flag. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, route='lookup') + params = make_params(path=path, parent=parent, user=user, exists=exists) + return request('get', url, params=params) diff --git a/plotly/api/v2/grids.py b/plotly/api/v2/grids.py new file mode 100644 index 00000000000..144ec3bd23f --- /dev/null +++ b/plotly/api/v2/grids.py @@ -0,0 +1,180 @@ +"""Interface to Plotly's /v2/grids endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, make_params, request + +RESOURCE = 'grids' + + +def create(body): + """ + Create a new grid. + + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE) + return request('post', url, json=body) + + +def retrieve(fid, share_key=None): + """ + Retrieve a grid from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + params = make_params(share_key=share_key) + return request('get', url, params=params) + + +def content(fid, share_key=None): + """ + Retrieve full content for the grid (normal retrieve only yields preview) + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='content') + params = make_params(share_key=share_key) + return request('get', url, params=params) + + +def update(fid, body): + """ + Update a grid from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + return request('put', url, json=body) + + +def trash(fid): + """ + Soft-delete a grid from Plotly. (Can be undone with 'restore'). + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='trash') + return request('post', url) + + +def restore(fid): + """ + Restore a trashed grid from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='restore') + return request('post', url) + + +def permanent_delete(fid): + """ + Permanently delete a trashed grid file from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='permanent_delete') + return request('delete', url) + + +def lookup(path, parent=None, user=None, exists=None): + """ + Retrieve a grid file from Plotly without needing a fid. + + :param (str) path: The '/'-delimited path specifying the file location. + :param (int) parent: Parent id, an integer, which the path is relative to. + :param (str) user: The username to target files for. Defaults to requestor. + :param (bool) exists: If True, don't return the full file, just a flag. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, route='lookup') + params = make_params(path=path, parent=parent, user=user, exists=exists) + return request('get', url, params=params) + + +def col_create(fid, body): + """ + Create a new column (or columns) inside a grid. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='col') + return request('post', url, json=body) + + +def col_retrieve(fid, uid): + """ + Retrieve a column (or columns) from a grid. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) uid: A ','-concatenated string of column uids in the grid. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='col') + params = make_params(uid=uid) + return request('get', url, params=params) + + +def col_update(fid, uid, body): + """ + Update a column (or columns) from a grid. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) uid: A ','-concatenated string of column uids in the grid. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='col') + params = make_params(uid=uid) + return request('put', url, json=body, params=params) + + +def col_delete(fid, uid): + """ + Permanently delete a column (or columns) from a grid. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) uid: A ','-concatenated string of column uids in the grid. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='col') + params = make_params(uid=uid) + return request('delete', url, params=params) + + +def row(fid, body): + """ + Append rows to a grid. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='row') + return request('post', url, json=body) diff --git a/plotly/api/v2/images.py b/plotly/api/v2/images.py new file mode 100644 index 00000000000..4c9d1816081 --- /dev/null +++ b/plotly/api/v2/images.py @@ -0,0 +1,18 @@ +"""Interface to Plotly's /v2/images endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, request + +RESOURCE = 'images' + + +def create(body): + """ + Generate an image (which does not get saved on Plotly). + + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE) + return request('post', url, json=body) diff --git a/plotly/api/v2/plot_schema.py b/plotly/api/v2/plot_schema.py new file mode 100644 index 00000000000..4edbc0a707b --- /dev/null +++ b/plotly/api/v2/plot_schema.py @@ -0,0 +1,19 @@ +"""Interface to Plotly's /v2/plot-schema endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, make_params, request + +RESOURCE = 'plot-schema' + + +def retrieve(sha1, **kwargs): + """ + Retrieve the most up-to-date copy of the plot-schema wrt the given hash. + + :param (str) sha1: The last-known hash of the plot-schema (or ''). + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE) + params = make_params(sha1=sha1) + return request('get', url, params=params, **kwargs) diff --git a/plotly/api/v2/plots.py b/plotly/api/v2/plots.py new file mode 100644 index 00000000000..da9f2d9e395 --- /dev/null +++ b/plotly/api/v2/plots.py @@ -0,0 +1,119 @@ +"""Interface to Plotly's /v2/plots endpoints.""" +from __future__ import absolute_import + +from plotly.api.v2.utils import build_url, make_params, request + +RESOURCE = 'plots' + + +def create(body): + """ + Create a new plot. + + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE) + return request('post', url, json=body) + + +def retrieve(fid, share_key=None): + """ + Retrieve a plot from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + params = make_params(share_key=share_key) + return request('get', url, params=params) + + +def content(fid, share_key=None, inline_data=None, map_data=None): + """ + Retrieve the *figure* for a Plotly plot file. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (str) share_key: The secret key granting 'read' access if private. + :param (bool) inline_data: If True, include the data arrays with the plot. + :param (str) map_data: Currently only accepts 'unreadable' to return a + mapping of grid-fid: grid. This is useful if you + want to maintain structure between the plot and + referenced grids when you have READ access to the + plot, but you don't have READ access to the + underlying grids. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='content') + params = make_params(share_key=share_key, inline_data=inline_data, + map_data=map_data) + return request('get', url, params=params) + + +def update(fid, body): + """ + Update a plot from Plotly. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :param (dict) body: A mapping of body param names to values. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid) + return request('put', url, json=body) + + +def trash(fid): + """ + Soft-delete a plot from Plotly. (Can be undone with 'restore'). + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='trash') + return request('post', url) + + +def restore(fid): + """ + Restore a trashed plot from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='restore') + return request('post', url) + + +def permanent_delete(fid, params=None): + """ + Permanently delete a trashed plot file from Plotly. See 'trash'. + + :param (str) fid: The `{username}:{idlocal}` identifier. E.g. `foo:88`. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, id=fid, route='permanent_delete') + return request('delete', url, params=params) + + +def lookup(path, parent=None, user=None, exists=None): + """ + Retrieve a plot file from Plotly without needing a fid. + + :param (str) path: The '/'-delimited path specifying the file location. + :param (int) parent: Parent id, an integer, which the path is relative to. + :param (str) user: The username to target files for. Defaults to requestor. + :param (bool) exists: If True, don't return the full file, just a flag. + :returns: (requests.Response) Returns response directly from requests. + + """ + url = build_url(RESOURCE, route='lookup') + params = make_params(path=path, parent=parent, user=user, exists=exists) + return request('get', url, params=params) diff --git a/plotly/api/v2/utils.py b/plotly/api/v2/utils.py new file mode 100644 index 00000000000..21bd0ddd016 --- /dev/null +++ b/plotly/api/v2/utils.py @@ -0,0 +1,154 @@ +from __future__ import absolute_import + +import requests +from requests.compat import json as _json +from requests.exceptions import RequestException + +from plotly import config, exceptions, version, utils +from plotly.api.utils import basic_auth + + +def make_params(**kwargs): + """ + Helper to create a params dict, skipping undefined entries. + + :returns: (dict) A params dict to pass to `request`. + + """ + return {k: v for k, v in kwargs.items() if v is not None} + + +def build_url(resource, id='', route=''): + """ + Create a url for a request on a V2 resource. + + :param (str) resource: E.g., 'files', 'plots', 'grids', etc. + :param (str) id: The unique identifier for the resource. + :param (str) route: Detail/list route. E.g., 'restore', 'lookup', etc. + :return: (str) The url. + + """ + base = config.get_config()['plotly_api_domain'] + formatter = {'base': base, 'resource': resource, 'id': id, 'route': route} + + # Add path to base url depending on the input params. Note that `route` + # can refer to a 'list' or a 'detail' route. Since it cannot refer to + # both at the same time, it's overloaded in this function. + if id: + if route: + url = '{base}/v2/{resource}/{id}/{route}'.format(**formatter) + else: + url = '{base}/v2/{resource}/{id}'.format(**formatter) + else: + if route: + url = '{base}/v2/{resource}/{route}'.format(**formatter) + else: + url = '{base}/v2/{resource}'.format(**formatter) + + return url + + +def validate_response(response): + """ + Raise a helpful PlotlyRequestError for failed requests. + + :param (requests.Response) response: A Response object from an api request. + :raises: (PlotlyRequestError) If the request failed for any reason. + :returns: (None) + + """ + if response.ok: + return + + content = response.content + status_code = response.status_code + try: + parsed_content = response.json() + except ValueError: + message = content if content else 'No Content' + raise exceptions.PlotlyRequestError(message, status_code, content) + + message = '' + if isinstance(parsed_content, dict): + errors = parsed_content.get('errors', []) + messages = [error.get('message') for error in errors] + message = '\n'.join([msg for msg in messages if msg]) + if not message: + message = content if content else 'No Content' + + raise exceptions.PlotlyRequestError(message, status_code, content) + + +def get_headers(): + """ + Using session credentials/config, get headers for a V2 API request. + + Users may have their own proxy layer and so we free up the `authorization` + header for this purpose (instead adding the user authorization in a new + `plotly-authorization` header). See pull #239. + + :returns: (dict) Headers to add to a requests.request call. + + """ + creds = config.get_credentials() + + headers = { + 'plotly-client-platform': 'python {}'.format(version.__version__), + 'content-type': 'application/json' + } + + plotly_auth = basic_auth(creds['username'], creds['api_key']) + proxy_auth = basic_auth(creds['proxy_username'], creds['proxy_password']) + + if config.get_config()['plotly_proxy_authorization']: + headers['authorization'] = proxy_auth + if creds['username'] and creds['api_key']: + headers['plotly-authorization'] = plotly_auth + else: + if creds['username'] and creds['api_key']: + headers['authorization'] = plotly_auth + + return headers + + +def request(method, url, **kwargs): + """ + Central place to make any api v2 api request. + + :param (str) method: The request method ('get', 'put', 'delete', ...). + :param (str) url: The full api url to make the request to. + :param kwargs: These are passed along (but possibly mutated) to requests. + :return: (requests.Response) The response directly from requests. + + """ + kwargs['headers'] = dict(kwargs.get('headers', {}), **get_headers()) + + # Change boolean params to lowercase strings. E.g., `True` --> `'true'`. + # Just change the value so that requests handles query string creation. + if isinstance(kwargs.get('params'), dict): + kwargs['params'] = kwargs['params'].copy() + for key in kwargs['params']: + if isinstance(kwargs['params'][key], bool): + kwargs['params'][key] = _json.dumps(kwargs['params'][key]) + + # We have a special json encoding class for non-native objects. + if kwargs.get('json') is not None: + if kwargs.get('data'): + raise exceptions.PlotlyError('Cannot supply data and json kwargs.') + kwargs['data'] = _json.dumps(kwargs.pop('json'), sort_keys=True, + cls=utils.PlotlyJSONEncoder) + + # The config file determines whether reuqests should *verify*. + kwargs['verify'] = config.get_config()['plotly_ssl_verification'] + + try: + response = requests.request(method, url, **kwargs) + except RequestException as e: + # The message can be an exception. E.g., MaxRetryError. + message = str(getattr(e, 'message', 'No message')) + response = getattr(e, 'response', None) + status_code = response.status_code if response else None + content = response.content if response else 'No content' + raise exceptions.PlotlyRequestError(message, status_code, content) + validate_response(response) + return response diff --git a/plotly/config.py b/plotly/config.py new file mode 100644 index 00000000000..dc1b8e28654 --- /dev/null +++ b/plotly/config.py @@ -0,0 +1,35 @@ +""" +Merges and prioritizes file/session config and credentials. + +This is promoted to its own module to simplify imports. + +""" +from __future__ import absolute_import + +from plotly import session, tools + + +def get_credentials(): + """Returns the credentials that will be sent to plotly.""" + credentials = tools.get_credentials_file() + session_credentials = session.get_session_credentials() + for credentials_key in credentials: + + # checking for not false, but truthy value here is the desired behavior + session_value = session_credentials.get(credentials_key) + if session_value is False or session_value: + credentials[credentials_key] = session_value + return credentials + + +def get_config(): + """Returns either module config or file config.""" + config = tools.get_config_file() + session_config = session.get_session_config() + for config_key in config: + + # checking for not false, but truthy value here is the desired behavior + session_value = session_config.get(config_key) + if session_value is False or session_value: + config[config_key] = session_value + return config diff --git a/plotly/exceptions.py b/plotly/exceptions.py index 8f7c8920454..05df864497f 100644 --- a/plotly/exceptions.py +++ b/plotly/exceptions.py @@ -5,7 +5,9 @@ A module that contains plotly's exception hierarchy. """ -import json +from __future__ import absolute_import + +from plotly.api.utils import to_native_utf8_string # Base Plotly Error @@ -18,29 +20,12 @@ class InputError(PlotlyError): class PlotlyRequestError(PlotlyError): - def __init__(self, requests_exception): - self.status_code = requests_exception.response.status_code - self.HTTPError = requests_exception - content_type = requests_exception.response.headers['content-type'] - if 'json' in content_type: - content = requests_exception.response.content - if content != '': - res_payload = json.loads( - requests_exception.response.content.decode('utf8') - ) - if 'detail' in res_payload: - self.message = res_payload['detail'] - else: - self.message = '' - else: - self.message = '' - elif content_type == 'text/plain': - self.message = requests_exception.response.content - else: - try: - self.message = requests_exception.message - except AttributeError: - self.message = 'unknown error' + """General API error. Raised for *all* failed requests.""" + + def __init__(self, message, status_code, content): + self.message = to_native_utf8_string(message) + self.status_code = status_code + self.content = content def __str__(self): return self.message diff --git a/plotly/graph_reference.py b/plotly/graph_reference.py index 1e8eccb0213..0a2cbd1b50d 100644 --- a/plotly/graph_reference.py +++ b/plotly/graph_reference.py @@ -5,17 +5,16 @@ from __future__ import absolute_import import hashlib -import json import os import re from pkg_resources import resource_string -import requests import six +from requests.compat import json as _json -from plotly import files, utils +from plotly import exceptions, files, utils +from plotly.api import v2 -GRAPH_REFERENCE_PATH = '/v2/plot-schema' GRAPH_REFERENCE_DOWNLOAD_TIMEOUT = 5 # seconds @@ -72,37 +71,24 @@ def get_graph_reference(): :raises: (PlotlyError) When graph reference DNE and GET request fails. """ - default_config = files.FILE_CONTENT[files.CONFIG_FILE] if files.check_file_permissions(): graph_reference = utils.load_json_dict(files.GRAPH_REFERENCE_FILE) - config = utils.load_json_dict(files.CONFIG_FILE) - - # TODO: https://github.com/plotly/python-api/issues/293 - plotly_api_domain = config.get('plotly_api_domain', - default_config['plotly_api_domain']) else: graph_reference = {} - plotly_api_domain = default_config['plotly_api_domain'] sha1 = hashlib.sha1(six.b(str(graph_reference))).hexdigest() - graph_reference_url = '{}{}?sha1={}'.format(plotly_api_domain, - GRAPH_REFERENCE_PATH, sha1) try: - response = requests.get(graph_reference_url, - timeout=GRAPH_REFERENCE_DOWNLOAD_TIMEOUT) - response.raise_for_status() - except requests.exceptions.RequestException: + response = v2.plot_schema.retrieve( + sha1, timeout=GRAPH_REFERENCE_DOWNLOAD_TIMEOUT + ) + except exceptions.PlotlyRequestError: if not graph_reference: path = os.path.join('graph_reference', 'default-schema.json') s = resource_string('plotly', path).decode('utf-8') - graph_reference = json.loads(s) + graph_reference = _json.loads(s) else: - if six.PY3: - content = str(response.content, encoding='utf-8') - else: - content = response.content - data = json.loads(content) + data = response.json() if data['modified']: graph_reference = data['schema'] diff --git a/plotly/graph_reference/default-schema.json b/plotly/graph_reference/default-schema.json index a3f62136af0..4b0a3870b97 100644 --- a/plotly/graph_reference/default-schema.json +++ b/plotly/graph_reference/default-schema.json @@ -565,6 +565,30 @@ "role": "info", "valType": "boolean" }, + "calendar": { + "description": "Sets the default calendar system to use for interpreting and displaying dates throughout the plot.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "direction": { "description": "For polar plots only. Sets the direction corresponding to positive angles.", "role": "info", @@ -1815,6 +1839,30 @@ "role": "style", "valType": "color" }, + "calendar": { + "description": "Sets the calendar system to use for `range` and `tick0` if this is a date axis. This does not set the calendar for interpreting data on this axis, that's specified in the trace or via the global `layout.calendar`", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "categoryarray": { "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", "role": "data", @@ -2224,6 +2272,30 @@ "role": "style", "valType": "color" }, + "calendar": { + "description": "Sets the calendar system to use for `range` and `tick0` if this is a date axis. This does not set the calendar for interpreting data on this axis, that's specified in the trace or via the global `layout.calendar`", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "categoryarray": { "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", "role": "data", @@ -2633,6 +2705,30 @@ "role": "style", "valType": "color" }, + "calendar": { + "description": "Sets the calendar system to use for `range` and `tick0` if this is a date axis. This does not set the calendar for interpreting data on this axis, that's specified in the trace or via the global `layout.calendar`", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "categoryarray": { "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", "role": "data", @@ -4626,6 +4722,30 @@ "reversed" ] }, + "calendar": { + "description": "Sets the calendar system to use for `range` and `tick0` if this is a date axis. This does not set the calendar for interpreting data on this axis, that's specified in the trace or via the global `layout.calendar`", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "categoryarray": { "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", "role": "data", @@ -4852,7 +4972,7 @@ ] }, "stepmode": { - "description": "Sets the range update mode. If *backward*, the range update shifts the start of range back *count* times *step* milliseconds. If *todate*, the range update shifts the start of range back to the first timestamp from *count* times *step* milliseconds back. For example, with `step` set to *year* and `count` set to *1* the range update shifts the start of the range back to January 01 of the current year.", + "description": "Sets the range update mode. If *backward*, the range update shifts the start of range back *count* times *step* milliseconds. If *todate*, the range update shifts the start of range back to the first timestamp from *count* times *step* milliseconds back. For example, with `step` set to *year* and `count` set to *1* the range update shifts the start of the range back to January 01 of the current year. Month and year *todate* are currently available only for the built-in (Gregorian) calendar.", "dflt": "backward", "role": "info", "valType": "enumerated", @@ -5245,6 +5365,30 @@ "reversed" ] }, + "calendar": { + "description": "Sets the calendar system to use for `range` and `tick0` if this is a date axis. This does not set the calendar for interpreting data on this axis, that's specified in the trace or via the global `layout.calendar`", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "categoryarray": { "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", "role": "data", @@ -6354,6 +6498,44 @@ "role": "info", "valType": "flaglist" }, + "insidetextfont": { + "color": { + "arrayOk": true, + "role": "style", + "valType": "color" + }, + "colorsrc": { + "description": "Sets the source reference on plot.ly for color .", + "role": "info", + "valType": "string" + }, + "description": "Sets the font used for `text` lying inside the bar.", + "family": { + "arrayOk": true, + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser will only be able to apply a font if it is available on the system which it operates. Provide multiple font families, separated by commas, to indicate the preference in which to apply fonts if they aren't available on the system. The plotly service (at https://plot.ly or on-premise) generates images on a server, where only a select number of fonts are installed and supported. These include *Arial*, *Balto*, *Courier New*, *Droid Sans*,, *Droid Serif*, *Droid Sans Mono*, *Gravitas One*, *Old Standard TT*, *Open Sans*, *Overpass*, *PT Sans Narrow*, *Raleway*, *Times New Roman*.", + "noBlank": true, + "role": "style", + "strict": true, + "valType": "string" + }, + "familysrc": { + "description": "Sets the source reference on plot.ly for family .", + "role": "info", + "valType": "string" + }, + "role": "object", + "size": { + "arrayOk": true, + "min": 1, + "role": "style", + "valType": "number" + }, + "sizesrc": { + "description": "Sets the source reference on plot.ly for size .", + "role": "info", + "valType": "string" + } + }, "legendgroup": { "description": "Sets the legend group for this trace. Traces part of the same legend group hide/show at the same time when toggling legend items.", "dflt": "", @@ -6853,6 +7035,44 @@ "h" ] }, + "outsidetextfont": { + "color": { + "arrayOk": true, + "role": "style", + "valType": "color" + }, + "colorsrc": { + "description": "Sets the source reference on plot.ly for color .", + "role": "info", + "valType": "string" + }, + "description": "Sets the font used for `text` lying outside the bar.", + "family": { + "arrayOk": true, + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser will only be able to apply a font if it is available on the system which it operates. Provide multiple font families, separated by commas, to indicate the preference in which to apply fonts if they aren't available on the system. The plotly service (at https://plot.ly or on-premise) generates images on a server, where only a select number of fonts are installed and supported. These include *Arial*, *Balto*, *Courier New*, *Droid Sans*,, *Droid Serif*, *Droid Sans Mono*, *Gravitas One*, *Old Standard TT*, *Open Sans*, *Overpass*, *PT Sans Narrow*, *Raleway*, *Times New Roman*.", + "noBlank": true, + "role": "style", + "strict": true, + "valType": "string" + }, + "familysrc": { + "description": "Sets the source reference on plot.ly for family .", + "role": "info", + "valType": "string" + }, + "role": "object", + "size": { + "arrayOk": true, + "min": 1, + "role": "style", + "valType": "number" + }, + "sizesrc": { + "description": "Sets the source reference on plot.ly for size .", + "role": "info", + "valType": "string" + } + }, "r": { "description": "For polar chart only.Sets the radial coordinates.", "role": "data", @@ -6899,6 +7119,62 @@ "role": "info", "valType": "string" }, + "textfont": { + "color": { + "arrayOk": true, + "role": "style", + "valType": "color" + }, + "colorsrc": { + "description": "Sets the source reference on plot.ly for color .", + "role": "info", + "valType": "string" + }, + "description": "Sets the font used for `text`.", + "family": { + "arrayOk": true, + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser will only be able to apply a font if it is available on the system which it operates. Provide multiple font families, separated by commas, to indicate the preference in which to apply fonts if they aren't available on the system. The plotly service (at https://plot.ly or on-premise) generates images on a server, where only a select number of fonts are installed and supported. These include *Arial*, *Balto*, *Courier New*, *Droid Sans*,, *Droid Serif*, *Droid Sans Mono*, *Gravitas One*, *Old Standard TT*, *Open Sans*, *Overpass*, *PT Sans Narrow*, *Raleway*, *Times New Roman*.", + "noBlank": true, + "role": "style", + "strict": true, + "valType": "string" + }, + "familysrc": { + "description": "Sets the source reference on plot.ly for family .", + "role": "info", + "valType": "string" + }, + "role": "object", + "size": { + "arrayOk": true, + "min": 1, + "role": "style", + "valType": "number" + }, + "sizesrc": { + "description": "Sets the source reference on plot.ly for size .", + "role": "info", + "valType": "string" + } + }, + "textposition": { + "arrayOk": true, + "description": "Specifies the location of the `text`. *inside* positions `text` inside, next to the bar end (rotated and scaled if needed). *outside* positions `text` outside, next to the bar end (scaled if needed). *auto* positions `text` inside or outside so that `text` size is maximized.", + "dflt": "none", + "role": "info", + "valType": "enumerated", + "values": [ + "inside", + "outside", + "auto", + "none" + ] + }, + "textpositionsrc": { + "description": "Sets the source reference on plot.ly for textposition .", + "role": "info", + "valType": "string" + }, "textsrc": { "description": "Sets the source reference on plot.ly for text .", "role": "info", @@ -6956,6 +7232,30 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -6978,6 +7278,30 @@ "role": "info", "valType": "subplotid" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -7847,6 +8171,30 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -8806,13 +9154,678 @@ "role": "style", "valType": "number" }, - "start": { - "description": "Sets the starting contour level value.", - "dflt": null, + "start": { + "description": "Sets the starting contour level value.", + "dflt": null, + "role": "style", + "valType": "number" + } + }, + "dx": { + "description": "Sets the x coordinate step. See `x0` for more info.", + "dflt": 1, + "role": "info", + "valType": "number" + }, + "dy": { + "description": "Sets the y coordinate step. See `y0` for more info.", + "dflt": 1, + "role": "info", + "valType": "number" + }, + "hoverinfo": { + "description": "Determines which trace information appear on hover. If `none` or `skip` are set, no information is displayed upon hovering. But, if `none` is set, click and hover events are still fired.", + "dflt": "all", + "extras": [ + "all", + "none", + "skip" + ], + "flags": [ + "x", + "y", + "z", + "text", + "name" + ], + "role": "info", + "valType": "flaglist" + }, + "legendgroup": { + "description": "Sets the legend group for this trace. Traces part of the same legend group hide/show at the same time when toggling legend items.", + "dflt": "", + "role": "info", + "valType": "string" + }, + "line": { + "color": { + "description": "Sets the color of the contour level. Has no if `contours.coloring` is set to *lines*.", + "role": "style", + "valType": "color" + }, + "dash": { + "description": "Sets the style of the lines. Set to a dash string type or a dash length in px.", + "dflt": "solid", + "role": "style", + "valType": "string", + "values": [ + "solid", + "dot", + "dash", + "longdash", + "dashdot", + "longdashdot" + ] + }, + "role": "object", + "smoothing": { + "description": "Sets the amount of smoothing for the contour lines, where *0* corresponds to no smoothing.", + "dflt": 1, + "max": 1.3, + "min": 0, + "role": "style", + "valType": "number" + }, + "width": { + "description": "Sets the line width (in px).", + "dflt": 2, + "min": 0, + "role": "style", + "valType": "number" + } + }, + "name": { + "description": "Sets the trace name. The trace name appear as the legend item and on hover.", + "role": "info", + "valType": "string" + }, + "ncontours": { + "description": "Sets the maximum number of contour levels. The actual number of contours will be chosen automatically to be less than or equal to the value of `ncontours`. Has an effect only if `autocontour` is *true*.", + "dflt": 0, + "role": "style", + "valType": "integer" + }, + "opacity": { + "description": "Sets the opacity of the trace.", + "dflt": 1, + "max": 1, + "min": 0, + "role": "style", + "valType": "number" + }, + "reversescale": { + "description": "Reverses the colorscale.", + "dflt": false, + "role": "style", + "valType": "boolean" + }, + "showlegend": { + "description": "Determines whether or not an item corresponding to this trace is shown in the legend.", + "dflt": true, + "role": "info", + "valType": "boolean" + }, + "showscale": { + "description": "Determines whether or not a colorbar is displayed for this trace.", + "dflt": true, + "role": "info", + "valType": "boolean" + }, + "stream": { + "maxpoints": { + "description": "Sets the maximum number of points to keep on the plots from an incoming stream. If `maxpoints` is set to *50*, only the newest 50 points will be displayed on the plot.", + "dflt": 500, + "max": 10000, + "min": 0, + "role": "info", + "valType": "number" + }, + "role": "object", + "token": { + "description": "The stream id number links a data trace on a plot with a stream. See https://plot.ly/settings for more details.", + "noBlank": true, + "role": "info", + "strict": true, + "valType": "string" + } + }, + "text": { + "description": "Sets the text elements associated with each z value.", + "role": "data", + "valType": "data_array" + }, + "textsrc": { + "description": "Sets the source reference on plot.ly for text .", + "role": "info", + "valType": "string" + }, + "transpose": { + "description": "Transposes the z data.", + "dflt": false, + "role": "info", + "valType": "boolean" + }, + "type": "contour", + "uid": { + "dflt": "", + "role": "info", + "valType": "string" + }, + "visible": { + "description": "Determines whether or not this trace is visible. If *legendonly*, the trace is not drawn, but can appear as a legend item (provided that the legend itself is visible).", + "dflt": true, + "role": "info", + "valType": "enumerated", + "values": [ + true, + false, + "legendonly" + ] + }, + "x": { + "description": "Sets the x coordinates.", + "role": "data", + "valType": "data_array" + }, + "x0": { + "description": "Alternate to `x`. Builds a linear space of x coordinates. Use with `dx` where `x0` is the starting coordinate and `dx` the step.", + "dflt": 0, + "role": "info", + "valType": "any" + }, + "xaxis": { + "description": "Sets a reference between this trace's x coordinates and a 2D cartesian x axis. If *x* (the default value), the x coordinates refer to `layout.xaxis`. If *x2*, the x coordinates refer to `layout.xaxis2`, and so on.", + "dflt": "x", + "role": "info", + "valType": "subplotid" + }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, + "xsrc": { + "description": "Sets the source reference on plot.ly for x .", + "role": "info", + "valType": "string" + }, + "xtype": { + "description": "If *array*, the heatmap's x coordinates are given by *x* (the default behavior when `x` is provided). If *scaled*, the heatmap's x coordinates are given by *x0* and *dx* (the default behavior when `x` is not provided).", + "role": "info", + "valType": "enumerated", + "values": [ + "array", + "scaled" + ] + }, + "y": { + "description": "Sets the y coordinates.", + "role": "data", + "valType": "data_array" + }, + "y0": { + "description": "Alternate to `y`. Builds a linear space of y coordinates. Use with `dy` where `y0` is the starting coordinate and `dy` the step.", + "dflt": 0, + "role": "info", + "valType": "any" + }, + "yaxis": { + "description": "Sets a reference between this trace's y coordinates and a 2D cartesian y axis. If *y* (the default value), the y coordinates refer to `layout.yaxis`. If *y2*, the y coordinates refer to `layout.xaxis2`, and so on.", + "dflt": "y", + "role": "info", + "valType": "subplotid" + }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, + "ysrc": { + "description": "Sets the source reference on plot.ly for y .", + "role": "info", + "valType": "string" + }, + "ytype": { + "description": "If *array*, the heatmap's y coordinates are given by *y* (the default behavior when `y` is provided) If *scaled*, the heatmap's y coordinates are given by *y0* and *dy* (the default behavior when `y` is not provided)", + "role": "info", + "valType": "enumerated", + "values": [ + "array", + "scaled" + ] + }, + "z": { + "description": "Sets the z data.", + "role": "data", + "valType": "data_array" + }, + "zauto": { + "description": "Determines the whether or not the color domain is computed with respect to the input data.", + "dflt": true, + "role": "info", + "valType": "boolean" + }, + "zmax": { + "description": "Sets the upper bound of color domain.", + "dflt": null, + "role": "info", + "valType": "number" + }, + "zmin": { + "description": "Sets the lower bound of color domain.", + "dflt": null, + "role": "info", + "valType": "number" + }, + "zsrc": { + "description": "Sets the source reference on plot.ly for z .", + "role": "info", + "valType": "string" + } + }, + "meta": { + "description": "The data from which contour lines are computed is set in `z`. Data in `z` must be a {2D array} of numbers. Say that `z` has N rows and M columns, then by default, these N rows correspond to N y coordinates (set in `y` or auto-generated) and the M columns correspond to M x coordinates (set in `x` or auto-generated). By setting `transpose` to *true*, the above behavior is flipped." + } + }, + "heatmap": { + "attributes": { + "autocolorscale": { + "description": "Determines whether or not the colorscale is picked using the sign of the input z values.", + "dflt": false, + "role": "style", + "valType": "boolean" + }, + "colorbar": { + "bgcolor": { + "description": "Sets the color of padded area.", + "dflt": "rgba(0,0,0,0)", + "role": "style", + "valType": "color" + }, + "bordercolor": { + "description": "Sets the axis line color.", + "dflt": "#444", + "role": "style", + "valType": "color" + }, + "borderwidth": { + "description": "Sets the width (in px) or the border enclosing this color bar.", + "dflt": 0, + "min": 0, + "role": "style", + "valType": "number" + }, + "dtick": { + "description": "Sets the step in-between ticks on this axis. Use with `tick0`. Must be a positive number, or special strings available to *log* and *date* axes. If the axis `type` is *log*, then ticks are set every 10^(n*dtick) where n is the tick number. For example, to set a tick mark at 1, 10, 100, 1000, ... set dtick to 1. To set tick marks at 1, 100, 10000, ... set dtick to 2. To set tick marks at 1, 5, 25, 125, 625, 3125, ... set dtick to log_10(5), or 0.69897000433. *log* has several special values; *L*, where `f` is a positive number, gives ticks linearly spaced in value (but not position). For example `tick0` = 0.1, `dtick` = *L0.5* will put ticks at 0.1, 0.6, 1.1, 1.6 etc. To show powers of 10 plus small digits between, use *D1* (all digits) or *D2* (only 2 and 5). `tick0` is ignored for *D1* and *D2*. If the axis `type` is *date*, then you must convert the time to milliseconds. For example, to set the interval between ticks to one day, set `dtick` to 86400000.0. *date* also has special values *M* gives ticks spaced by a number of months. `n` must be a positive integer. To set ticks on the 15th of every third month, set `tick0` to *2000-01-15* and `dtick` to *M3*. To set ticks every 4 years, set `dtick` to *M48*", + "role": "style", + "valType": "any" + }, + "exponentformat": { + "description": "Determines a formatting rule for the tick exponents. For example, consider the number 1,000,000,000. If *none*, it appears as 1,000,000,000. If *e*, 1e+9. If *E*, 1E+9. If *power*, 1x10^9 (with 9 in a super script). If *SI*, 1G. If *B*, 1B.", + "dflt": "B", + "role": "style", + "valType": "enumerated", + "values": [ + "none", + "e", + "E", + "power", + "SI", + "B" + ] + }, + "len": { + "description": "Sets the length of the color bar This measure excludes the padding of both ends. That is, the color bar length is this length minus the padding on both ends.", + "dflt": 1, + "min": 0, + "role": "style", + "valType": "number" + }, + "lenmode": { + "description": "Determines whether this color bar's length (i.e. the measure in the color variation direction) is set in units of plot *fraction* or in *pixels. Use `len` to set the value.", + "dflt": "fraction", + "role": "info", + "valType": "enumerated", + "values": [ + "fraction", + "pixels" + ] + }, + "nticks": { + "description": "Specifies the maximum number of ticks for the particular axis. The actual number of ticks will be chosen automatically to be less than or equal to `nticks`. Has an effect only if `tickmode` is set to *auto*.", + "dflt": 0, + "min": 0, + "role": "style", + "valType": "integer" + }, + "outlinecolor": { + "description": "Sets the axis line color.", + "dflt": "#444", + "role": "style", + "valType": "color" + }, + "outlinewidth": { + "description": "Sets the width (in px) of the axis line.", + "dflt": 1, + "min": 0, + "role": "style", + "valType": "number" + }, + "role": "object", + "separatethousands": { + "description": "If \"true\", even 4-digit integers are separated", + "dflt": false, + "role": "style", + "valType": "boolean" + }, + "showexponent": { + "description": "If *all*, all exponents are shown besides their significands. If *first*, only the exponent of the first tick is shown. If *last*, only the exponent of the last tick is shown. If *none*, no exponents appear.", + "dflt": "all", + "role": "style", + "valType": "enumerated", + "values": [ + "all", + "first", + "last", + "none" + ] + }, + "showticklabels": { + "description": "Determines whether or not the tick labels are drawn.", + "dflt": true, + "role": "style", + "valType": "boolean" + }, + "showtickprefix": { + "description": "If *all*, all tick labels are displayed with a prefix. If *first*, only the first tick is displayed with a prefix. If *last*, only the last tick is displayed with a suffix. If *none*, tick prefixes are hidden.", + "dflt": "all", + "role": "style", + "valType": "enumerated", + "values": [ + "all", + "first", + "last", + "none" + ] + }, + "showticksuffix": { + "description": "Same as `showtickprefix` but for tick suffixes.", + "dflt": "all", + "role": "style", + "valType": "enumerated", + "values": [ + "all", + "first", + "last", + "none" + ] + }, + "thickness": { + "description": "Sets the thickness of the color bar This measure excludes the size of the padding, ticks and labels.", + "dflt": 30, + "min": 0, + "role": "style", + "valType": "number" + }, + "thicknessmode": { + "description": "Determines whether this color bar's thickness (i.e. the measure in the constant color direction) is set in units of plot *fraction* or in *pixels*. Use `thickness` to set the value.", + "dflt": "pixels", + "role": "style", + "valType": "enumerated", + "values": [ + "fraction", + "pixels" + ] + }, + "tick0": { + "description": "Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears.", + "role": "style", + "valType": "any" + }, + "tickangle": { + "description": "Sets the angle of the tick labels with respect to the horizontal. For example, a `tickangle` of -90 draws the tick labels vertically.", + "dflt": "auto", + "role": "style", + "valType": "angle" + }, + "tickcolor": { + "description": "Sets the tick color.", + "dflt": "#444", + "role": "style", + "valType": "color" + }, + "tickfont": { + "color": { + "role": "style", + "valType": "color" + }, + "description": "Sets the tick font.", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser will only be able to apply a font if it is available on the system which it operates. Provide multiple font families, separated by commas, to indicate the preference in which to apply fonts if they aren't available on the system. The plotly service (at https://plot.ly or on-premise) generates images on a server, where only a select number of fonts are installed and supported. These include *Arial*, *Balto*, *Courier New*, *Droid Sans*,, *Droid Serif*, *Droid Sans Mono*, *Gravitas One*, *Old Standard TT*, *Open Sans*, *Overpass*, *PT Sans Narrow*, *Raleway*, *Times New Roman*.", + "noBlank": true, + "role": "style", + "strict": true, + "valType": "string" + }, + "role": "object", + "size": { + "min": 1, + "role": "style", + "valType": "number" + } + }, + "tickformat": { + "description": "Sets the tick label formatting rule using d3 formatting mini-languages which are very similar to those in Python. For numbers, see: https://github.com/d3/d3-format/blob/master/README.md#locale_format And for dates see: https://github.com/d3/d3-time-format/blob/master/README.md#locale_format We add one item to d3's date formatter: *%{n}f* for fractional seconds with n digits. For example, *2016-10-13 09:15:23.456* with tickformat *%H~%M~%S.%2f* would display *09~15~23.46*", + "dflt": "", + "role": "style", + "valType": "string" + }, + "ticklen": { + "description": "Sets the tick length (in px).", + "dflt": 5, + "min": 0, + "role": "style", + "valType": "number" + }, + "tickmode": { + "description": "Sets the tick mode for this axis. If *auto*, the number of ticks is set via `nticks`. If *linear*, the placement of the ticks is determined by a starting position `tick0` and a tick step `dtick` (*linear* is the default value if `tick0` and `dtick` are provided). If *array*, the placement of the ticks is set via `tickvals` and the tick text is `ticktext`. (*array* is the default value if `tickvals` is provided).", + "role": "info", + "valType": "enumerated", + "values": [ + "auto", + "linear", + "array" + ] + }, + "tickprefix": { + "description": "Sets a tick label prefix.", + "dflt": "", + "role": "style", + "valType": "string" + }, + "ticks": { + "description": "Determines whether ticks are drawn or not. If **, this axis' ticks are not drawn. If *outside* (*inside*), this axis' are drawn outside (inside) the axis lines.", + "dflt": "", + "role": "style", + "valType": "enumerated", + "values": [ + "outside", + "inside", + "" + ] + }, + "ticksuffix": { + "description": "Sets a tick label suffix.", + "dflt": "", + "role": "style", + "valType": "string" + }, + "ticktext": { + "description": "Sets the text displayed at the ticks position via `tickvals`. Only has an effect if `tickmode` is set to *array*. Used with `tickvals`.", + "role": "data", + "valType": "data_array" + }, + "ticktextsrc": { + "description": "Sets the source reference on plot.ly for ticktext .", + "role": "info", + "valType": "string" + }, + "tickvals": { + "description": "Sets the values at which ticks on this axis appear. Only has an effect if `tickmode` is set to *array*. Used with `ticktext`.", + "role": "data", + "valType": "data_array" + }, + "tickvalssrc": { + "description": "Sets the source reference on plot.ly for tickvals .", + "role": "info", + "valType": "string" + }, + "tickwidth": { + "description": "Sets the tick width (in px).", + "dflt": 1, + "min": 0, + "role": "style", + "valType": "number" + }, + "title": { + "description": "Sets the title of the color bar.", + "dflt": "Click to enter colorscale title", + "role": "info", + "valType": "string" + }, + "titlefont": { + "color": { + "role": "style", + "valType": "color" + }, + "description": "Sets this color bar's title font.", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser will only be able to apply a font if it is available on the system which it operates. Provide multiple font families, separated by commas, to indicate the preference in which to apply fonts if they aren't available on the system. The plotly service (at https://plot.ly or on-premise) generates images on a server, where only a select number of fonts are installed and supported. These include *Arial*, *Balto*, *Courier New*, *Droid Sans*,, *Droid Serif*, *Droid Sans Mono*, *Gravitas One*, *Old Standard TT*, *Open Sans*, *Overpass*, *PT Sans Narrow*, *Raleway*, *Times New Roman*.", + "noBlank": true, + "role": "style", + "strict": true, + "valType": "string" + }, + "role": "object", + "size": { + "min": 1, + "role": "style", + "valType": "number" + } + }, + "titleside": { + "description": "Determines the location of the colorbar title with respect to the color bar.", + "dflt": "top", + "role": "style", + "valType": "enumerated", + "values": [ + "right", + "top", + "bottom" + ] + }, + "x": { + "description": "Sets the x position of the color bar (in plot fraction).", + "dflt": 1.02, + "max": 3, + "min": -2, + "role": "style", + "valType": "number" + }, + "xanchor": { + "description": "Sets this color bar's horizontal position anchor. This anchor binds the `x` position to the *left*, *center* or *right* of the color bar.", + "dflt": "left", + "role": "style", + "valType": "enumerated", + "values": [ + "left", + "center", + "right" + ] + }, + "xpad": { + "description": "Sets the amount of padding (in px) along the x direction.", + "dflt": 10, + "min": 0, + "role": "style", + "valType": "number" + }, + "y": { + "description": "Sets the y position of the color bar (in plot fraction).", + "dflt": 0.5, + "max": 3, + "min": -2, + "role": "style", + "valType": "number" + }, + "yanchor": { + "description": "Sets this color bar's vertical position anchor This anchor binds the `y` position to the *top*, *middle* or *bottom* of the color bar.", + "dflt": "middle", + "role": "style", + "valType": "enumerated", + "values": [ + "top", + "middle", + "bottom" + ] + }, + "ypad": { + "description": "Sets the amount of padding (in px) along the y direction.", + "dflt": 10, + "min": 0, "role": "style", "valType": "number" } }, + "colorscale": { + "description": "Sets the colorscale. The colorscale must be an array containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named color string. At minimum, a mapping for the lowest (0) and highest (1) values are required. For example, `[[0, 'rgb(0,0,255)', [1, 'rgb(255,0,0)']]`. To control the bounds of the colorscale in z space, use zmin and zmax", + "role": "style", + "valType": "colorscale" + }, + "connectgaps": { + "description": "Determines whether or not gaps (i.e. {nan} or missing values) in the `z` data are filled in.", + "dflt": false, + "role": "info", + "valType": "boolean" + }, "dx": { "description": "Sets the x coordinate step. See `x0` for more info.", "dflt": 1, @@ -8849,54 +9862,11 @@ "role": "info", "valType": "string" }, - "line": { - "color": { - "description": "Sets the color of the contour level. Has no if `contours.coloring` is set to *lines*.", - "role": "style", - "valType": "color" - }, - "dash": { - "description": "Sets the style of the lines. Set to a dash string type or a dash length in px.", - "dflt": "solid", - "role": "style", - "valType": "string", - "values": [ - "solid", - "dot", - "dash", - "longdash", - "dashdot", - "longdashdot" - ] - }, - "role": "object", - "smoothing": { - "description": "Sets the amount of smoothing for the contour lines, where *0* corresponds to no smoothing.", - "dflt": 1, - "max": 1.3, - "min": 0, - "role": "style", - "valType": "number" - }, - "width": { - "description": "Sets the line width (in px).", - "dflt": 2, - "min": 0, - "role": "style", - "valType": "number" - } - }, "name": { "description": "Sets the trace name. The trace name appear as the legend item and on hover.", "role": "info", "valType": "string" }, - "ncontours": { - "description": "Sets the maximum number of contour levels. The actual number of contours will be chosen automatically to be less than or equal to the value of `ncontours`. Has an effect only if `autocontour` is *true*.", - "dflt": 0, - "role": "style", - "valType": "integer" - }, "opacity": { "description": "Sets the opacity of the trace.", "dflt": 1, @@ -8957,7 +9927,7 @@ "role": "info", "valType": "boolean" }, - "type": "contour", + "type": "heatmap", "uid": { "dflt": "", "role": "info", @@ -8991,6 +9961,37 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, + "xgap": { + "description": "Sets the horizontal gap (in pixels) between bricks.", + "dflt": 0, + "min": 0, + "role": "style", + "valType": "number" + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -9022,6 +10023,37 @@ "role": "info", "valType": "subplotid" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, + "ygap": { + "description": "Sets the vertical gap (in pixels) between bricks.", + "dflt": 0, + "min": 0, + "role": "style", + "valType": "number" + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -9059,6 +10091,17 @@ "role": "info", "valType": "number" }, + "zsmooth": { + "description": "Picks a smoothing algorithm use to smooth `z` data.", + "dflt": false, + "role": "style", + "valType": "enumerated", + "values": [ + "fast", + "best", + false + ] + }, "zsrc": { "description": "Sets the source reference on plot.ly for z .", "role": "info", @@ -9066,10 +10109,10 @@ } }, "meta": { - "description": "The data from which contour lines are computed is set in `z`. Data in `z` must be a {2D array} of numbers. Say that `z` has N rows and M columns, then by default, these N rows correspond to N y coordinates (set in `y` or auto-generated) and the M columns correspond to M x coordinates (set in `x` or auto-generated). By setting `transpose` to *true*, the above behavior is flipped." + "description": "The data that describes the heatmap value-to-color mapping is set in `z`. Data in `z` can either be a {2D array} of values (ragged or not) or a 1D array of values. In the case where `z` is a {2D array}, say that `z` has N rows and M columns. Then, by default, the resulting heatmap will have N partitions along the y axis and M partitions along the x axis. In other words, the i-th row/ j-th column cell in `z` is mapped to the i-th partition of the y axis (starting from the bottom of the plot) and the j-th partition of the x-axis (starting from the left of the plot). This behavior can be flipped by using `transpose`. Moreover, `x` (`y`) can be provided with M or M+1 (N or N+1) elements. If M (N), then the coordinates correspond to the center of the heatmap cells and the cells have equal width. If M+1 (N+1), then the coordinates correspond to the edges of the heatmap cells. In the case where `z` is a 1D {array}, the x and y coordinates must be provided in `x` and `y` respectively to form data triplets." } }, - "heatmap": { + "heatmapgl": { "attributes": { "autocolorscale": { "description": "Determines whether or not the colorscale is picked using the sign of the input z values.", @@ -9424,12 +10467,6 @@ "role": "style", "valType": "colorscale" }, - "connectgaps": { - "description": "Determines whether or not gaps (i.e. {nan} or missing values) in the `z` data are filled in.", - "dflt": false, - "role": "info", - "valType": "boolean" - }, "dx": { "description": "Sets the x coordinate step. See `x0` for more info.", "dflt": 1, @@ -9531,7 +10568,7 @@ "role": "info", "valType": "boolean" }, - "type": "heatmap", + "type": "heatmapgl", "uid": { "dflt": "", "role": "info", @@ -9565,13 +10602,6 @@ "role": "info", "valType": "subplotid" }, - "xgap": { - "description": "Sets the horizontal gap (in pixels) between bricks.", - "dflt": 0, - "min": 0, - "role": "style", - "valType": "number" - }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -9603,13 +10633,6 @@ "role": "info", "valType": "subplotid" }, - "ygap": { - "description": "Sets the vertical gap (in pixels) between bricks.", - "dflt": 0, - "min": 0, - "role": "style", - "valType": "number" - }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -9647,17 +10670,6 @@ "role": "info", "valType": "number" }, - "zsmooth": { - "description": "Picks a smoothing algorithm use to smooth `z` data.", - "dflt": false, - "role": "style", - "valType": "enumerated", - "values": [ - "fast", - "best", - false - ] - }, "zsrc": { "description": "Sets the source reference on plot.ly for z .", "role": "info", @@ -9665,7 +10677,7 @@ } }, "meta": { - "description": "The data that describes the heatmap value-to-color mapping is set in `z`. Data in `z` can either be a {2D array} of values (ragged or not) or a 1D array of values. In the case where `z` is a {2D array}, say that `z` has N rows and M columns. Then, by default, the resulting heatmap will have N partitions along the y axis and M partitions along the x axis. In other words, the i-th row/ j-th column cell in `z` is mapped to the i-th partition of the y axis (starting from the bottom of the plot) and the j-th partition of the x-axis (starting from the left of the plot). This behavior can be flipped by using `transpose`. Moreover, `x` (`y`) can be provided with M or M+1 (N or N+1) elements. If M (N), then the coordinates correspond to the center of the heatmap cells and the cells have equal width. If M+1 (N+1), then the coordinates correspond to the edges of the heatmap cells. In the case where `z` is a 1D {array}, the x and y coordinates must be provided in `x` and `y` respectively to form data triplets." + "description": "WebGL version of the heatmap trace type." } }, "histogram": { @@ -10529,6 +11541,30 @@ "valType": "any" } }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -10566,6 +11602,30 @@ "valType": "any" } }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -11158,6 +12218,30 @@ "valType": "any" } }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xgap": { "description": "Sets the horizontal gap (in pixels) between bricks.", "dflt": 0, @@ -11202,6 +12286,30 @@ "valType": "any" } }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ygap": { "description": "Sets the vertical gap (in pixels) between bricks.", "dflt": 0, @@ -11889,6 +12997,30 @@ "valType": "any" } }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -11926,6 +13058,30 @@ "valType": "any" } }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -12612,6 +13768,30 @@ "role": "data", "valType": "data_array" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -12622,6 +13802,30 @@ "role": "data", "valType": "data_array" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -12632,6 +13836,30 @@ "role": "data", "valType": "data_array" }, + "zcalendar": { + "description": "Sets the calendar system to use with `z` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "zsrc": { "description": "Sets the source reference on plot.ly for z .", "role": "info", @@ -12909,6 +14137,30 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -14852,6 +16104,30 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -14874,6 +16150,30 @@ "role": "info", "valType": "subplotid" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -16066,6 +17366,30 @@ "role": "data", "valType": "data_array" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -16076,6 +17400,30 @@ "role": "data", "valType": "data_array" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -16086,6 +17434,30 @@ "role": "data", "valType": "data_array" }, + "zcalendar": { + "description": "Sets the calendar system to use with `z` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "zsrc": { "description": "Sets the source reference on plot.ly for z .", "role": "info", @@ -18090,6 +19462,30 @@ "role": "info", "valType": "subplotid" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -18112,6 +19508,30 @@ "role": "info", "valType": "subplotid" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -20710,6 +22130,30 @@ "role": "data", "valType": "data_array" }, + "xcalendar": { + "description": "Sets the calendar system to use with `x` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "xsrc": { "description": "Sets the source reference on plot.ly for x .", "role": "info", @@ -20720,6 +22164,30 @@ "role": "data", "valType": "data_array" }, + "ycalendar": { + "description": "Sets the calendar system to use with `y` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "ysrc": { "description": "Sets the source reference on plot.ly for y .", "role": "info", @@ -20730,6 +22198,30 @@ "role": "data", "valType": "data_array" }, + "zcalendar": { + "description": "Sets the calendar system to use with `z` date data.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "zsrc": { "description": "Sets the source reference on plot.ly for z .", "role": "info", @@ -20782,6 +22274,30 @@ "strict": true, "valType": "string" }, + "targetcalendar": { + "description": "Sets the calendar system to use for `target`, if it is an array of dates. If `target` is a string (eg *x*) we use the corresponding trace attribute (eg `xcalendar`) if it exists, even if `targetcalendar` is provided.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] + }, "targetsrc": { "description": "Sets the source reference on plot.ly for target .", "role": "info", @@ -20791,6 +22307,30 @@ "description": "Sets the value or values by which to filter by. Values are expected to be in the same type as the data linked to *target*. When `operation` is set to one of the inequality values (=,<,>=,>,<=) *value* is expected to be a number or a string. When `operation` is set to one of the interval value ([],(),[),(],][,)(,](,)[) *value* is expected to be 2-item array where the first item is the lower bound and the second item is the upper bound. When `operation`, is set to one of the set value ({},}{) *value* is expected to be an array with as many items as the desired set elements.", "dflt": 0, "valType": "any" + }, + "valuecalendar": { + "description": "Sets the calendar system to use for `value`, if it is a date.", + "dflt": "gregorian", + "role": "info", + "valType": "enumerated", + "values": [ + "gregorian", + "chinese", + "coptic", + "discworld", + "ethiopian", + "hebrew", + "islamic", + "julian", + "mayan", + "nanakshahi", + "nepali", + "persian", + "jalali", + "taiwan", + "thai", + "ummalqura" + ] } } }, diff --git a/plotly/grid_objs/grid_objs.py b/plotly/grid_objs/grid_objs.py index 782e3dc2fe0..128d3bf90a1 100644 --- a/plotly/grid_objs/grid_objs.py +++ b/plotly/grid_objs/grid_objs.py @@ -5,9 +5,10 @@ """ from __future__ import absolute_import -import json from collections import MutableSequence +from requests.compat import json as _json + from plotly import exceptions, utils __all__ = None @@ -66,7 +67,7 @@ def __init__(self, data, name): def __str__(self): max_chars = 10 - jdata = json.dumps(self.data, cls=utils.PlotlyJSONEncoder) + jdata = _json.dumps(self.data, cls=utils.PlotlyJSONEncoder) if len(jdata) > max_chars: data_string = jdata[:max_chars] + "...]" else: diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py index 1031240714a..446c118ea05 100644 --- a/plotly/offline/offline.py +++ b/plotly/offline/offline.py @@ -5,7 +5,6 @@ """ from __future__ import absolute_import -import json import os import uuid import warnings @@ -13,6 +12,8 @@ import time import webbrowser +from requests.compat import json as _json + import plotly from plotly import tools, utils from plotly.exceptions import PlotlyError @@ -183,10 +184,12 @@ def _plot_html(figure_or_data, config, validate, default_width, height = str(height) + 'px' plotdivid = uuid.uuid4() - jdata = json.dumps(figure.get('data', []), cls=utils.PlotlyJSONEncoder) - jlayout = json.dumps(figure.get('layout', {}), cls=utils.PlotlyJSONEncoder) + jdata = _json.dumps(figure.get('data', []), cls=utils.PlotlyJSONEncoder) + jlayout = _json.dumps(figure.get('layout', {}), + cls=utils.PlotlyJSONEncoder) if 'frames' in figure_or_data: - jframes = json.dumps(figure.get('frames', {}), cls=utils.PlotlyJSONEncoder) + jframes = _json.dumps(figure.get('frames', {}), + cls=utils.PlotlyJSONEncoder) configkeys = ( 'editable', @@ -211,7 +214,7 @@ def _plot_html(figure_or_data, config, validate, default_width, ) config_clean = dict((k, config[k]) for k in configkeys if k in config) - jconfig = json.dumps(config_clean) + jconfig = _json.dumps(config_clean) # TODO: The get_config 'source of truth' should # really be somewhere other than plotly.plotly diff --git a/plotly/plotly/plotly.py b/plotly/plotly/plotly.py index 756fc190000..272567f3a0f 100644 --- a/plotly/plotly/plotly.py +++ b/plotly/plotly/plotly.py @@ -16,25 +16,25 @@ """ from __future__ import absolute_import -import base64 import copy -import json import os import warnings -import requests import six import six.moves +from requests.compat import json as _json -from requests.auth import HTTPBasicAuth - -from plotly import exceptions, tools, utils, version, files +from plotly import exceptions, tools, utils, files +from plotly.api import v1, v2 +from plotly.graph_reference import GRAPH_REFERENCE from plotly.plotly import chunked_requests from plotly.session import (sign_in, update_session_plot_options, - get_session_plot_options, get_session_credentials, - get_session_config) + get_session_plot_options) from plotly.grid_objs import Grid, Column +# This is imported like this for backwards compat. Careful if changing. +from plotly.config import get_config, get_credentials + __all__ = None DEFAULT_PLOT_OPTIONS = { @@ -47,7 +47,7 @@ } # test file permissions and make sure nothing is corrupted -tools.ensure_local_plotly_files() +tools.ensure_local_plotly_files(graph_reference=GRAPH_REFERENCE) # don't break backwards compatibility @@ -55,32 +55,6 @@ update_plot_options = update_session_plot_options -def get_credentials(): - """Returns the credentials that will be sent to plotly.""" - credentials = tools.get_credentials_file() - session_credentials = get_session_credentials() - for credentials_key in credentials: - - # checking for not false, but truthy value here is the desired behavior - session_value = session_credentials.get(credentials_key) - if session_value is False or session_value: - credentials[credentials_key] = session_value - return credentials - - -def get_config(): - """Returns either module config or file config.""" - config = tools.get_config_file() - session_config = get_session_config() - for config_key in config: - - # checking for not false, but truthy value here is the desired behavior - session_value = session_config.get(config_key) - if session_value is False or session_value: - config[config_key] = session_value - return config - - def _plot_option_logic(plot_options_from_call_signature): """ Given some plot_options as part of a plot call, decide on final options. @@ -238,15 +212,22 @@ def plot(figure_or_data, validate=True, **plot_options): pass plot_options = _plot_option_logic(plot_options) - res = _send_to_plotly(figure, **plot_options) - if res['error'] == '': - if plot_options['auto_open']: - _open_url(res['url']) + fig = tools._replace_newline(figure) # does not mutate figure + data = fig.get('data', []) + response = v1.clientresp(data, **plot_options) - return res['url'] - else: - raise exceptions.PlotlyAccountError(res['error']) + # Check if the url needs a secret key + url = response.json()['url'] + if plot_options['sharing'] == 'secret': + if 'share_key=' not in url: + # add_share_key_to_url updates the url to include the share_key + url = add_share_key_to_url(url) + + if plot_options['auto_open']: + _open_url(url) + + return url def iplot_mpl(fig, resize=True, strip_style=False, update=None, @@ -316,6 +297,64 @@ def plot_mpl(fig, resize=True, strip_style=False, update=None, **plot_options): return plot(fig, **plot_options) +def _swap_keys(obj, key1, key2): + """Swap obj[key1] with obj[key2]""" + val1, val2 = None, None + try: + val2 = obj.pop(key1) + except KeyError: + pass + try: + val1 = obj.pop(key2) + except KeyError: + pass + if val2 is not None: + obj[key2] = val2 + if val1 is not None: + obj[key1] = val1 + + +def _swap_xy_data(data_obj): + """Swap x and y data and references""" + swaps = [('x', 'y'), + ('x0', 'y0'), + ('dx', 'dy'), + ('xbins', 'ybins'), + ('nbinsx', 'nbinsy'), + ('autobinx', 'autobiny'), + ('error_x', 'error_y')] + for swap in swaps: + _swap_keys(data_obj, swap[0], swap[1]) + try: + rows = len(data_obj['z']) + cols = len(data_obj['z'][0]) + for row in data_obj['z']: + if len(row) != cols: + raise TypeError + + # if we can't do transpose, we hit an exception before here + z = data_obj.pop('z') + data_obj['z'] = [[0 for rrr in range(rows)] for ccc in range(cols)] + for iii in range(rows): + for jjj in range(cols): + data_obj['z'][jjj][iii] = z[iii][jjj] + except (KeyError, TypeError, IndexError) as err: + warn = False + try: + if data_obj['z'] is not None: + warn = True + if len(data_obj['z']) == 0: + warn = False + except (KeyError, TypeError): + pass + if warn: + warnings.warn( + "Data in this file required an 'xy' swap but the 'z' matrix " + "in one of the data objects could not be transposed. Here's " + "why:\n\n{}".format(repr(err)) + ) + + def get_figure(file_owner_or_url, file_id=None, raw=False): """Returns a JSON figure representation for the specified file @@ -363,15 +402,6 @@ def get_figure(file_owner_or_url, file_id=None, raw=False): file_id = url.replace(head, "").split('/')[1] else: file_owner = file_owner_or_url - resource = "/apigetfile/{username}/{file_id}".format(username=file_owner, - file_id=file_id) - credentials = get_credentials() - validate_credentials(credentials) - username, api_key = credentials['username'], credentials['api_key'] - headers = {'plotly-username': username, - 'plotly-apikey': api_key, - 'plotly-version': version.__version__, - 'plotly-platform': 'python'} try: int(file_id) except ValueError: @@ -386,28 +416,49 @@ def get_figure(file_owner_or_url, file_id=None, raw=False): "The 'file_id' argument must be a non-negative number." ) - response = requests.get(plotly_rest_url + resource, - headers=headers, - verify=get_config()['plotly_ssl_verification']) - if response.status_code == 200: - if six.PY3: - content = json.loads(response.content.decode('utf-8')) - else: - content = json.loads(response.content) - response_payload = content['payload'] - figure = response_payload['figure'] - utils.decode_unicode(figure) - if raw: - return figure - else: - return tools.get_valid_graph_obj(figure, obj_type='Figure') - else: + fid = '{}:{}'.format(file_owner, file_id) + response = v2.plots.content(fid, inline_data=True) + figure = response.json() + + # Fix 'histogramx', 'histogramy', and 'bardir' stuff + for index, entry in enumerate(figure['data']): try: - content = json.loads(response.content) - raise exceptions.PlotlyError(content) - except: - raise exceptions.PlotlyError( - "There was an error retrieving this file") + # Use xbins to bin data in x, and ybins to bin data in y + if all((entry['type'] == 'histogramy', 'xbins' in entry, + 'ybins' not in entry)): + entry['ybins'] = entry.pop('xbins') + + # Convert bardir to orientation, and put the data into the axes + # it's eventually going to be used with + if entry['type'] in ['histogramx', 'histogramy']: + entry['type'] = 'histogram' + if 'bardir' in entry: + entry['orientation'] = entry.pop('bardir') + if entry['type'] == 'bar': + if entry['orientation'] == 'h': + _swap_xy_data(entry) + if entry['type'] == 'histogram': + if ('x' in entry) and ('y' not in entry): + if entry['orientation'] == 'h': + _swap_xy_data(entry) + del entry['orientation'] + if ('y' in entry) and ('x' not in entry): + if entry['orientation'] == 'v': + _swap_xy_data(entry) + del entry['orientation'] + figure['data'][index] = entry + except KeyError: + pass + + # Remove stream dictionary if found in a data trace + # (it has private tokens in there we need to hide!) + for index, entry in enumerate(figure['data']): + if 'stream' in entry: + del figure['data'][index]['stream'] + + if raw: + return figure + return tools.get_valid_graph_obj(figure, obj_type='Figure') @utils.template_doc(**tools.get_config_file()) @@ -592,7 +643,7 @@ def write(self, trace, layout=None, validate=True, stream_object.update(dict(layout=layout)) # TODO: allow string version of this? - jdata = json.dumps(stream_object, cls=utils.PlotlyJSONEncoder) + jdata = _json.dumps(stream_object, cls=utils.PlotlyJSONEncoder) jdata += "\n" try: @@ -673,10 +724,6 @@ def get(figure_or_data, format='png', width=None, height=None, scale=None): "Invalid scale parameter. Scale must be a number." ) - headers = _api_v2.headers() - headers['plotly_version'] = version.__version__ - headers['content-type'] = 'application/json' - payload = {'figure': figure, 'format': format} if width is not None: payload['width'] = width @@ -684,38 +731,18 @@ def get(figure_or_data, format='png', width=None, height=None, scale=None): payload['height'] = height if scale is not None: payload['scale'] = scale - url = _api_v2.api_url('images/') - - res = requests.post( - url, data=json.dumps(payload, cls=utils.PlotlyJSONEncoder), - headers=headers, verify=get_config()['plotly_ssl_verification'], - ) - - headers = res.headers - if res.status_code == 200: - if ('content-type' in headers and - headers['content-type'] in ['image/png', 'image/jpeg', - 'application/pdf', - 'image/svg+xml']): - return res.content + response = v2.images.create(payload) - elif ('content-type' in headers and - 'json' in headers['content-type']): - return_data = json.loads(res.content) - return return_data['image'] - else: - try: - if ('content-type' in headers and - 'json' in headers['content-type']): - return_data = json.loads(res.content) - else: - return_data = {'error': res.content} - except: - raise exceptions.PlotlyError("The response " - "from plotly could " - "not be translated.") - raise exceptions.PlotlyError(return_data['error']) + headers = response.headers + if ('content-type' in headers and + headers['content-type'] in ['image/png', 'image/jpeg', + 'application/pdf', + 'image/svg+xml']): + return response.content + elif ('content-type' in headers and + 'json' in headers['content-type']): + return response.json()['image'] @classmethod def ishow(cls, figure_or_data, format='png', width=None, height=None, @@ -829,22 +856,8 @@ def mkdirs(cls, folder_path): >> mkdirs('new/folder/path') """ - # trim trailing slash TODO: necessesary? - if folder_path[-1] == '/': - folder_path = folder_path[0:-1] - - payload = { - 'path': folder_path - } - - url = _api_v2.api_url('folders') - - res = requests.post(url, data=payload, headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - - _api_v2.response_handler(res) - - return res.status_code + response = v2.folders.create({'path': folder_path}) + return response.status_code class grid_ops: @@ -874,6 +887,15 @@ def _fill_in_response_column_ids(cls, request_columns, req_col.id = '{0}:{1}'.format(grid_id, resp_col['uid']) response_columns.remove(resp_col) + @staticmethod + def ensure_uploaded(fid): + if fid: + return + raise exceptions.PlotlyError( + 'This operation requires that the grid has already been uploaded ' + 'to Plotly. Try `uploading` first.' + ) + @classmethod def upload(cls, grid, filename, world_readable=True, auto_open=True, meta=None): @@ -954,37 +976,32 @@ def upload(cls, grid, filename, payload = { 'filename': filename, - 'data': json.dumps(grid_json, cls=utils.PlotlyJSONEncoder), + 'data': grid_json, 'world_readable': world_readable } if parent_path != '': payload['parent_path'] = parent_path - upload_url = _api_v2.api_url('grids') + response = v2.grids.create(payload) - req = requests.post(upload_url, data=payload, - headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - - res = _api_v2.response_handler(req) - - response_columns = res['file']['cols'] - grid_id = res['file']['fid'] - grid_url = res['file']['web_url'] + parsed_content = response.json() + cols = parsed_content['file']['cols'] + fid = parsed_content['file']['fid'] + web_url = parsed_content['file']['web_url'] # mutate the grid columns with the id's returned from the server - cls._fill_in_response_column_ids(grid, response_columns, grid_id) + cls._fill_in_response_column_ids(grid, cols, fid) - grid.id = grid_id + grid.id = fid if meta is not None: meta_ops.upload(meta, grid=grid) if auto_open: - _open_url(grid_url) + _open_url(web_url) - return grid_url + return web_url @classmethod def append_columns(cls, columns, grid=None, grid_url=None): @@ -1024,7 +1041,9 @@ def append_columns(cls, columns, grid=None, grid_url=None): ``` """ - grid_id = _api_v2.parse_grid_id_args(grid, grid_url) + grid_id = parse_grid_id_args(grid, grid_url) + + grid_ops.ensure_uploaded(grid_id) # Verify unique column names column_names = [c.name for c in columns] @@ -1036,17 +1055,15 @@ def append_columns(cls, columns, grid=None, grid_url=None): err = exceptions.NON_UNIQUE_COLUMN_MESSAGE.format(duplicate_name) raise exceptions.InputError(err) - payload = { - 'cols': json.dumps(columns, cls=utils.PlotlyJSONEncoder) + # This is sorta gross, we need to double-encode this. + body = { + 'cols': _json.dumps(columns, cls=utils.PlotlyJSONEncoder) } + fid = grid_id + response = v2.grids.col_create(fid, body) + parsed_content = response.json() - api_url = (_api_v2.api_url('grids') + - '/{grid_id}/col'.format(grid_id=grid_id)) - res = requests.post(api_url, data=payload, headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - res = _api_v2.response_handler(res) - - cls._fill_in_response_column_ids(columns, res['cols'], grid_id) + cls._fill_in_response_column_ids(columns, parsed_content['cols'], fid) if grid: grid.extend(columns) @@ -1096,7 +1113,9 @@ def append_rows(cls, rows, grid=None, grid_url=None): ``` """ - grid_id = _api_v2.parse_grid_id_args(grid, grid_url) + grid_id = parse_grid_id_args(grid, grid_url) + + grid_ops.ensure_uploaded(grid_id) if grid: n_columns = len([column for column in grid]) @@ -1112,15 +1131,8 @@ def append_rows(cls, rows, grid=None, grid_url=None): n_columns, 'column' if n_columns == 1 else 'columns')) - payload = { - 'rows': json.dumps(rows, cls=utils.PlotlyJSONEncoder) - } - - api_url = (_api_v2.api_url('grids') + - '/{grid_id}/row'.format(grid_id=grid_id)) - res = requests.post(api_url, data=payload, headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - _api_v2.response_handler(res) + fid = grid_id + v2.grids.row(fid, {'rows': rows}) if grid: longest_column_length = max([len(col.data) for col in grid]) @@ -1168,11 +1180,10 @@ def delete(cls, grid=None, grid_url=None): ``` """ - grid_id = _api_v2.parse_grid_id_args(grid, grid_url) - api_url = _api_v2.api_url('grids') + '/' + grid_id - res = requests.delete(api_url, headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - _api_v2.response_handler(res) + fid = parse_grid_id_args(grid, grid_url) + grid_ops.ensure_uploaded(fid) + v2.grids.trash(fid) + v2.grids.permanent_delete(fid) class meta_ops: @@ -1230,269 +1241,99 @@ def upload(cls, meta, grid=None, grid_url=None): ``` """ - grid_id = _api_v2.parse_grid_id_args(grid, grid_url) + fid = parse_grid_id_args(grid, grid_url) + return v2.grids.update(fid, {'metadata': meta}).json() - payload = { - 'metadata': json.dumps(meta, cls=utils.PlotlyJSONEncoder) - } - - api_url = _api_v2.api_url('grids') + '/{grid_id}'.format(grid_id=grid_id) - - res = requests.patch(api_url, data=payload, headers=_api_v2.headers(), - verify=get_config()['plotly_ssl_verification']) - - return _api_v2.response_handler(res) - - -class _api_v2: - """ - Request and response helper class for communicating with Plotly's v2 API +def parse_grid_id_args(grid, grid_url): """ - @classmethod - def parse_grid_id_args(cls, grid, grid_url): - """ - Return the grid_id from the non-None input argument. - - Raise an error if more than one argument was supplied. - - """ - if grid is not None: - id_from_grid = grid.id - else: - id_from_grid = None - args = [id_from_grid, grid_url] - arg_names = ('grid', 'grid_url') - - supplied_arg_names = [arg_name for arg_name, arg - in zip(arg_names, args) if arg is not None] - - if not supplied_arg_names: - raise exceptions.InputError( - "One of the two keyword arguments is required:\n" - " `grid` or `grid_url`\n\n" - "grid: a plotly.graph_objs.Grid object that has already\n" - " been uploaded to Plotly.\n\n" - "grid_url: the url where the grid can be accessed on\n" - " Plotly, e.g. 'https://plot.ly/~chris/3043'\n\n" - ) - elif len(supplied_arg_names) > 1: - raise exceptions.InputError( - "Only one of `grid` or `grid_url` is required. \n" - "You supplied both. \n" - ) - else: - supplied_arg_name = supplied_arg_names.pop() - if supplied_arg_name == 'grid_url': - path = six.moves.urllib.parse.urlparse(grid_url).path - file_owner, file_id = path.replace("/~", "").split('/')[0:2] - return '{0}:{1}'.format(file_owner, file_id) - else: - return grid.id - - @classmethod - def response_handler(cls, response): - try: - response.raise_for_status() - except requests.exceptions.HTTPError as requests_exception: - if (response.status_code == 404 and - get_config()['plotly_api_domain'] - != tools.get_config_defaults()['plotly_api_domain']): - raise exceptions.PlotlyError( - "This endpoint is unavailable at {url}. If you are using " - "Plotly On-Premise, you may need to upgrade your Plotly " - "Plotly On-Premise server to request against this endpoint or " - "this endpoint may not be available yet.\nQuestions? " - "Visit community.plot.ly, contact your plotly administrator " - "or upgrade to a Pro account for 1-1 help: https://goo.gl/1YUVu9 " - .format(url=get_config()['plotly_api_domain']) - ) - else: - raise requests_exception + Return the grid_id from the non-None input argument. - if ('content-type' in response.headers and - 'json' in response.headers['content-type'] and - len(response.content) > 0): - - response_dict = json.loads(response.content.decode('utf8')) - - if 'warnings' in response_dict and len(response_dict['warnings']): - warnings.warn('\n'.join(response_dict['warnings'])) - - return response_dict - - @classmethod - def api_url(cls, resource): - return ('{0}/v2/{1}'.format(get_config()['plotly_api_domain'], - resource)) - - @classmethod - def headers(cls): - credentials = get_credentials() + Raise an error if more than one argument was supplied. - # todo, validate here? - username, api_key = credentials['username'], credentials['api_key'] - encoded_api_auth = base64.b64encode(six.b('{0}:{1}'.format( - username, api_key))).decode('utf8') - - headers = { - 'plotly-client-platform': 'python {0}'.format(version.__version__) - } - - if get_config()['plotly_proxy_authorization']: - proxy_username = credentials['proxy_username'] - proxy_password = credentials['proxy_password'] - encoded_proxy_auth = base64.b64encode(six.b('{0}:{1}'.format( - proxy_username, proxy_password))).decode('utf8') - headers['authorization'] = 'Basic ' + encoded_proxy_auth - headers['plotly-authorization'] = 'Basic ' + encoded_api_auth - else: - headers['authorization'] = 'Basic ' + encoded_api_auth - - return headers - - -def validate_credentials(credentials): """ - Currently only checks for truthy username and api_key - - """ - username = credentials.get('username') - api_key = credentials.get('api_key') - if not username or not api_key: - raise exceptions.PlotlyLocalCredentialsError() + if grid is not None: + id_from_grid = grid.id + else: + id_from_grid = None + args = [id_from_grid, grid_url] + arg_names = ('grid', 'grid_url') + + supplied_arg_names = [arg_name for arg_name, arg + in zip(arg_names, args) if arg is not None] + + if not supplied_arg_names: + raise exceptions.InputError( + "One of the two keyword arguments is required:\n" + " `grid` or `grid_url`\n\n" + "grid: a plotly.graph_objs.Grid object that has already\n" + " been uploaded to Plotly.\n\n" + "grid_url: the url where the grid can be accessed on\n" + " Plotly, e.g. 'https://plot.ly/~chris/3043'\n\n" + ) + elif len(supplied_arg_names) > 1: + raise exceptions.InputError( + "Only one of `grid` or `grid_url` is required. \n" + "You supplied both. \n" + ) + else: + supplied_arg_name = supplied_arg_names.pop() + if supplied_arg_name == 'grid_url': + path = six.moves.urllib.parse.urlparse(grid_url).path + file_owner, file_id = path.replace("/~", "").split('/')[0:2] + return '{0}:{1}'.format(file_owner, file_id) + else: + return grid.id -def add_share_key_to_url(plot_url, attempt=0): +def add_share_key_to_url(plot_url): """ Update plot's url to include the secret key """ urlsplit = six.moves.urllib.parse.urlparse(plot_url) - file_owner = urlsplit.path.split('/')[1].split('~')[1] - file_id = urlsplit.path.split('/')[2] + username = urlsplit.path.split('/')[1].split('~')[1] + idlocal = urlsplit.path.split('/')[2] + fid = '{}:{}'.format(username, idlocal) - url = _api_v2.api_url("files/") + file_owner + ":" + file_id - new_response = requests.patch(url, - headers=_api_v2.headers(), - data={"share_key_enabled": - "True", - "world_readable": - "False"}) + body = {'share_key_enabled': True, 'world_readable': False} + response = v2.files.update(fid, body) - _api_v2.response_handler(new_response) - - # decode bytes for python 3.3: https://bugs.python.org/issue10976 - str_content = new_response.content.decode('utf-8') - - new_response_data = json.loads(str_content) - - plot_url += '?share_key=' + new_response_data['share_key'] - - # sometimes a share key is added, but access is still denied - # check for access, and retry a couple of times if this is the case - # https://github.com/plotly/streambed/issues/4089 - embed_url = plot_url.split('?')[0] + '.embed' + plot_url.split('?')[1] - access_res = requests.get(embed_url) - if access_res.status_code == 404: - attempt += 1 - if attempt == 5: - return plot_url - plot_url = add_share_key_to_url(plot_url.split('?')[0], attempt) - - return plot_url + return plot_url + '?share_key=' + response.json()['share_key'] def _send_to_plotly(figure, **plot_options): fig = tools._replace_newline(figure) # does not mutate figure - data = json.dumps(fig['data'] if 'data' in fig else [], - cls=utils.PlotlyJSONEncoder) - credentials = get_credentials() - validate_credentials(credentials) - username = credentials['username'] - api_key = credentials['api_key'] - kwargs = json.dumps(dict(filename=plot_options['filename'], - fileopt=plot_options['fileopt'], - world_readable=plot_options['world_readable'], - sharing=plot_options['sharing'], - layout=fig['layout'] if 'layout' in fig else {}), - cls=utils.PlotlyJSONEncoder) - - # TODO: It'd be cool to expose the platform for RaspPi and others - payload = dict(platform='python', - version=version.__version__, - args=data, - un=username, - key=api_key, - origin='plot', - kwargs=kwargs) - - url = get_config()['plotly_domain'] + "/clientresp" - - r = requests.post(url, data=payload, - verify=get_config()['plotly_ssl_verification']) - r.raise_for_status() - r = json.loads(r.text) - - if 'error' in r and r['error'] != '': - raise exceptions.PlotlyError(r['error']) - - # Check if the url needs a secret key - if (plot_options['sharing'] == 'secret' and - 'share_key=' not in r['url']): + data = fig.get('data', []) + response = v1.clientresp(data, **plot_options) - # add_share_key_to_url updates the url to include the share_key - r['url'] = add_share_key_to_url(r['url']) + parsed_content = response.json() - if 'error' in r and r['error'] != '': - print(r['error']) - if 'warning' in r and r['warning'] != '': - warnings.warn(r['warning']) - if 'message' in r and r['message'] != '': - print(r['message']) + # Check if the url needs a secret key + if plot_options['sharing'] == 'secret': + url = parsed_content['url'] + if 'share_key=' not in url: + # add_share_key_to_url updates the url to include the share_key + parsed_content['url'] = add_share_key_to_url(url) - return r + return parsed_content def get_grid(grid_url, raw=False): """ Returns the specified grid as a Grid instance or in JSON/dict form. + :param (str) grid_url: The web_url which locates a Plotly grid. :param (bool) raw: if False, will output a Grid instance of the JSON grid being retrieved. If True, raw JSON will be returned. """ - credentials = get_credentials() - validate_credentials(credentials) - username, api_key = credentials['username'], credentials['api_key'] - headers = {'plotly-username': username, - 'plotly-apikey': api_key, - 'plotly-version': version.__version__, - 'plotly-platform': 'python'} - upload_url = _api_v2.api_url('grids') - - # extract path in grid url - url_path = six.moves.urllib.parse.urlparse(grid_url)[2][2:] - if url_path[-1] == '/': - url_path = url_path[0: -1] - url_path = url_path.replace('/', ':') - - meta_get_url = upload_url + '/' + url_path - get_url = meta_get_url + '/content' - - r = requests.get(get_url, headers=headers) - json_res = json.loads(r.text) - - # make request to grab the grid id (fid) - r_meta = requests.get(meta_get_url, headers=headers) - r_meta.raise_for_status() - - json_res_meta = json.loads(r_meta.text) - retrieved_grid_id = json_res_meta['fid'] - - if raw is False: - return Grid(json_res, retrieved_grid_id) - else: - return json_res + fid = parse_grid_id_args(None, grid_url) + response = v2.grids.content(fid) + parsed_content = response.json() + + if raw: + return parsed_content + return Grid(parsed_content, fid) def create_animations(figure, filename=None, sharing='public', auto_open=True): @@ -1661,14 +1502,7 @@ def create_animations(figure, filename=None, sharing='public', auto_open=True): py.create_animations(figure, 'growing_circles') ``` """ - credentials = get_credentials() - validate_credentials(credentials) - username, api_key = credentials['username'], credentials['api_key'] - auth = HTTPBasicAuth(str(username), str(api_key)) - headers = {'Plotly-Client-Platform': 'python', - 'content-type': 'application/json'} - - json = { + body = { 'figure': figure, 'world_readable': True } @@ -1682,48 +1516,30 @@ def create_animations(figure, filename=None, sharing='public', auto_open=True): "automatic folder creation. This means a filename of the form " "'name1/name2' will just create the plot with that name only." ) - json['filename'] = filename + body['filename'] = filename # set sharing if sharing == 'public': - json['world_readable'] = True + body['world_readable'] = True elif sharing == 'private': - json['world_readable'] = False + body['world_readable'] = False elif sharing == 'secret': - json['world_readable'] = False - json['share_key_enabled'] = True + body['world_readable'] = False + body['share_key_enabled'] = True else: raise exceptions.PlotlyError( "Whoops, sharing can only be set to either 'public', 'private', " "or 'secret'." ) - api_url = _api_v2.api_url('plots') - r = requests.post(api_url, auth=auth, headers=headers, json=json) - - try: - parsed_response = r.json() - except: - parsed_response = r.content - - # raise error message - if not r.ok: - message = '' - if isinstance(parsed_response, dict): - errors = parsed_response.get('errors') - if errors and errors[-1].get('message'): - message = errors[-1]['message'] - if message: - raise exceptions.PlotlyError(message) - else: - # shucks, we're stuck with a generic error... - r.raise_for_status() + response = v2.plots.create(body) + parsed_content = response.json() if sharing == 'secret': - web_url = (parsed_response['file']['web_url'][:-1] + - '?share_key=' + parsed_response['file']['share_key']) + web_url = (parsed_content['file']['web_url'][:-1] + + '?share_key=' + parsed_content['file']['share_key']) else: - web_url = parsed_response['file']['web_url'] + web_url = parsed_content['file']['web_url'] if auto_open: _open_url(web_url) @@ -1738,7 +1554,7 @@ def icreate_animations(figure, filename=None, sharing='public', auto_open=False) This function is based off `plotly.plotly.iplot`. See `plotly.plotly. create_animations` Doc String for param descriptions. """ - # Still needs doing: create a wrapper for iplot and icreate_animations + # TODO: create a wrapper for iplot and icreate_animations url = create_animations(figure, filename, sharing, auto_open) if isinstance(figure, dict): diff --git a/plotly/session.py b/plotly/session.py index e93d9a85996..2e72d45bff3 100644 --- a/plotly/session.py +++ b/plotly/session.py @@ -22,6 +22,8 @@ CREDENTIALS_KEYS = { 'username': six.string_types, 'api_key': six.string_types, + 'proxy_username': six.string_types, + 'proxy_password': six.string_types, 'stream_ids': list } diff --git a/plotly/tests/test_core/test_api/__init__.py b/plotly/tests/test_core/test_api/__init__.py new file mode 100644 index 00000000000..f8a93ee0238 --- /dev/null +++ b/plotly/tests/test_core/test_api/__init__.py @@ -0,0 +1,59 @@ +from __future__ import absolute_import + +from mock import patch +from requests import Response + +from plotly.session import sign_in +from plotly.tests.utils import PlotlyTestCase + + +class PlotlyApiTestCase(PlotlyTestCase): + + def mock(self, path_string): + patcher = patch(path_string) + new_mock = patcher.start() + self.addCleanup(patcher.stop) + return new_mock + + def setUp(self): + + super(PlotlyApiTestCase, self).setUp() + + self.username = 'foo' + self.api_key = 'bar' + + self.proxy_username = 'cnet' + self.proxy_password = 'hoopla' + self.stream_ids = ['heyThere'] + + self.plotly_api_domain = 'https://api.do.not.exist' + self.plotly_domain = 'https://who.am.i' + self.plotly_proxy_authorization = False + self.plotly_streaming_domain = 'stream.does.not.exist' + self.plotly_ssl_verification = True + + sign_in( + username=self.username, + api_key=self.api_key, + proxy_username=self.proxy_username, + proxy_password=self.proxy_password, + stream_ids = self.stream_ids, + plotly_domain=self.plotly_domain, + plotly_api_domain=self.plotly_api_domain, + plotly_streaming_domain=self.plotly_streaming_domain, + plotly_proxy_authorization=self.plotly_proxy_authorization, + plotly_ssl_verification=self.plotly_ssl_verification + ) + + def to_bytes(self, string): + try: + return string.encode('utf-8') + except AttributeError: + return string + + def get_response(self, content=b'', status_code=200): + response = Response() + response.status_code = status_code + response._content = content + response.encoding = 'utf-8' + return response diff --git a/plotly/tests/test_core/test_api/test_v1/__init__.py b/plotly/tests/test_core/test_api/test_v1/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plotly/tests/test_core/test_api/test_v1/test_clientresp.py b/plotly/tests/test_core/test_api/test_v1/test_clientresp.py new file mode 100644 index 00000000000..784ca087642 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v1/test_clientresp.py @@ -0,0 +1,61 @@ +from __future__ import absolute_import + +from plotly import version +from plotly.api.v1 import clientresp +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class Duck(object): + def to_plotly_json(self): + return 'what else floats?' + + +class ClientrespTest(PlotlyApiTestCase): + + def setUp(self): + super(ClientrespTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v1.utils.requests.request') + self.request_mock.return_value = self.get_response(b'{}', 200) + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v1.utils.validate_response') + + def test_data_only(self): + data = [{'y': [3, 5], 'name': Duck()}] + clientresp(data) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/clientresp'.format(self.plotly_domain)) + expected_data = ({ + 'origin': 'plot', + 'args': '[{"name": "what else floats?", "y": [3, 5]}]', + 'platform': 'python', 'version': version.__version__, 'key': 'bar', + 'kwargs': '{}', 'un': 'foo' + }) + self.assertEqual(kwargs['data'], expected_data) + self.assertTrue(kwargs['verify']) + self.assertEqual(kwargs['headers'], {}) + + def test_data_and_kwargs(self): + data = [{'y': [3, 5], 'name': Duck()}] + clientresp_kwargs = {'layout': {'title': 'mah plot'}, 'filename': 'ok'} + clientresp(data, **clientresp_kwargs) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/clientresp'.format(self.plotly_domain)) + expected_data = ({ + 'origin': 'plot', + 'args': '[{"name": "what else floats?", "y": [3, 5]}]', + 'platform': 'python', 'version': version.__version__, 'key': 'bar', + 'kwargs': '{"filename": "ok", "layout": {"title": "mah plot"}}', + 'un': 'foo' + }) + self.assertEqual(kwargs['data'], expected_data) + self.assertTrue(kwargs['verify']) + self.assertEqual(kwargs['headers'], {}) diff --git a/plotly/tests/test_core/test_api/test_v1/test_utils.py b/plotly/tests/test_core/test_api/test_v1/test_utils.py new file mode 100644 index 00000000000..dee352db785 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v1/test_utils.py @@ -0,0 +1,175 @@ +from __future__ import absolute_import + +from unittest import TestCase + +from mock import MagicMock, patch +from requests import Response +from requests.compat import json as _json +from requests.exceptions import ConnectionError + +from plotly.api.utils import to_native_utf8_string +from plotly.api.v1 import utils +from plotly.exceptions import PlotlyError, PlotlyRequestError +from plotly.session import sign_in +from plotly.tests.test_core.test_api import PlotlyApiTestCase +from plotly.tests.utils import PlotlyTestCase + + +class ValidateResponseTest(PlotlyApiTestCase): + + def test_validate_ok(self): + try: + utils.validate_response(self.get_response(content=b'{}')) + except PlotlyRequestError: + self.fail('Expected this to pass!') + + def test_validate_not_ok(self): + bad_status_codes = (400, 404, 500) + for bad_status_code in bad_status_codes: + response = self.get_response(content=b'{}', + status_code=bad_status_code) + self.assertRaises(PlotlyRequestError, utils.validate_response, + response) + + def test_validate_no_content(self): + + # We shouldn't flake if the response has no content. + + response = self.get_response(content=b'', status_code=200) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'No Content') + self.assertEqual(e.status_code, 200) + self.assertEqual(e.content, b'') + else: + self.fail('Expected this to raise!') + + def test_validate_non_json_content(self): + response = self.get_response(content=b'foobar', status_code=200) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'foobar') + self.assertEqual(e.status_code, 200) + self.assertEqual(e.content, b'foobar') + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_array(self): + content = self.to_bytes(_json.dumps([1, 2, 3])) + response = self.get_response(content=content, status_code=200) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 200) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_no_error(self): + content = self.to_bytes(_json.dumps({'foo': 'bar'})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_error_empty(self): + content = self.to_bytes(_json.dumps({'error': ''})) + response = self.get_response(content=content, status_code=200) + try: + utils.validate_response(response) + except PlotlyRequestError: + self.fail('Expected this not to raise!') + + def test_validate_json_content_dict_one_error_ok(self): + content = self.to_bytes(_json.dumps({'error': 'not ok!'})) + response = self.get_response(content=content, status_code=200) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'not ok!') + self.assertEqual(e.status_code, 200) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + +class GetHeadersTest(PlotlyTestCase): + + def setUp(self): + super(GetHeadersTest, self).setUp() + self.domain = 'https://foo.bar' + self.username = 'hodor' + self.api_key = 'secret' + sign_in(self.username, self.api_key, proxy_username='kleen-kanteen', + proxy_password='hydrated', plotly_proxy_authorization=False) + + def test_normal_auth(self): + headers = utils.get_headers() + expected_headers = {} + self.assertEqual(headers, expected_headers) + + def test_proxy_auth(self): + sign_in(self.username, self.api_key, plotly_proxy_authorization=True) + headers = utils.get_headers() + expected_headers = { + 'authorization': 'Basic a2xlZW4ta2FudGVlbjpoeWRyYXRlZA==' + } + self.assertEqual(headers, expected_headers) + + +class RequestTest(PlotlyTestCase): + + def setUp(self): + super(RequestTest, self).setUp() + self.domain = 'https://foo.bar' + self.username = 'hodor' + self.api_key = 'secret' + sign_in(self.username, self.api_key, proxy_username='kleen-kanteen', + proxy_password='hydrated', plotly_proxy_authorization=False) + + # Mock the actual api call, we don't want to do network tests here. + patcher = patch('plotly.api.v1.utils.requests.request') + self.request_mock = patcher.start() + self.addCleanup(patcher.stop) + self.request_mock.return_value = MagicMock(Response) + + # Mock the validation function since we test that elsewhere. + patcher = patch('plotly.api.v1.utils.validate_response') + self.validate_response_mock = patcher.start() + self.addCleanup(patcher.stop) + + self.method = 'get' + self.url = 'https://foo.bar.does.not.exist.anywhere' + + def test_request_with_json(self): + + # You can pass along non-native objects in the `json` kwarg for a + # requests.request, however, V1 packs up json objects a little + # differently, so we don't allow such requests. + + self.assertRaises(PlotlyError, utils.request, self.method, + self.url, json={}) + + def test_request_with_ConnectionError(self): + + # requests can flake out and not return a response object, we want to + # make sure we remain consistent with our errors. + + self.request_mock.side_effect = ConnectionError() + self.assertRaises(PlotlyRequestError, utils.request, self.method, + self.url) + + def test_request_validate_response(self): + + # Finally, we check details elsewhere, but make sure we do validate. + + utils.request(self.method, self.url) + self.validate_response_mock.assert_called_once() diff --git a/plotly/tests/test_core/test_api/test_v2/__init__.py b/plotly/tests/test_core/test_api/test_v2/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plotly/tests/test_core/test_api/test_v2/test_files.py b/plotly/tests/test_core/test_api/test_v2/test_files.py new file mode 100644 index 00000000000..32e4ec99347 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_files.py @@ -0,0 +1,104 @@ +from __future__ import absolute_import + +from plotly.api.v2 import files +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class FilesTest(PlotlyApiTestCase): + + def setUp(self): + super(FilesTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_retrieve(self): + files.retrieve('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/files/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {}) + + def test_retrieve_share_key(self): + files.retrieve('hodor:88', share_key='foobar') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/files/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'share_key': 'foobar'}) + + def test_update(self): + new_filename = '..zzZ ..zzZ' + files.update('hodor:88', body={'filename': new_filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'put') + self.assertEqual( + url, '{}/v2/files/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], + '{{"filename": "{}"}}'.format(new_filename)) + + def test_trash(self): + files.trash('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/files/hodor:88/trash'.format(self.plotly_api_domain) + ) + + def test_restore(self): + files.restore('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/files/hodor:88/restore'.format(self.plotly_api_domain) + ) + + def test_permanent_delete(self): + files.permanent_delete('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'delete') + self.assertEqual( + url, + '{}/v2/files/hodor:88/permanent_delete' + .format(self.plotly_api_domain) + ) + + def test_lookup(self): + + # requests does urlencode, so don't worry about the `' '` character! + + path = '/mah plot' + parent = 43 + user = 'someone' + exists = True + files.lookup(path=path, parent=parent, user=user, exists=exists) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + expected_params = {'path': path, 'parent': parent, 'exists': 'true', + 'user': user} + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/files/lookup'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], expected_params) diff --git a/plotly/tests/test_core/test_api/test_v2/test_folders.py b/plotly/tests/test_core/test_api/test_v2/test_folders.py new file mode 100644 index 00000000000..0365ad79879 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_folders.py @@ -0,0 +1,114 @@ +from __future__ import absolute_import + +from plotly.api.v2 import folders +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class FoldersTest(PlotlyApiTestCase): + + def setUp(self): + super(FoldersTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_create(self): + path = '/foo/man/bar/' + folders.create({'path': path}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/v2/folders'.format(self.plotly_api_domain)) + self.assertEqual(kwargs['data'], '{{"path": "{}"}}'.format(path)) + + def test_retrieve(self): + folders.retrieve('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/folders/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {}) + + def test_retrieve_share_key(self): + folders.retrieve('hodor:88', share_key='foobar') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/folders/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'share_key': 'foobar'}) + + def test_update(self): + new_filename = '..zzZ ..zzZ' + folders.update('hodor:88', body={'filename': new_filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'put') + self.assertEqual( + url, '{}/v2/folders/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], + '{{"filename": "{}"}}'.format(new_filename)) + + def test_trash(self): + folders.trash('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/folders/hodor:88/trash'.format(self.plotly_api_domain) + ) + + def test_restore(self): + folders.restore('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/folders/hodor:88/restore'.format(self.plotly_api_domain) + ) + + def test_permanent_delete(self): + folders.permanent_delete('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'delete') + self.assertEqual( + url, + '{}/v2/folders/hodor:88/permanent_delete' + .format(self.plotly_api_domain) + ) + + def test_lookup(self): + + # requests does urlencode, so don't worry about the `' '` character! + + path = '/mah folder' + parent = 43 + user = 'someone' + exists = True + folders.lookup(path=path, parent=parent, user=user, exists=exists) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + expected_params = {'path': path, 'parent': parent, 'exists': 'true', + 'user': user} + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/folders/lookup'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], expected_params) diff --git a/plotly/tests/test_core/test_api/test_v2/test_grids.py b/plotly/tests/test_core/test_api/test_v2/test_grids.py new file mode 100644 index 00000000000..ff6fb3ec1b3 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_grids.py @@ -0,0 +1,185 @@ +from __future__ import absolute_import + +from requests.compat import json as _json + +from plotly.api.v2 import grids +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class GridsTest(PlotlyApiTestCase): + + def setUp(self): + super(GridsTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_create(self): + filename = 'a grid' + grids.create({'filename': filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/v2/grids'.format(self.plotly_api_domain)) + self.assertEqual( + kwargs['data'], '{{"filename": "{}"}}'.format(filename) + ) + + def test_retrieve(self): + grids.retrieve('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/grids/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {}) + + def test_retrieve_share_key(self): + grids.retrieve('hodor:88', share_key='foobar') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/grids/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'share_key': 'foobar'}) + + def test_update(self): + new_filename = '..zzZ ..zzZ' + grids.update('hodor:88', body={'filename': new_filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'put') + self.assertEqual( + url, '{}/v2/grids/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], + '{{"filename": "{}"}}'.format(new_filename)) + + def test_trash(self): + grids.trash('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/grids/hodor:88/trash'.format(self.plotly_api_domain) + ) + + def test_restore(self): + grids.restore('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/grids/hodor:88/restore'.format(self.plotly_api_domain) + ) + + def test_permanent_delete(self): + grids.permanent_delete('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'delete') + self.assertEqual( + url, + '{}/v2/grids/hodor:88/permanent_delete' + .format(self.plotly_api_domain) + ) + + def test_lookup(self): + + # requests does urlencode, so don't worry about the `' '` character! + + path = '/mah grid' + parent = 43 + user = 'someone' + exists = True + grids.lookup(path=path, parent=parent, user=user, exists=exists) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + expected_params = {'path': path, 'parent': parent, 'exists': 'true', + 'user': user} + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/grids/lookup'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], expected_params) + + def test_col_create(self): + cols = [ + {'name': 'foo', 'data': [1, 2, 3]}, + {'name': 'bar', 'data': ['b', 'a', 'r']}, + ] + body = {'cols': _json.dumps(cols, sort_keys=True)} + grids.col_create('hodor:88', body) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/grids/hodor:88/col'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], _json.dumps(body, sort_keys=True)) + + def test_col_retrieve(self): + grids.col_retrieve('hodor:88', 'aaaaaa,bbbbbb') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/grids/hodor:88/col'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'uid': 'aaaaaa,bbbbbb'}) + + def test_col_update(self): + cols = [ + {'name': 'foo', 'data': [1, 2, 3]}, + {'name': 'bar', 'data': ['b', 'a', 'r']}, + ] + body = {'cols': _json.dumps(cols, sort_keys=True)} + grids.col_update('hodor:88', 'aaaaaa,bbbbbb', body) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'put') + self.assertEqual( + url, '{}/v2/grids/hodor:88/col'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'uid': 'aaaaaa,bbbbbb'}) + self.assertEqual(kwargs['data'], _json.dumps(body, sort_keys=True)) + + def test_col_delete(self): + grids.col_delete('hodor:88', 'aaaaaa,bbbbbb') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'delete') + self.assertEqual( + url, '{}/v2/grids/hodor:88/col'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'uid': 'aaaaaa,bbbbbb'}) + + def test_row(self): + body = {'rows': [[1, 'A'], [2, 'B']]} + grids.row('hodor:88', body) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/grids/hodor:88/row'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], _json.dumps(body, sort_keys=True)) diff --git a/plotly/tests/test_core/test_api/test_v2/test_images.py b/plotly/tests/test_core/test_api/test_v2/test_images.py new file mode 100644 index 00000000000..480cf0f05bf --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_images.py @@ -0,0 +1,41 @@ +from __future__ import absolute_import + +from requests.compat import json as _json + +from plotly.api.v2 import images +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class ImagesTest(PlotlyApiTestCase): + + def setUp(self): + super(ImagesTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_create(self): + + body = { + "figure": { + "data": [{"y": [10, 10, 2, 20]}], + "layout": {"width": 700} + }, + "width": 1000, + "height": 500, + "format": "png", + "scale": 4, + "encoded": False + } + + images.create(body) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/v2/images'.format(self.plotly_api_domain)) + self.assertEqual(kwargs['data'], _json.dumps(body, sort_keys=True)) diff --git a/plotly/tests/test_core/test_api/test_v2/test_plot_schema.py b/plotly/tests/test_core/test_api/test_v2/test_plot_schema.py new file mode 100644 index 00000000000..b52f1b3a000 --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_plot_schema.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import + +from plotly.api.v2 import plot_schema +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class PlotSchemaTest(PlotlyApiTestCase): + + def setUp(self): + super(PlotSchemaTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_retrieve(self): + + plot_schema.retrieve('some-hash', timeout=400) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/plot-schema'.format(self.plotly_api_domain) + ) + self.assertTrue(kwargs['timeout']) + self.assertEqual(kwargs['params'], {'sha1': 'some-hash'}) diff --git a/plotly/tests/test_core/test_api/test_v2/test_plots.py b/plotly/tests/test_core/test_api/test_v2/test_plots.py new file mode 100644 index 00000000000..31d50cb7aaf --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_plots.py @@ -0,0 +1,116 @@ +from __future__ import absolute_import + +from plotly.api.v2 import plots +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class PlotsTest(PlotlyApiTestCase): + + def setUp(self): + super(PlotsTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.mock('plotly.api.v2.utils.validate_response') + + def test_create(self): + filename = 'a plot' + plots.create({'filename': filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual(url, '{}/v2/plots'.format(self.plotly_api_domain)) + self.assertEqual( + kwargs['data'], '{{"filename": "{}"}}'.format(filename) + ) + + def test_retrieve(self): + plots.retrieve('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/plots/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {}) + + def test_retrieve_share_key(self): + plots.retrieve('hodor:88', share_key='foobar') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/plots/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], {'share_key': 'foobar'}) + + def test_update(self): + new_filename = '..zzZ ..zzZ' + plots.update('hodor:88', body={'filename': new_filename}) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'put') + self.assertEqual( + url, '{}/v2/plots/hodor:88'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['data'], + '{{"filename": "{}"}}'.format(new_filename)) + + def test_trash(self): + plots.trash('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/plots/hodor:88/trash'.format(self.plotly_api_domain) + ) + + def test_restore(self): + plots.restore('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'post') + self.assertEqual( + url, '{}/v2/plots/hodor:88/restore'.format(self.plotly_api_domain) + ) + + def test_permanent_delete(self): + plots.permanent_delete('hodor:88') + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + self.assertEqual(method, 'delete') + self.assertEqual( + url, + '{}/v2/plots/hodor:88/permanent_delete' + .format(self.plotly_api_domain) + ) + + def test_lookup(self): + + # requests does urlencode, so don't worry about the `' '` character! + + path = '/mah plot' + parent = 43 + user = 'someone' + exists = True + plots.lookup(path=path, parent=parent, user=user, exists=exists) + self.request_mock.assert_called_once() + args, kwargs = self.request_mock.call_args + method, url = args + expected_params = {'path': path, 'parent': parent, 'exists': 'true', + 'user': user} + self.assertEqual(method, 'get') + self.assertEqual( + url, '{}/v2/plots/lookup'.format(self.plotly_api_domain) + ) + self.assertEqual(kwargs['params'], expected_params) diff --git a/plotly/tests/test_core/test_api/test_v2/test_utils.py b/plotly/tests/test_core/test_api/test_v2/test_utils.py new file mode 100644 index 00000000000..c370ef418ed --- /dev/null +++ b/plotly/tests/test_core/test_api/test_v2/test_utils.py @@ -0,0 +1,252 @@ +from __future__ import absolute_import + +from requests.compat import json as _json +from requests.exceptions import ConnectionError + +from plotly import version +from plotly.api.utils import to_native_utf8_string +from plotly.api.v2 import utils +from plotly.exceptions import PlotlyRequestError +from plotly.session import sign_in +from plotly.tests.test_core.test_api import PlotlyApiTestCase + + +class MakeParamsTest(PlotlyApiTestCase): + + def test_make_params(self): + params = utils.make_params(foo='FOO', bar=None) + self.assertEqual(params, {'foo': 'FOO'}) + + def test_make_params_empty(self): + params = utils.make_params(foo=None, bar=None) + self.assertEqual(params, {}) + + +class BuildUrlTest(PlotlyApiTestCase): + + def test_build_url(self): + url = utils.build_url('cats') + self.assertEqual(url, '{}/v2/cats'.format(self.plotly_api_domain)) + + def test_build_url_id(self): + url = utils.build_url('cats', id='MsKitty') + self.assertEqual( + url, '{}/v2/cats/MsKitty'.format(self.plotly_api_domain) + ) + + def test_build_url_route(self): + url = utils.build_url('cats', route='about') + self.assertEqual( + url, '{}/v2/cats/about'.format(self.plotly_api_domain) + ) + + def test_build_url_id_route(self): + url = utils.build_url('cats', id='MsKitty', route='de-claw') + self.assertEqual( + url, '{}/v2/cats/MsKitty/de-claw'.format(self.plotly_api_domain) + ) + + +class ValidateResponseTest(PlotlyApiTestCase): + + def test_validate_ok(self): + try: + utils.validate_response(self.get_response()) + except PlotlyRequestError: + self.fail('Expected this to pass!') + + def test_validate_not_ok(self): + bad_status_codes = (400, 404, 500) + for bad_status_code in bad_status_codes: + response = self.get_response(status_code=bad_status_code) + self.assertRaises(PlotlyRequestError, utils.validate_response, + response) + + def test_validate_no_content(self): + + # We shouldn't flake if the response has no content. + + response = self.get_response(content=b'', status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, u'No Content') + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content.decode('utf-8'), u'') + else: + self.fail('Expected this to raise!') + + def test_validate_non_json_content(self): + response = self.get_response(content=b'foobar', status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'foobar') + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, b'foobar') + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_array(self): + content = self.to_bytes(_json.dumps([1, 2, 3])) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_no_errors(self): + content = self.to_bytes(_json.dumps({'foo': 'bar'})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_one_error_bad(self): + content = self.to_bytes(_json.dumps({'errors': [{}]})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + content = self.to_bytes(_json.dumps({'errors': [{'message': ''}]})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, to_native_utf8_string(content)) + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_one_error_ok(self): + content = self.to_bytes(_json.dumps( + {'errors': [{'message': 'not ok!'}]})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'not ok!') + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + def test_validate_json_content_dict_multiple_errors(self): + content = self.to_bytes(_json.dumps({'errors': [ + {'message': 'not ok!'}, {'message': 'bad job...'} + ]})) + response = self.get_response(content=content, status_code=400) + try: + utils.validate_response(response) + except PlotlyRequestError as e: + self.assertEqual(e.message, 'not ok!\nbad job...') + self.assertEqual(e.status_code, 400) + self.assertEqual(e.content, content) + else: + self.fail('Expected this to raise!') + + +class GetHeadersTest(PlotlyApiTestCase): + + def test_normal_auth(self): + headers = utils.get_headers() + expected_headers = { + 'plotly-client-platform': 'python {}'.format(version.__version__), + 'authorization': 'Basic Zm9vOmJhcg==', + 'content-type': 'application/json' + } + self.assertEqual(headers, expected_headers) + + def test_proxy_auth(self): + sign_in(self.username, self.api_key, plotly_proxy_authorization=True) + headers = utils.get_headers() + expected_headers = { + 'plotly-client-platform': 'python {}'.format(version.__version__), + 'authorization': 'Basic Y25ldDpob29wbGE=', + 'plotly-authorization': 'Basic Zm9vOmJhcg==', + 'content-type': 'application/json' + } + self.assertEqual(headers, expected_headers) + + +class RequestTest(PlotlyApiTestCase): + + def setUp(self): + super(RequestTest, self).setUp() + + # Mock the actual api call, we don't want to do network tests here. + self.request_mock = self.mock('plotly.api.v2.utils.requests.request') + self.request_mock.return_value = self.get_response() + + # Mock the validation function since we can test that elsewhere. + self.validate_response_mock = self.mock( + 'plotly.api.v2.utils.validate_response') + + self.method = 'get' + self.url = 'https://foo.bar.does.not.exist.anywhere' + + def test_request_with_params(self): + + # urlencode transforms `True` --> `'True'`, which isn't super helpful, + # Our backend accepts the JS `true`, so we want `True` --> `'true'`. + + params = {'foo': True, 'bar': 'True', 'baz': False, 'zap': 0} + utils.request(self.method, self.url, params=params) + args, kwargs = self.request_mock.call_args + method, url = args + expected_params = {'foo': 'true', 'bar': 'True', 'baz': 'false', + 'zap': 0} + self.assertEqual(method, self.method) + self.assertEqual(url, self.url) + self.assertEqual(kwargs['params'], expected_params) + + def test_request_with_non_native_objects(self): + + # We always send along json, but it may contain non-native objects like + # a pandas array or a Column reference. Make sure that's handled in one + # central place. + + class Duck(object): + def to_plotly_json(self): + return 'what else floats?' + + utils.request(self.method, self.url, json={'foo': [Duck(), Duck()]}) + args, kwargs = self.request_mock.call_args + method, url = args + expected_data = '{"foo": ["what else floats?", "what else floats?"]}' + self.assertEqual(method, self.method) + self.assertEqual(url, self.url) + self.assertEqual(kwargs['data'], expected_data) + self.assertNotIn('json', kwargs) + + def test_request_with_ConnectionError(self): + + # requests can flake out and not return a response object, we want to + # make sure we remain consistent with our errors. + + self.request_mock.side_effect = ConnectionError() + self.assertRaises(PlotlyRequestError, utils.request, self.method, + self.url) + + def test_request_validate_response(self): + + # Finally, we check details elsewhere, but make sure we do validate. + + utils.request(self.method, self.url) + self.validate_response_mock.assert_called_once() diff --git a/plotly/tests/test_core/test_file/test_file.py b/plotly/tests/test_core/test_file/test_file.py index cd4ea5b9a47..c8b3bb8680a 100644 --- a/plotly/tests/test_core/test_file/test_file.py +++ b/plotly/tests/test_core/test_file/test_file.py @@ -49,7 +49,7 @@ def test_duplicate_folders(self): py.file_ops.mkdirs(first_folder) try: py.file_ops.mkdirs(first_folder) - except requests.exceptions.RequestException as e: - self.assertTrue(400 <= e.response.status_code < 500) + except PlotlyRequestError as e: + self.assertTrue(400 <= e.status_code < 500) else: self.fail('Expected this to fail!') diff --git a/plotly/tests/test_core/test_get_requests/test_get_requests.py b/plotly/tests/test_core/test_get_requests/test_get_requests.py index 4c4fd939e47..1719d86b38d 100644 --- a/plotly/tests/test_core/test_get_requests/test_get_requests.py +++ b/plotly/tests/test_core/test_get_requests/test_get_requests.py @@ -6,11 +6,11 @@ """ import copy -import json -import requests +import requests import six from nose.plugins.attrib import attr +from requests.compat import json as _json default_headers = {'plotly-username': '', @@ -37,9 +37,9 @@ def test_user_does_not_exist(): resource = "/apigetfile/{0}/{1}/".format(file_owner, file_id) response = requests.get(server + resource, headers=hd) if six.PY3: - content = json.loads(response.content.decode('unicode_escape')) + content = _json.loads(response.content.decode('unicode_escape')) else: - content = json.loads(response.content) + content = _json.loads(response.content) print(response.status_code) print(content) assert response.status_code == 404 @@ -60,9 +60,9 @@ def test_file_does_not_exist(): resource = "/apigetfile/{0}/{1}/".format(file_owner, file_id) response = requests.get(server + resource, headers=hd) if six.PY3: - content = json.loads(response.content.decode('unicode_escape')) + content = _json.loads(response.content.decode('unicode_escape')) else: - content = json.loads(response.content) + content = _json.loads(response.content) print(response.status_code) print(content) assert response.status_code == 404 @@ -100,9 +100,9 @@ def test_private_permission_defined(): resource = "/apigetfile/{0}/{1}/".format(file_owner, file_id) response = requests.get(server + resource, headers=hd) if six.PY3: - content = json.loads(response.content.decode('unicode_escape')) + content = _json.loads(response.content.decode('unicode_escape')) else: - content = json.loads(response.content) + content = _json.loads(response.content) print(response.status_code) print(content) assert response.status_code == 403 @@ -122,9 +122,9 @@ def test_missing_headers(): del hd[header] response = requests.get(server + resource, headers=hd) if six.PY3: - content = json.loads(response.content.decode('unicode_escape')) + content = _json.loads(response.content.decode('unicode_escape')) else: - content = json.loads(response.content) + content = _json.loads(response.content) print(response.status_code) print(content) assert response.status_code == 422 @@ -142,13 +142,13 @@ def test_valid_request(): resource = "/apigetfile/{0}/{1}/".format(file_owner, file_id) response = requests.get(server + resource, headers=hd) if six.PY3: - content = json.loads(response.content.decode('unicode_escape')) + content = _json.loads(response.content.decode('unicode_escape')) else: - content = json.loads(response.content) + content = _json.loads(response.content) print(response.status_code) print(content) assert response.status_code == 200 - # content = json.loads(res.content) + # content = _json.loads(res.content) # response_payload = content['payload'] # figure = response_payload['figure'] # if figure['data'][0]['x'] != [u'1', u'2', u'3']: diff --git a/plotly/tests/test_core/test_graph_reference/test_graph_reference.py b/plotly/tests/test_core/test_graph_reference/test_graph_reference.py index 38ac0b872b7..a1ba5d9bc99 100644 --- a/plotly/tests/test_core/test_graph_reference/test_graph_reference.py +++ b/plotly/tests/test_core/test_graph_reference/test_graph_reference.py @@ -4,19 +4,20 @@ """ from __future__ import absolute_import -import json import os from pkg_resources import resource_string from unittest import TestCase -import requests -import six from nose.plugins.attrib import attr +from requests.compat import json as _json from plotly import files, graph_reference as gr, tools, utils +from plotly.api import v2 from plotly.graph_reference import string_to_class_name, get_role from plotly.tests.utils import PlotlyTestCase +FAKE_API_DOMAIN = 'https://api.am.not.here.ly' + class TestGraphReferenceCaching(PlotlyTestCase): @@ -40,7 +41,7 @@ def test_get_graph_reference_bad_request_local_copy(self): # if the request fails (mocked by using a bad url here) and a local # copy of the graph reference exists, we can just use that. - tools.set_config_file(plotly_api_domain='api.am.not.here.ly') + tools.set_config_file(plotly_api_domain=FAKE_API_DOMAIN) local_graph_reference = {'real': 'local'} self.set_graph_reference(local_graph_reference) graph_reference = gr.get_graph_reference() @@ -50,30 +51,23 @@ def test_get_graph_reference_bad_request_no_copy(self): # if we don't have a graph reference we load an outdated default - tools.set_config_file(plotly_api_domain='api.am.not.here.ly') + tools.set_config_file(plotly_api_domain=FAKE_API_DOMAIN) empty_graph_reference = {} # set it to a false-y value. self.set_graph_reference(empty_graph_reference) path = os.path.join('graph_reference', 'default-schema.json') s = resource_string('plotly', path).decode('utf-8') - default_graph_reference = json.loads(s) + default_graph_reference = _json.loads(s) graph_reference = gr.get_graph_reference() self.assertEqual(graph_reference, default_graph_reference) @attr('slow') def test_default_schema_is_up_to_date(self): - api_domain = files.FILE_CONTENT[files.CONFIG_FILE]['plotly_api_domain'] - graph_reference_url = '{}{}?sha1'.format(api_domain, - gr.GRAPH_REFERENCE_PATH) - response = requests.get(graph_reference_url) - if six.PY3: - content = str(response.content, encoding='utf-8') - else: - content = response.content - schema = json.loads(content)['schema'] + response = v2.plot_schema.retrieve('') + schema = response.json()['schema'] path = os.path.join('graph_reference', 'default-schema.json') s = resource_string('plotly', path).decode('utf-8') - default_schema = json.loads(s) + default_schema = _json.loads(s) msg = ( 'The default, hard-coded plot schema we ship with pip is out of ' diff --git a/plotly/tests/test_core/test_grid/test_grid.py b/plotly/tests/test_core/test_grid/test_grid.py index d711fd8fd73..4ccd3690136 100644 --- a/plotly/tests/test_core/test_grid/test_grid.py +++ b/plotly/tests/test_core/test_grid/test_grid.py @@ -9,7 +9,6 @@ import random import string -import requests from nose import with_setup from nose.plugins.attrib import attr @@ -17,10 +16,10 @@ from unittest import skip import plotly.plotly as py -from plotly.exceptions import InputError, PlotlyRequestError +from plotly.exceptions import InputError, PlotlyRequestError, PlotlyError from plotly.graph_objs import Scatter from plotly.grid_objs import Column, Grid -from plotly.plotly.plotly import _api_v2 +from plotly.plotly.plotly import parse_grid_id_args def random_filename(): @@ -124,19 +123,18 @@ def test_get_figure_from_references(): def test_grid_id_args(): assert( - _api_v2.parse_grid_id_args(_grid, None) == - _api_v2.parse_grid_id_args(None, _grid_url) + parse_grid_id_args(_grid, None) == parse_grid_id_args(None, _grid_url) ) @raises(InputError) def test_no_grid_id_args(): - _api_v2.parse_grid_id_args(None, None) + parse_grid_id_args(None, None) @raises(InputError) def test_overspecified_grid_args(): - _api_v2.parse_grid_id_args(_grid, _grid_url) + parse_grid_id_args(_grid, _grid_url) # Out of order usage @@ -149,8 +147,7 @@ def test_scatter_from_non_uploaded_grid(): Scatter(xsrc=g[0], ysrc=g[1]) -@attr('slow') -@raises(requests.exceptions.HTTPError) +@raises(PlotlyError) def test_column_append_of_non_uploaded_grid(): c1 = Column([1, 2, 3, 4], 'first column') c2 = Column(['a', 'b', 'c', 'd'], 'second column') @@ -158,8 +155,7 @@ def test_column_append_of_non_uploaded_grid(): py.grid_ops.append_columns([c2], grid=g) -@attr('slow') -@raises(requests.exceptions.HTTPError) +@raises(PlotlyError) def test_row_append_of_non_uploaded_grid(): c1 = Column([1, 2, 3, 4], 'first column') rows = [[1], [2]] diff --git a/plotly/tests/test_core/test_offline/test_offline.py b/plotly/tests/test_core/test_offline/test_offline.py index f845709a287..cc4b903de71 100644 --- a/plotly/tests/test_core/test_offline/test_offline.py +++ b/plotly/tests/test_core/test_offline/test_offline.py @@ -4,12 +4,13 @@ """ from __future__ import absolute_import -from nose.tools import raises from unittest import TestCase -from plotly.tests.utils import PlotlyTestCase -import json + +from requests.compat import json as _json import plotly +from plotly.tests.utils import PlotlyTestCase + fig = { 'data': [ @@ -35,8 +36,9 @@ def _read_html(self, file_url): return f.read() def test_default_plot_generates_expected_html(self): - data_json = json.dumps(fig['data'], cls=plotly.utils.PlotlyJSONEncoder) - layout_json = json.dumps( + data_json = _json.dumps(fig['data'], + cls=plotly.utils.PlotlyJSONEncoder) + layout_json = _json.dumps( fig['layout'], cls=plotly.utils.PlotlyJSONEncoder) diff --git a/plotly/tests/test_core/test_plotly/test_plot.py b/plotly/tests/test_core/test_plotly/test_plot.py index 25b6d208aa3..77e9c84d6b3 100644 --- a/plotly/tests/test_core/test_plotly/test_plot.py +++ b/plotly/tests/test_core/test_plotly/test_plot.py @@ -7,9 +7,9 @@ """ from __future__ import absolute_import -import json import requests import six +from requests.compat import json as _json from unittest import TestCase from nose.plugins.attrib import attr @@ -296,10 +296,10 @@ def generate_conflicting_plot_options_with_json_writes_of_config(): """ def gen_test(plot_options): def test(self): - config = json.load(open(CONFIG_FILE)) + config = _json.load(open(CONFIG_FILE)) with open(CONFIG_FILE, 'w') as f: config.update(plot_options) - f.write(json.dumps(config)) + f.write(_json.dumps(config)) self.assertRaises(PlotlyError, py._plot_option_logic, {}) return test diff --git a/plotly/tests/test_core/test_utils/test_utils.py b/plotly/tests/test_core/test_utils/test_utils.py index cb38648b8b6..b406a6464ab 100644 --- a/plotly/tests/test_core/test_utils/test_utils.py +++ b/plotly/tests/test_core/test_utils/test_utils.py @@ -1,8 +1,9 @@ from __future__ import absolute_import -import json from unittest import TestCase +from requests.compat import json as _json + from plotly.utils import PlotlyJSONEncoder, get_by_path, node_generator @@ -10,7 +11,7 @@ class TestJSONEncoder(TestCase): def test_nan_to_null(self): array = [1, float('NaN'), float('Inf'), float('-Inf'), 'platypus'] - result = json.dumps(array, cls=PlotlyJSONEncoder) + result = _json.dumps(array, cls=PlotlyJSONEncoder) expected_result = '[1, null, null, null, "platypus"]' self.assertEqual(result, expected_result) diff --git a/plotly/tests/test_optional/test_offline/test_offline.py b/plotly/tests/test_optional/test_offline/test_offline.py index 93d2c4c3770..61fb1840cfb 100644 --- a/plotly/tests/test_optional/test_offline/test_offline.py +++ b/plotly/tests/test_optional/test_offline/test_offline.py @@ -6,9 +6,9 @@ from nose.tools import raises from nose.plugins.attrib import attr +from requests.compat import json as _json from unittest import TestCase -import json import plotly @@ -75,8 +75,8 @@ def test_default_mpl_plot_generates_expected_html(self): figure = plotly.tools.mpl_to_plotly(fig) data = figure['data'] layout = figure['layout'] - data_json = json.dumps(data, cls=plotly.utils.PlotlyJSONEncoder) - layout_json = json.dumps(layout, cls=plotly.utils.PlotlyJSONEncoder) + data_json = _json.dumps(data, cls=plotly.utils.PlotlyJSONEncoder) + layout_json = _json.dumps(layout, cls=plotly.utils.PlotlyJSONEncoder) html = self._read_html(plotly.offline.plot_mpl(fig)) # just make sure a few of the parts are in here diff --git a/plotly/tests/test_optional/test_utils/test_utils.py b/plotly/tests/test_optional/test_utils/test_utils.py index 9cefd68ef35..ae0cfb3fbb6 100644 --- a/plotly/tests/test_optional/test_utils/test_utils.py +++ b/plotly/tests/test_optional/test_utils/test_utils.py @@ -5,7 +5,6 @@ from __future__ import absolute_import import datetime -import json import math from datetime import datetime as dt from unittest import TestCase @@ -15,6 +14,7 @@ import pytz from nose.plugins.attrib import attr from pandas.util.testing import assert_series_equal +from requests.compat import json as _json from plotly import utils from plotly.graph_objs import Scatter, Scatter3d, Figure, Data @@ -171,7 +171,7 @@ def test_column_json_encoding(): Column(mixed_list, 'col 2'), Column(np_list, 'col 3') ] - json_columns = json.dumps( + json_columns = _json.dumps( columns, cls=utils.PlotlyJSONEncoder, sort_keys=True ) assert('[{"data": [1, 2, 3], "name": "col 1"}, ' @@ -190,8 +190,8 @@ def test_figure_json_encoding(): data = Data([s1, s2]) figure = Figure(data=data) - js1 = json.dumps(s1, cls=utils.PlotlyJSONEncoder, sort_keys=True) - js2 = json.dumps(s2, cls=utils.PlotlyJSONEncoder, sort_keys=True) + js1 = _json.dumps(s1, cls=utils.PlotlyJSONEncoder, sort_keys=True) + js2 = _json.dumps(s2, cls=utils.PlotlyJSONEncoder, sort_keys=True) assert(js1 == '{"type": "scatter3d", "x": [1, 2, 3], ' '"y": [1, 2, 3, null, null, null, "2014-01-05"], ' @@ -200,8 +200,8 @@ def test_figure_json_encoding(): assert(js2 == '{"type": "scatter", "x": [1, 2, 3]}') # Test JSON encoding works - json.dumps(data, cls=utils.PlotlyJSONEncoder, sort_keys=True) - json.dumps(figure, cls=utils.PlotlyJSONEncoder, sort_keys=True) + _json.dumps(data, cls=utils.PlotlyJSONEncoder, sort_keys=True) + _json.dumps(figure, cls=utils.PlotlyJSONEncoder, sort_keys=True) # Test data wasn't mutated assert(bool(np.asarray(np_list == @@ -213,18 +213,18 @@ def test_figure_json_encoding(): def test_datetime_json_encoding(): - j1 = json.dumps(dt_list, cls=utils.PlotlyJSONEncoder) + j1 = _json.dumps(dt_list, cls=utils.PlotlyJSONEncoder) assert(j1 == '["2014-01-05", ' '"2014-01-05 01:01:01", ' '"2014-01-05 01:01:01.000001"]') - j2 = json.dumps({"x": dt_list}, cls=utils.PlotlyJSONEncoder) + j2 = _json.dumps({"x": dt_list}, cls=utils.PlotlyJSONEncoder) assert(j2 == '{"x": ["2014-01-05", ' '"2014-01-05 01:01:01", ' '"2014-01-05 01:01:01.000001"]}') def test_pandas_json_encoding(): - j1 = json.dumps(df['col 1'], cls=utils.PlotlyJSONEncoder) + j1 = _json.dumps(df['col 1'], cls=utils.PlotlyJSONEncoder) assert(j1 == '[1, 2, 3, "2014-01-05", null, null, null]') # Test that data wasn't mutated @@ -232,28 +232,28 @@ def test_pandas_json_encoding(): pd.Series([1, 2, 3, dt(2014, 1, 5), pd.NaT, np.NaN, np.Inf], name='col 1')) - j2 = json.dumps(df.index, cls=utils.PlotlyJSONEncoder) + j2 = _json.dumps(df.index, cls=utils.PlotlyJSONEncoder) assert(j2 == '[0, 1, 2, 3, 4, 5, 6]') nat = [pd.NaT] - j3 = json.dumps(nat, cls=utils.PlotlyJSONEncoder) + j3 = _json.dumps(nat, cls=utils.PlotlyJSONEncoder) assert(j3 == '[null]') assert(nat[0] is pd.NaT) - j4 = json.dumps(rng, cls=utils.PlotlyJSONEncoder) + j4 = _json.dumps(rng, cls=utils.PlotlyJSONEncoder) assert(j4 == '["2011-01-01", "2011-01-01 01:00:00"]') - j5 = json.dumps(ts, cls=utils.PlotlyJSONEncoder) + j5 = _json.dumps(ts, cls=utils.PlotlyJSONEncoder) assert(j5 == '[1.5, 2.5]') assert_series_equal(ts, pd.Series([1.5, 2.5], index=rng)) - j6 = json.dumps(ts.index, cls=utils.PlotlyJSONEncoder) + j6 = _json.dumps(ts.index, cls=utils.PlotlyJSONEncoder) assert(j6 == '["2011-01-01", "2011-01-01 01:00:00"]') def test_numpy_masked_json_encoding(): l = [1, 2, np.ma.core.masked] - j1 = json.dumps(l, cls=utils.PlotlyJSONEncoder) + j1 = _json.dumps(l, cls=utils.PlotlyJSONEncoder) print(j1) assert(j1 == '[1, 2, null]') @@ -277,18 +277,18 @@ def test_masked_constants_example(): renderer = PlotlyRenderer() Exporter(renderer).run(fig) - json.dumps(renderer.plotly_fig, cls=utils.PlotlyJSONEncoder) + _json.dumps(renderer.plotly_fig, cls=utils.PlotlyJSONEncoder) - jy = json.dumps(renderer.plotly_fig['data'][1]['y'], + jy = _json.dumps(renderer.plotly_fig['data'][1]['y'], cls=utils.PlotlyJSONEncoder) print(jy) - array = json.loads(jy) + array = _json.loads(jy) assert(array == [-398.11793027, -398.11792966, -398.11786308, None]) def test_numpy_dates(): a = np.arange(np.datetime64('2011-07-11'), np.datetime64('2011-07-18')) - j1 = json.dumps(a, cls=utils.PlotlyJSONEncoder) + j1 = _json.dumps(a, cls=utils.PlotlyJSONEncoder) assert(j1 == '["2011-07-11", "2011-07-12", "2011-07-13", ' '"2011-07-14", "2011-07-15", "2011-07-16", ' '"2011-07-17"]') @@ -296,5 +296,5 @@ def test_numpy_dates(): def test_datetime_dot_date(): a = [datetime.date(2014, 1, 1), datetime.date(2014, 1, 2)] - j1 = json.dumps(a, cls=utils.PlotlyJSONEncoder) + j1 = _json.dumps(a, cls=utils.PlotlyJSONEncoder) assert(j1 == '["2014-01-01", "2014-01-02"]') diff --git a/plotly/tests/utils.py b/plotly/tests/utils.py index fb837d74bfa..267afe8ae12 100644 --- a/plotly/tests/utils.py +++ b/plotly/tests/utils.py @@ -1,5 +1,4 @@ import copy -import json from numbers import Number as Num from unittest import TestCase diff --git a/plotly/tools.py b/plotly/tools.py index 0b78c535996..234e94a37d2 100644 --- a/plotly/tools.py +++ b/plotly/tools.py @@ -19,7 +19,6 @@ from plotly import colors from plotly import utils from plotly import exceptions -from plotly import graph_reference from plotly import session from plotly.files import (CONFIG_FILE, CREDENTIALS_FILE, FILE_CONTENT, GRAPH_REFERENCE_FILE, check_file_permissions) @@ -127,7 +126,7 @@ def get_config_defaults(): return dict(FILE_CONTENT[CONFIG_FILE]) # performs a shallow copy -def ensure_local_plotly_files(): +def ensure_local_plotly_files(graph_reference=None): """Ensure that filesystem is setup/filled out in a valid way. If the config or credential files aren't filled out, then write them to the disk. @@ -148,8 +147,9 @@ def ensure_local_plotly_files(): # make a request to get graph reference if DNE. utils.ensure_file_exists(GRAPH_REFERENCE_FILE) - utils.save_json_dict(GRAPH_REFERENCE_FILE, - graph_reference.GRAPH_REFERENCE) + if graph_reference is not None: + # This is a workaround to keep from having weird circular imports. + utils.save_json_dict(GRAPH_REFERENCE_FILE, graph_reference) else: warnings.warn("Looks like you don't have 'read-write' permission to " @@ -1362,6 +1362,7 @@ def validate(obj, obj_type): """ # TODO: Deprecate or move. #283 + from plotly import graph_reference from plotly.graph_objs import graph_objs if obj_type not in graph_reference.CLASSES: diff --git a/plotly/utils.py b/plotly/utils.py index 841d9a9d305..d1456d03693 100644 --- a/plotly/utils.py +++ b/plotly/utils.py @@ -7,7 +7,6 @@ """ from __future__ import absolute_import -import json import os.path import re import sys @@ -15,6 +14,8 @@ import pytz +from requests.compat import json as _json + from . exceptions import PlotlyError @@ -50,7 +51,7 @@ def load_json_dict(filename, *args): lock.acquire() with open(filename, "r") as f: try: - data = json.load(f) + data = _json.load(f) if not isinstance(data, dict): data = {} except: @@ -65,7 +66,7 @@ def save_json_dict(filename, json_dict): """Save json to file. Error if path DNE, not a dict, or invalid json.""" if isinstance(json_dict, dict): # this will raise a TypeError if something goes wrong - json_string = json.dumps(json_dict, indent=4) + json_string = _json.dumps(json_dict, indent=4) lock.acquire() with open(filename, "w") as f: f.write(json_string) @@ -111,7 +112,7 @@ class NotEncodable(Exception): pass -class PlotlyJSONEncoder(json.JSONEncoder): +class PlotlyJSONEncoder(_json.JSONEncoder): """ Meant to be passed as the `cls` kwarg to json.dumps(obj, cls=..) @@ -148,7 +149,8 @@ def encode(self, o): # 1. `loads` to switch Infinity, -Infinity, NaN to None # 2. `dumps` again so you get 'null' instead of extended JSON try: - new_o = json.loads(encoded_o, parse_constant=self.coerce_to_strict) + new_o = _json.loads(encoded_o, + parse_constant=self.coerce_to_strict) except ValueError: # invalid separators will fail here. raise a helpful exception @@ -157,10 +159,10 @@ def encode(self, o): "valid JSON separators?" ) else: - return json.dumps(new_o, sort_keys=self.sort_keys, - indent=self.indent, - separators=(self.item_separator, - self.key_separator)) + return _json.dumps(new_o, sort_keys=self.sort_keys, + indent=self.indent, + separators=(self.item_separator, + self.key_separator)) def default(self, obj): """ @@ -208,7 +210,7 @@ def default(self, obj): return encoding_method(obj) except NotEncodable: pass - return json.JSONEncoder.default(self, obj) + return _json.JSONEncoder.default(self, obj) @staticmethod def encode_as_plotly(obj): diff --git a/plotly/widgets/graph_widget.py b/plotly/widgets/graph_widget.py index ea400d95717..bdc694ad690 100644 --- a/plotly/widgets/graph_widget.py +++ b/plotly/widgets/graph_widget.py @@ -2,11 +2,11 @@ Module to allow Plotly graphs to interact with IPython widgets. """ -import json import uuid from collections import deque from pkg_resources import resource_string +from requests.compat import json as _json # TODO: protected imports? from IPython.html import widgets @@ -93,7 +93,7 @@ def _handle_msg(self, message): while self._clientMessages: _message = self._clientMessages.popleft() _message['graphId'] = self._graphId - _message = json.dumps(_message) + _message = _json.dumps(_message) self._message = _message if content.get('event', '') in ['click', 'hover', 'zoom']: @@ -131,7 +131,7 @@ def _handle_outgoing_message(self, message): else: message['graphId'] = self._graphId message['uid'] = str(uuid.uuid4()) - self._message = json.dumps(message, cls=utils.PlotlyJSONEncoder) + self._message = _json.dumps(message, cls=utils.PlotlyJSONEncoder) def on_click(self, callback, remove=False): """ Assign a callback to click events propagated diff --git a/setup.py b/setup.py index e72f78ae770..66dd24cccf3 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,9 @@ def readme(): ], license='MIT', packages=['plotly', + 'plotly/api', + 'plotly/api/v1', + 'plotly/api/v2', 'plotly/plotly', 'plotly/plotly/chunked_requests', 'plotly/graph_objs',