""" This module handles accessing, storing, and managing the graph reference. """ from __future__ import absolute_import import os import re from pkg_resources import resource_string import six from requests.compat import json as _json from plotly import utils # For backwards compat, we keep this list of previously known objects. # Moving forward, we only add new trace names. # {: {'object_name': , 'base_type': } _BACKWARDS_COMPAT_CLASS_NAMES = { 'AngularAxis': {'object_name': 'angularaxis', 'base_type': dict}, 'Annotation': {'object_name': 'annotation', 'base_type': dict}, 'Annotations': {'object_name': 'annotations', 'base_type': list}, 'Area': {'object_name': 'area', 'base_type': dict}, 'Bar': {'object_name': 'bar', 'base_type': dict}, 'Box': {'object_name': 'box', 'base_type': dict}, 'ColorBar': {'object_name': 'colorbar', 'base_type': dict}, 'Contour': {'object_name': 'contour', 'base_type': dict}, 'Contours': {'object_name': 'contours', 'base_type': dict}, 'Data': {'object_name': 'data', 'base_type': list}, 'ErrorX': {'object_name': 'error_x', 'base_type': dict}, 'ErrorY': {'object_name': 'error_y', 'base_type': dict}, 'ErrorZ': {'object_name': 'error_z', 'base_type': dict}, 'Figure': {'object_name': 'figure', 'base_type': dict}, 'Font': {'object_name': 'font', 'base_type': dict}, 'Frames': {'object_name': 'frames', 'base_type': list}, 'Heatmap': {'object_name': 'heatmap', 'base_type': dict}, 'Histogram': {'object_name': 'histogram', 'base_type': dict}, 'Histogram2d': {'object_name': 'histogram2d', 'base_type': dict}, 'Histogram2dContour': {'object_name': 'histogram2dcontour', 'base_type': dict}, 'Layout': {'object_name': 'layout', 'base_type': dict}, 'Legend': {'object_name': 'legend', 'base_type': dict}, 'Line': {'object_name': 'line', 'base_type': dict}, 'Margin': {'object_name': 'margin', 'base_type': dict}, 'Marker': {'object_name': 'marker', 'base_type': dict}, 'RadialAxis': {'object_name': 'radialaxis', 'base_type': dict}, 'Scatter': {'object_name': 'scatter', 'base_type': dict}, 'Scatter3d': {'object_name': 'scatter3d', 'base_type': dict}, 'Scene': {'object_name': 'scene', 'base_type': dict}, 'Stream': {'object_name': 'stream', 'base_type': dict}, 'Surface': {'object_name': 'surface', 'base_type': dict}, 'Trace': {'object_name': None, 'base_type': dict}, 'XAxis': {'object_name': 'xaxis', 'base_type': dict}, 'XBins': {'object_name': 'xbins', 'base_type': dict}, 'YAxis': {'object_name': 'yaxis', 'base_type': dict}, 'YBins': {'object_name': 'ybins', 'base_type': dict}, 'ZAxis': {'object_name': 'zaxis', 'base_type': dict} } def get_graph_reference(): """ Load graph reference JSON (aka plot-schema) :return: (dict) The graph reference. """ path = os.path.join('package_data', 'default-schema.json') s = resource_string('plotly', path).decode('utf-8') graph_reference = utils.decode_unicode(_json.loads(s)) # TODO: Patch in frames info until it hits streambed. See #659 graph_reference['frames'] = { "items": { "frames_entry": { "baseframe": { "description": "The name of the frame into which this " "frame's properties are merged before " "applying. This is used to unify " "properties and avoid needing to specify " "the same values for the same properties " "in multiple frames.", "role": "info", "valType": "string" }, "data": { "description": "A list of traces this frame modifies. " "The format is identical to the normal " "trace definition.", "role": "object", "valType": "any" }, "group": { "description": "An identifier that specifies the group " "to which the frame belongs, used by " "animate to select a subset of frames.", "role": "info", "valType": "string" }, "layout": { "role": "object", "description": "Layout properties which this frame " "modifies. The format is identical to " "the normal layout definition.", "valType": "any" }, "name": { "description": "A label by which to identify the frame", "role": "info", "valType": "string" }, "role": "object", "traces": { "description": "A list of trace indices that identify " "the respective traces in the data " "attribute", "role": "info", "valType": "info_array" } } }, "role": "object" } return graph_reference def string_to_class_name(string): """ Single function to handle turning object names into class names. GRAPH_REFERENCE has names like `error_y`, which we'll turn into `ErrorY`. :param (str) string: A string that we'll turn into a class name string. :return: (str) """ # capitalize first letter string = re.sub(r'[A-Za-z]', lambda m: m.group().title(), string, count=1) # replace `*_` with `*` E.g., `Error_x` --> `ErrorX` string = re.sub(r'_[A-Za-z0-9]+', lambda m: m.group()[1:].title(), string) return str(string) def object_name_to_class_name(object_name): """Not all objects have classes auto-generated.""" if object_name in TRACE_NAMES: return string_to_class_name(object_name) if object_name in OBJECT_NAME_TO_CLASS_NAME: return OBJECT_NAME_TO_CLASS_NAME[object_name] if object_name in ARRAYS: return 'list' else: return 'dict' def get_attributes_dicts(object_name, parent_object_names=()): """ Returns *all* attribute information given the context of parents. The response has the form: { ('some', 'path'): {}, ('some', 'other', 'path'): {}, ... 'additional_attributes': {} } There may be any number of paths mapping to attribute dicts. There will be one attribute dict under 'additional_attributes' which will usually be empty. :param (str|unicode) object_name: The object name whose attributes we want. :param (list[str|unicode]) parent_object_names: Names of parent objects. :return: (dict) """ object_dict = OBJECTS[object_name] # If we patched this object, we may have added hard-coded attrs. additional_attributes = object_dict['additional_attributes'] # We should also one or more paths where attributes are defined. attribute_paths = list(object_dict['attribute_paths']) # shallow copy # Map frame 'data' and 'layout' to previously-defined figure attributes. # Examples of parent_object_names changes: # ['figure', 'frames'] --> ['figure', 'frames'] # ['figure', 'frames', FRAME_NAME] --> ['figure'] # ['figure', 'frames', FRAME_NAME, 'data'] --> ['figure', 'data'] # ['figure', 'frames', FRAME_NAME, 'layout'] --> ['figure', 'layout'] # ['figure', 'frames', FRAME_NAME, 'foo'] --> # ['figure', 'frames', FRAME_NAME, 'foo'] # [FRAME_NAME, 'layout'] --> ['figure', 'layout'] if FRAME_NAME in parent_object_names: len_parent_object_names = len(parent_object_names) index = parent_object_names.index(FRAME_NAME) if len_parent_object_names == index + 1: if object_name in ('data', 'layout'): parent_object_names = ['figure', object_name] elif len_parent_object_names > index + 1: if parent_object_names[index + 1] in ('data', 'layout'): parent_object_names = ( ['figure'] + list(parent_object_names)[index + 1:] ) # If we have parent_names, some of these attribute paths may be invalid. for parent_object_name in reversed(parent_object_names): if parent_object_name in ARRAYS: continue parent_object_dict = OBJECTS[parent_object_name] parent_attribute_paths = parent_object_dict['attribute_paths'] for path in list(attribute_paths): if not _is_valid_sub_path(path, parent_attribute_paths): attribute_paths.remove(path) # We return a dict mapping paths to attributes. We also add in additional # attributes if defined. attributes_dicts = {path: utils.get_by_path(GRAPH_REFERENCE, path) for path in attribute_paths} attributes_dicts['additional_attributes'] = additional_attributes return attributes_dicts @utils.memoize() def _get_valid_attributes(object_name, parent_object_names): attributes = get_attributes_dicts(object_name, parent_object_names) # These are for documentation and quick lookups. They're just strings. valid_attributes = set() for attributes_dict in attributes.values(): for key, val in attributes_dict.items(): if key not in GRAPH_REFERENCE['defs']['metaKeys']: valid_attributes.add(key) deprecated_attributes = attributes_dict.get('_deprecated', {}) for key, val in deprecated_attributes.items(): if key not in GRAPH_REFERENCE['defs']['metaKeys']: valid_attributes.add(key) return valid_attributes def get_valid_attributes(object_name, parent_object_names=()): # Enforce that parent_object_names is hashable (a tuple). return _get_valid_attributes(object_name, tuple(parent_object_names)) def get_deprecated_attributes(object_name, parent_object_names=()): attributes = get_attributes_dicts(object_name, parent_object_names) # These are for documentation and quick lookups. They're just strings. deprecated_attributes = set() for attributes_dict in attributes.values(): deprecated_attributes_dict = attributes_dict.get('_deprecated', {}) for key, val in deprecated_attributes_dict.items(): if key not in GRAPH_REFERENCE['defs']['metaKeys']: deprecated_attributes.add(key) return deprecated_attributes def get_subplot_attributes(object_name, parent_object_names=()): attributes = get_attributes_dicts(object_name, parent_object_names) # These are for documentation and quick lookups. They're just strings. subplot_attributes = set() for attributes_dict in attributes.values(): for key, val in attributes_dict.items(): if key not in GRAPH_REFERENCE['defs']['metaKeys']: if isinstance(val, dict) and val.get('_isSubplotObj'): subplot_attributes.add(key) deprecated_attributes = attributes_dict.get('_deprecated', {}) for key, val in deprecated_attributes.items(): if key not in GRAPH_REFERENCE['defs']['metaKeys']: if isinstance(val, dict) and val.get('_isSubplotObj'): subplot_attributes.add(key) return subplot_attributes def attribute_path_to_object_names(attribute_container_path): """ Return a location within a figure from a path existing in GRAPH_REFERENCE. Users don't need to know about GRAPH_REFERENCE, so yielding information about paths there would only be confusing. Also, the implementation and structure there may change, but figure structure won't. :param (tuple[str]) attribute_container_path: An object should exist here. :return: (tuple[str]) A tuple of object names: Example: In: ('traces', 'pie', 'attributes', 'marker') Out: ('figure', 'data', 'pie', 'marker') """ object_names = ['figure'] # this is always the case if 'layout' in attribute_container_path: for path_part in attribute_container_path: if path_part in OBJECTS: object_names.append(path_part) if path_part in ARRAYS: object_names.append(path_part) object_names.append(path_part[:-1]) elif 'layoutAttributes' in attribute_container_path: object_names.append('layout') start_index = attribute_container_path.index('layoutAttributes') for path_part in attribute_container_path[start_index:]: if path_part in OBJECTS: object_names.append(path_part) if path_part in ARRAYS: object_names.append(path_part) object_names.append(path_part[:-1]) else: # assume it's in 'traces' object_names.append('data') for path_part in attribute_container_path: if path_part in OBJECTS: object_names.append(path_part) if path_part in ARRAYS: object_names.append(path_part) object_names.append(path_part[:-1]) return tuple(object_names) @utils.memoize() def _get_role(object_name, attribute, value_type, parent_object_names=()): """Private, more easily memoized version of get_role.""" if attribute == 'type' and object_name in TRACE_NAMES: return 'info' attributes_dicts = get_attributes_dicts(object_name, parent_object_names) matches = [] for attributes_dict in attributes_dicts.values(): for key, val in attributes_dict.items(): if key == attribute: matches.append(val) for key, val in attributes_dict.get('_deprecated', {}).items(): if key == attribute: matches.append(val) roles = [] for match in matches: role = match['role'] array_ok = match.get('arrayOk') if array_ok and value_type == 'array': role = 'data' roles.append(role) # TODO: this is ambiguous until the figure is in place... if 'data' in roles: role = 'data' else: role = roles[0] return role def get_role(object_name, attribute, value=None, parent_object_names=()): """ Values have types associated with them based on graph_reference. 'data' type values are always kept 'style' values are kept if they're sequences (but not strings) :param (str) object_name: The name of the object containing 'attribute'. :param (str) attribute: The attribute we want the `role` of. :param (*) value: If the value is an array, the return can be different. :param parent_object_names: An iterable of obj names from graph reference. :returns: (str) This will be 'data', 'style', or 'info'. """ if value is None: value_type = 'none' elif isinstance(value, dict): value_type = 'dict' elif isinstance(value, six.string_types): value_type = 'string' elif hasattr(value, '__iter__'): value_type = 'array' else: value_type = 'unknown' # Enforce that parent_object_names is hashable (a tuple). return _get_role(object_name, attribute, value_type, tuple(parent_object_names)) def _is_valid_sub_path(path, parent_paths): """ Check if a sub path is valid given an iterable of parent paths. :param (tuple[str]) path: The path that may be a sub path. :param (list[tuple]) parent_paths: The known parent paths. :return: (bool) Examples: * ('a', 'b', 'c') is a valid subpath of ('a', ) * ('a', 'd') is not a valid subpath of ('b', ) * ('a', ) is not a valid subpath of ('a', 'b') * ('anything',) is a valid subpath of () """ if not parent_paths: return True for parent_path in parent_paths: if path[:len(parent_path)] == parent_path: return True return False def _get_objects(): """ Create a reorganization of graph reference which organizes by object name. Each object can have *many* different definitions in the graph reference. These possibilities get narrowed down when we have contextual information about parent objects. For instance, Marker in Scatter has a different definition than Marker in Pie. However, we need Marker, Scatter, and Pie to exist on their own as well. Each value has the form: { 'meta_paths': [], 'attribute_paths': [], 'additional_attributes': {} } * meta_paths describes the top-most path where this object is defined * attribute_paths describes all the locations where attributes exist * additional_attributes can be used to hard-code (patch) the plot schema :return: (dict) """ objects = {} for node, path in utils.node_generator(GRAPH_REFERENCE): if any([key in path for key in GRAPH_REFERENCE['defs']['metaKeys']]): continue # objects don't exist under nested meta keys if node.get('role') != 'object': continue if 'items' in node: continue object_name = path[-1] if object_name not in objects: objects[object_name] = {'meta_paths': [], 'attribute_paths': [], 'additional_attributes': {}} if node.get('attributes'): objects[object_name]['attribute_paths'].append( path + ('attributes', ) ) else: objects[object_name]['attribute_paths'].append(path) objects[object_name]['meta_paths'].append(path) return objects def _patch_objects(): """Things like Layout, Figure, and Data need to be included.""" layout_attribute_paths = [] for node, path in utils.node_generator(GRAPH_REFERENCE): if any([key in path for key in GRAPH_REFERENCE['defs']['metaKeys']]): continue # objects don't exist under nested meta keys if path and path[-1] == 'layoutAttributes': layout_attribute_paths.append(path) for trace_name in TRACE_NAMES: OBJECTS[trace_name] = { 'meta_paths': [('traces', trace_name)], 'attribute_paths': [('traces', trace_name, 'attributes')], 'additional_attributes': {} } OBJECTS['layout'] = {'meta_paths': [('layout', )], 'attribute_paths': layout_attribute_paths, 'additional_attributes': {}} figure_attributes = { 'layout': {'role': 'object'}, 'data': {'role': 'object', '_isLinkedToArray': True}, 'frames': {'role': 'object', '_isLinkedToArray': True} } OBJECTS['figure'] = {'meta_paths': [], 'attribute_paths': [], 'additional_attributes': figure_attributes} def _get_arrays(): """Very few arrays, but this dict is the complement of OBJECTS.""" arrays = {} for node, path in utils.node_generator(GRAPH_REFERENCE): if any([key in path for key in GRAPH_REFERENCE['defs']['metaKeys']]): continue # objects don't exist under nested meta keys if node.get('role') != 'object': continue if 'items' not in node: continue object_name = path[-1] if object_name not in arrays: items = node['items'] # If items is a dict, it's anyOf them. if isinstance(items, dict): item_names = list(items.keys()) else: item_names = [object_name[:-1]] arrays[object_name] = {'meta_paths': [path], 'items': item_names} return arrays def _patch_arrays(): """Adds information on our eventual Data array.""" ARRAYS['data'] = {'meta_paths': [('traces', )], 'items': list(TRACE_NAMES)} def _get_classes(): """ We eventually make classes out of the objects in GRAPH_REFERENCE. :return: (dict) A mapping of class names to object names. """ classes = {} # add all the objects we had before, but mark them if they no longer # exist in the graph reference for class_name, class_dict in _BACKWARDS_COMPAT_CLASS_NAMES.items(): object_name = class_dict['object_name'] base_type = class_dict['base_type'] if object_name in OBJECTS or object_name in ARRAYS: classes[class_name] = {'object_name': object_name, 'base_type': base_type} else: classes[class_name] = {'object_name': None, 'base_type': base_type} # always keep the trace dicts up to date for object_name in TRACE_NAMES: class_name = string_to_class_name(object_name) classes[class_name] = {'object_name': object_name, 'base_type': dict} return classes # The ordering here is important. GRAPH_REFERENCE = get_graph_reference() FRAME_NAME = list(GRAPH_REFERENCE['frames']['items'].keys())[0] # See http://blog.labix.org/2008/06/27/watch-out-for-listdictkeys-in-python-3 TRACE_NAMES = list(GRAPH_REFERENCE['traces'].keys()) OBJECTS = _get_objects() _patch_objects() ARRAYS = _get_arrays() _patch_arrays() CLASSES = _get_classes() OBJECT_NAME_TO_CLASS_NAME = {class_dict['object_name']: class_name for class_name, class_dict in CLASSES.items() if class_dict['object_name'] is not None}