diff --git a/CHANGELOG.md b/CHANGELOG.md index 60cda725b32..c6df26fcc27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). - The `add_trace`, `add_shape`, `add_annotation`, `add_layout_image`, `add_hline`, `add_vline`, `add_hrect`, `add_vrect` functions accept an argument `exclude_empty_subplots` which if `True`, only adds the object to subplots already containing traces or layout objects. This is useful in conjunction with the `row="all"` and `col="all"` arguments. ([#2840](https://github.com/plotly/plotly.py/pull/2840)) - For all `go.Figure` functions accepting a selector argument (e.g., `select_traces`), this argument can now also be a function which is passed each relevant graph object (in the case of `select_traces`, it is passed every trace in the figure). For graph objects where this function returns true, the graph object is included in the selection. ([#2844](https://github.com/plotly/plotly.py/pull/2844)) +### Added + +- Better magic underscore error messages. For example, `some_fig.update_layout(geo_ltaxis_showgrid=True)` shows `Bad property path:\ngeo_ltaxis_showgrid\n ^` and lists the valid properties for `geo`. + ### Updated - Updated Plotly.js to version 1.57.1. See the [plotly.js CHANGELOG](https://github.com/plotly/plotly.js/blob/v1.57.1/CHANGELOG.md) for more information. These changes are reflected in the auto-generated `plotly.graph_objects` module. diff --git a/packages/python/plotly/_plotly_utils/exceptions.py b/packages/python/plotly/_plotly_utils/exceptions.py index c1a2d6b368e..836ef59e215 100644 --- a/packages/python/plotly/_plotly_utils/exceptions.py +++ b/packages/python/plotly/_plotly_utils/exceptions.py @@ -83,3 +83,15 @@ def __init__(self, obj, path, notes=()): super(PlotlyDataTypeError, self).__init__( message=message, path=path, notes=notes ) + + +class PlotlyKeyError(KeyError): + """ + KeyErrors are not printed as beautifully as other errors (this is so that + {}[''] prints "KeyError: ''" and not "KeyError:"). So here we use + LookupError's __str__ to make a PlotlyKeyError object which will print nicer + error messages for KeyErrors. + """ + + def __str__(self): + return LookupError.__str__(self) diff --git a/packages/python/plotly/_plotly_utils/utils.py b/packages/python/plotly/_plotly_utils/utils.py index cbf8d3a6b98..9dfb803d486 100644 --- a/packages/python/plotly/_plotly_utils/utils.py +++ b/packages/python/plotly/_plotly_utils/utils.py @@ -2,6 +2,7 @@ import json as _json import sys import re +from functools import reduce from _plotly_utils.optional_imports import get_module from _plotly_utils.basevalidators import ImageUriValidator @@ -10,6 +11,20 @@ PY36_OR_LATER = sys.version_info >= (3, 6) +def cumsum(x): + """ + Custom cumsum to avoid a numpy import. + """ + + def _reducer(a, x): + if len(a) == 0: + return [x] + return a + [a[-1] + x] + + ret = reduce(_reducer, x, []) + return ret + + class PlotlyJSONEncoder(_json.JSONEncoder): """ Meant to be passed as the `cls` kwarg to json.dumps(obj, cls=..) @@ -256,3 +271,170 @@ def _get_int_type(): else: int_type = (int,) return int_type + + +def split_multichar(ss, chars): + """ + Split all the strings in ss at any of the characters in chars. + Example: + + >>> ss = ["a.string[0].with_separators"] + >>> chars = list(".[]_") + >>> split_multichar(ss, chars) + ['a', 'string', '0', '', 'with', 'separators'] + + :param (list) ss: A list of strings. + :param (list) chars: Is a list of chars (note: not a string). + """ + if len(chars) == 0: + return ss + c = chars.pop() + ss = reduce(lambda x, y: x + y, map(lambda x: x.split(c), ss)) + return split_multichar(ss, chars) + + +def split_string_positions(ss): + """ + Given a list of strings split using split_multichar, return a list of + integers representing the indices of the first character of every string in + the original string. + Example: + + >>> ss = ["a.string[0].with_separators"] + >>> chars = list(".[]_") + >>> ss_split = split_multichar(ss, chars) + >>> ss_split + ['a', 'string', '0', '', 'with', 'separators'] + >>> split_string_positions(ss_split) + [0, 2, 9, 11, 12, 17] + + :param (list) ss: A list of strings. + """ + return list( + map( + lambda t: t[0] + t[1], + zip(range(len(ss)), cumsum([0] + list(map(len, ss[:-1])))), + ) + ) + + +def display_string_positions(p, i=None, offset=0, length=1, char="^", trim=True): + """ + Return a string that is whitespace except at p[i] which is replaced with char. + If i is None then all the indices of the string in p are replaced with char. + + Example: + + >>> ss = ["a.string[0].with_separators"] + >>> chars = list(".[]_") + >>> ss_split = split_multichar(ss, chars) + >>> ss_split + ['a', 'string', '0', '', 'with', 'separators'] + >>> ss_pos = split_string_positions(ss_split) + >>> ss[0] + 'a.string[0].with_separators' + >>> display_string_positions(ss_pos,4) + ' ^' + >>> display_string_positions(ss_pos,4,offset=1,length=3,char="~",trim=False) + ' ~~~ ' + >>> display_string_positions(ss_pos) + '^ ^ ^ ^^ ^' + :param (list) p: A list of integers. + :param (integer|None) i: Optional index of p to display. + :param (integer) offset: Allows adding a number of spaces to the replacement. + :param (integer) length: Allows adding a replacement that is the char + repeated length times. + :param (str) char: allows customizing the replacement character. + :param (boolean) trim: trims the remaining whitespace if True. + """ + s = [" " for _ in range(max(p) + 1 + offset + length)] + maxaddr = 0 + if i is None: + for p_ in p: + for l in range(length): + maxaddr = p_ + offset + l + s[maxaddr] = char + else: + for l in range(length): + maxaddr = p[i] + offset + l + s[maxaddr] = char + ret = "".join(s) + if trim: + ret = ret[: maxaddr + 1] + return ret + + +def chomp_empty_strings(strings, c, reverse=False): + """ + Given a list of strings, some of which are the empty string "", replace the + empty strings with c and combine them with the closest non-empty string on + the left or "" if it is the first string. + Examples: + for c="_" + ['hey', '', 'why', '', '', 'whoa', '', ''] -> ['hey_', 'why__', 'whoa__'] + ['', 'hi', '', "I'm", 'bob', '', ''] -> ['_', 'hi_', "I'm", 'bob__'] + ['hi', "i'm", 'a', 'good', 'string'] -> ['hi', "i'm", 'a', 'good', 'string'] + Some special cases are: + [] -> [] + [''] -> [''] + ['', ''] -> ['_'] + ['', '', '', ''] -> ['___'] + If reverse is true, empty strings are combined with closest non-empty string + on the right or "" if it is the last string. + """ + + def _rev(l): + return [s[::-1] for s in l][::-1] + + if reverse: + return _rev(chomp_empty_strings(_rev(strings), c)) + if not len(strings): + return strings + if sum(map(len, strings)) == 0: + return [c * (len(strings) - 1)] + + class _Chomper: + def __init__(self, c): + self.c = c + + def __call__(self, x, y): + # x is list up to now + # y is next item in list + # x should be [""] initially, and then empty strings filtered out at the + # end + if len(y) == 0: + return x[:-1] + [x[-1] + self.c] + else: + return x + [y] + + return list(filter(len, reduce(_Chomper(c), strings, [""]))) + + +# taken from +# https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Python +def levenshtein(s1, s2): + if len(s1) < len(s2): + return levenshtein(s2, s1) # len(s1) >= len(s2) + if len(s2) == 0: + return len(s1) + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + # j+1 instead of j since previous_row and current_row are one character longer + # than s2 + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + return previous_row[-1] + + +def find_closest_string(string, strings): + def _key(s): + # sort by levenshtein distance and lexographically to maintain a stable + # sort for different keys with the same levenshtein distance + return (levenshtein(s, string), s) + + return sorted(strings, key=_key)[0] diff --git a/packages/python/plotly/plotly/basedatatypes.py b/packages/python/plotly/plotly/basedatatypes.py index 5b85e46fe97..7e089eeb3c1 100644 --- a/packages/python/plotly/plotly/basedatatypes.py +++ b/packages/python/plotly/plotly/basedatatypes.py @@ -9,8 +9,18 @@ from contextlib import contextmanager from copy import deepcopy, copy import itertools - -from _plotly_utils.utils import _natural_sort_strings, _get_int_type +from functools import reduce + +from _plotly_utils.utils import ( + _natural_sort_strings, + _get_int_type, + split_multichar, + split_string_positions, + display_string_positions, + chomp_empty_strings, + find_closest_string, +) +from _plotly_utils.exceptions import PlotlyKeyError from .optional_imports import get_module from . import shapeannotation @@ -22,6 +32,225 @@ Undefined = object() +def _len_dict_item(item): + """ + Because a parsed dict path is a tuple containings strings or integers, to + know the length of the resulting string when printing we might need to + convert to a string before calling len on it. + """ + try: + l = len(item) + except TypeError: + try: + l = len("%d" % (item,)) + except TypeError: + raise ValueError( + "Cannot find string length of an item that is not string-like nor an integer." + ) + return l + + +def _str_to_dict_path_full(key_path_str): + """ + Convert a key path string into a tuple of key path elements and also + return a tuple of indices marking the beginning of each element in the + string. + + Parameters + ---------- + key_path_str : str + Key path string, where nested keys are joined on '.' characters + and array indexes are specified using brackets + (e.g. 'foo.bar[1]') + Returns + ------- + tuple[str | int] + tuple [int] + """ + # skip all the parsing if the string is empty + if len(key_path_str): + # split string on ".[]" and filter out empty strings + key_path2 = split_multichar([key_path_str], list(".[]")) + # Split out underscore + # e.g. ['foo', 'bar_baz', '1'] -> ['foo', 'bar', 'baz', '1'] + key_path3 = [] + underscore_props = BaseFigure._valid_underscore_properties + + def _make_hyphen_key(key): + if "_" in key[1:]: + # For valid properties that contain underscores (error_x) + # replace the underscores with hyphens to protect them + # from being split up + for under_prop, hyphen_prop in underscore_props.items(): + key = key.replace(under_prop, hyphen_prop) + return key + + def _make_underscore_key(key): + return key.replace("-", "_") + + key_path2b = list(map(_make_hyphen_key, key_path2)) + # Here we want to split up each non-empty string in the list at + # underscores and recombine the strings using chomp_empty_strings so + # that leading, trailing and multiple _ will be preserved + def _split_and_chomp(s): + if not len(s): + return s + s_split = split_multichar([s], list("_")) + # handle key paths like "a_path_", "_another_path", or + # "yet__another_path" by joining extra "_" to the string to the right or + # the empty string if at the end + s_chomped = chomp_empty_strings(s_split, "_", reverse=True) + return s_chomped + + # after running _split_and_chomp on key_path2b, it will be a list + # containing strings and lists of strings; concatenate the sublists with + # the list ("lift" the items out of the sublists) + key_path2c = list( + reduce( + lambda x, y: x + y if type(y) == type(list()) else x + [y], + map(_split_and_chomp, key_path2b), + [], + ) + ) + + key_path2d = list(map(_make_underscore_key, key_path2c)) + all_elem_idcs = tuple(split_string_positions(list(key_path2d))) + # remove empty strings, and indices pointing to them + key_elem_pairs = list(filter(lambda t: len(t[1]), enumerate(key_path2d))) + key_path3 = [x for _, x in key_elem_pairs] + elem_idcs = [all_elem_idcs[i] for i, _ in key_elem_pairs] + + # Convert elements to ints if possible. + # e.g. ['foo', 'bar', '0'] -> ['foo', 'bar', 0] + for i in range(len(key_path3)): + try: + key_path3[i] = int(key_path3[i]) + except ValueError as _: + pass + else: + key_path3 = [] + elem_idcs = [] + + return (tuple(key_path3), elem_idcs) + + +def _remake_path_from_tuple(props): + """ + try to remake a path using the properties in props + """ + if len(props) == 0: + return "" + + def _add_square_brackets_to_number(n): + if type(n) == type(int()): + return "[%d]" % (n,) + return n + + def _prepend_dot_if_not_number(s): + if not s.startswith("["): + return "." + s + return s + + props_all_str = list(map(_add_square_brackets_to_number, props)) + props_w_underscore = props_all_str[:1] + list( + map(_prepend_dot_if_not_number, props_all_str[1:]) + ) + return "".join(props_w_underscore) + + +def _check_path_in_prop_tree(obj, path, error_cast=None): + """ + obj: the object in which the first property is looked up + path: the path that will be split into properties to be looked up + path can also be a tuple. In this case, it is combined using . + and [] because it is impossible to reconstruct the string fully + in order to give a decent error message. + error_cast: this function walks down the property tree by looking up values + in objects. So this will throw exceptions that are thrown by + __getitem__, but in some cases we are checking the path for a + different reason and would prefer throwing a more relevant + exception (e.g., __getitem__ throws KeyError but __setitem__ + throws ValueError for subclasses of BasePlotlyType and + BaseFigure). So the resulting error can be "casted" to the + passed in type, if not None. + returns + an Exception object or None. The caller can raise this + exception to see where the lookup error occurred. + """ + if type(path) == type(tuple()): + path = _remake_path_from_tuple(path) + prop, prop_idcs = _str_to_dict_path_full(path) + prev_objs = [] + for i, p in enumerate(prop): + arg = "" + prev_objs.append(obj) + try: + obj = obj[p] + except (ValueError, KeyError, IndexError, TypeError) as e: + arg = e.args[0] + if issubclass(e.__class__, TypeError): + # If obj doesn't support subscripting, state that and show the + # (valid) property that gives the object that doesn't support + # subscripting. + if i > 0: + validator = prev_objs[i - 1]._get_validator(prop[i - 1]) + arg += """ + +Invalid value received for the '{plotly_name}' property of {parent_name} + +{description}""".format( + parent_name=validator.parent_name, + plotly_name=validator.plotly_name, + description=validator.description(), + ) + # In case i is 0, the best we can do is indicate the first + # property in the string as having caused the error + disp_i = max(i - 1, 0) + dict_item_len = _len_dict_item(prop[disp_i]) + # if the path has trailing underscores, the prop string will start with "_" + trailing_underscores = "" + if prop[i][0] == "_": + trailing_underscores = " and path has trailing underscores" + # if the path has trailing underscores and the display index is + # one less than the prop index (see above), then we can also + # indicate the offending underscores + if (trailing_underscores != "") and (disp_i != i): + dict_item_len += _len_dict_item(prop[i]) + arg += """ + +Property does not support subscripting%s: +%s +%s""" % ( + trailing_underscores, + path, + display_string_positions( + prop_idcs, disp_i, length=dict_item_len, char="^" + ), + ) + else: + # State that the property for which subscripting was attempted + # is bad and indicate the start of the bad property. + arg += """ +Bad property path: +%s +%s""" % ( + path, + display_string_positions( + prop_idcs, i, length=_len_dict_item(prop[i]), char="^" + ), + ) + # Make KeyError more pretty by changing it to a PlotlyKeyError, + # because the Python interpreter has a special way of printing + # KeyError + if type(e) == type(KeyError()): + e = PlotlyKeyError() + if error_cast is not None: + e = error_cast() + e.args = (arg,) + return e + return None + + def _combine_dicts(dicts): all_args = dict() for d in dicts: @@ -400,10 +629,18 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. # Process kwargs # -------------- for k, v in kwargs.items(): - if k in self: + err = _check_path_in_prop_tree(self, k) + if err is None: self[k] = v elif not skip_invalid: - raise TypeError("invalid Figure property: {}".format(k)) + type_err = TypeError("invalid Figure property: {}".format(k)) + type_err.args = ( + type_err.args[0] + + """ +%s""" + % (err.args[0],), + ) + raise type_err # Magic Methods # ------------- @@ -450,6 +687,9 @@ def __setitem__(self, prop, value): # ---------------------- # e.g. ('foo', 1) else: + err = _check_path_in_prop_tree(self, orig_prop, error_cast=ValueError) + if err is not None: + raise err res = self for p in prop[:-1]: res = res[p] @@ -505,6 +745,9 @@ def __getitem__(self, prop): # ---------------------- # e.g. ('foo', 1) else: + err = _check_path_in_prop_tree(self, orig_prop, error_cast=PlotlyKeyError) + if err is not None: + raise err res = self for p in prop: res = res[p] @@ -1541,7 +1784,7 @@ def _normalize_trace_indexes(self, trace_indexes): @staticmethod def _str_to_dict_path(key_path_str): """ - Convert a key path string into a tuple of key path elements + Convert a key path string into a tuple of key path elements. Parameters ---------- @@ -1565,53 +1808,8 @@ def _str_to_dict_path(key_path_str): # Nothing to do return key_path_str else: - # Split string on periods. - # e.g. 'foo.bar_baz[1]' -> ['foo', 'bar_baz[1]'] - key_path = key_path_str.split(".") - - # Split out bracket indexes. - # e.g. ['foo', 'bar_baz[1]'] -> ['foo', 'bar_baz', '1'] - key_path2 = [] - for key in key_path: - match = BaseFigure._bracket_re.match(key) - if match: - key_path2.extend(match.groups()) - else: - key_path2.append(key) - - # Split out underscore - # e.g. ['foo', 'bar_baz', '1'] -> ['foo', 'bar', 'baz', '1'] - key_path3 = [] - underscore_props = BaseFigure._valid_underscore_properties - for key in key_path2: - if "_" in key[1:]: - # For valid properties that contain underscores (error_x) - # replace the underscores with hyphens to protect them - # from being split up - for under_prop, hyphen_prop in underscore_props.items(): - key = key.replace(under_prop, hyphen_prop) - - # Split key on underscores - key = key.split("_") - - # Replace hyphens with underscores to restore properties - # that include underscores - for i in range(len(key)): - key[i] = key[i].replace("-", "_") - - key_path3.extend(key) - else: - key_path3.append(key) - - # Convert elements to ints if possible. - # e.g. ['foo', 'bar', '0'] -> ['foo', 'bar', 0] - for i in range(len(key_path3)): - try: - key_path3[i] = int(key_path3[i]) - except ValueError as _: - pass - - return tuple(key_path3) + ret = _str_to_dict_path_full(key_path_str)[0] + return ret @staticmethod def _set_in(d, key_path_str, v): @@ -3608,19 +3806,20 @@ def _perform_update(plotly_obj, update_obj, overwrite=False): # ------------------------------- # This should be valid even if xaxis2 hasn't been initialized: # >>> layout.update(xaxis2={'title': 'xaxis 2'}) - if isinstance(plotly_obj, BaseLayoutType): - for key in update_obj: - if key not in plotly_obj: + for key in update_obj: + err = _check_path_in_prop_tree(plotly_obj, key, error_cast=ValueError) + if err is not None: + if isinstance(plotly_obj, BaseLayoutType): + # try _subplot_re_match match = plotly_obj._subplot_re_match(key) if match: # We need to create a subplotid object plotly_obj[key] = {} - - # Handle invalid properties - # ------------------------- - invalid_props = [k for k in update_obj if k not in plotly_obj] - - plotly_obj._raise_on_invalid_property_error(*invalid_props) + continue + # If no match, raise the error, which should already + # contain the _raise_on_invalid_property_error + # generated message + raise err # Convert update_obj to dict # -------------------------- @@ -4077,17 +4276,20 @@ def _process_kwargs(self, **kwargs): """ invalid_kwargs = {} for k, v in kwargs.items(): - if k in self: + err = _check_path_in_prop_tree(self, k, error_cast=ValueError) + if err is None: # e.g. underscore kwargs like marker_line_color self[k] = v elif not self._validate: # Set extra property as-is self[k] = v - else: - invalid_kwargs[k] = v - - if invalid_kwargs and not self._skip_invalid: - self._raise_on_invalid_property_error(*invalid_kwargs.keys()) + elif not self._skip_invalid: + raise err + # No need to call _raise_on_invalid_property_error here, + # because we have it set up so that the singular case of calling + # __setitem__ will raise this. If _check_path_in_prop_tree + # raised that in its travels, it will already be in the error + # message. @property def plotly_name(self): @@ -4393,12 +4595,14 @@ def __getitem__(self, prop): # Normalize prop # -------------- # Convert into a property tuple + orig_prop = prop prop = BaseFigure._str_to_dict_path(prop) # Handle remapping # ---------------- if prop and prop[0] in self._mapped_properties: prop = self._mapped_properties[prop[0]] + prop[1:] + orig_prop = _remake_path_from_tuple(prop) # Handle scalar case # ------------------ @@ -4407,7 +4611,9 @@ def __getitem__(self, prop): # Unwrap scalar tuple prop = prop[0] if prop not in self._valid_props: - raise KeyError(prop) + self._raise_on_invalid_property_error(_error_to_raise=PlotlyKeyError)( + prop + ) validator = self._get_validator(prop) @@ -4445,6 +4651,9 @@ def __getitem__(self, prop): # ---------------------- # e.g. ('foo', 1), () else: + err = _check_path_in_prop_tree(self, orig_prop, error_cast=PlotlyKeyError) + if err is not None: + raise err res = self for p in prop: res = res[p] @@ -4542,7 +4751,7 @@ def __setitem__(self, prop, value): if self._validate: if prop not in self._valid_props: - self._raise_on_invalid_property_error(prop) + self._raise_on_invalid_property_error()(prop) # ### Get validator for this property ### validator = self._get_validator(prop) @@ -4588,6 +4797,9 @@ def __setitem__(self, prop, value): # ---------------------- # e.g. ('foo', 1), () else: + err = _check_path_in_prop_tree(self, orig_prop, error_cast=ValueError) + if err is not None: + raise err res = self for p in prop[:-1]: res = res[p] @@ -4613,7 +4825,7 @@ def __setattr__(self, prop, value): super(BasePlotlyType, self).__setattr__(prop, value) else: # Raise error on unknown public properties - self._raise_on_invalid_property_error(prop) + self._raise_on_invalid_property_error()(prop) def __iter__(self): """ @@ -4722,10 +4934,11 @@ def __repr__(self): return repr_str - def _raise_on_invalid_property_error(self, *args): + def _raise_on_invalid_property_error(self, _error_to_raise=None): """ - Raise informative exception when invalid property names are - encountered + Returns a function that raises informative exception when invalid + property names are encountered. The _error_to_raise argument allows + specifying the exception to raise, which is ValueError if None. Parameters ---------- @@ -4735,37 +4948,59 @@ def _raise_on_invalid_property_error(self, *args): Raises ------ - ValueError - Always + ValueError by default, or _error_to_raise if not None """ - invalid_props = args - if invalid_props: - if len(invalid_props) == 1: - prop_str = "property" - invalid_str = repr(invalid_props[0]) - else: - prop_str = "properties" - invalid_str = repr(invalid_props) + if _error_to_raise is None: + _error_to_raise = ValueError - module_root = "plotly.graph_objs." - if self._parent_path_str: - full_obj_name = ( - module_root + self._parent_path_str + "." + self.__class__.__name__ - ) - else: - full_obj_name = module_root + self.__class__.__name__ + def _ret(*args): + invalid_props = args + if invalid_props: + if len(invalid_props) == 1: + prop_str = "property" + invalid_str = repr(invalid_props[0]) + else: + prop_str = "properties" + invalid_str = repr(invalid_props) + + module_root = "plotly.graph_objs." + if self._parent_path_str: + full_obj_name = ( + module_root + + self._parent_path_str + + "." + + self.__class__.__name__ + ) + else: + full_obj_name = module_root + self.__class__.__name__ - raise ValueError( - "Invalid {prop_str} specified for object of type " - "{full_obj_name}: {invalid_str}\n\n" - " Valid properties:\n" - "{prop_descriptions}".format( - prop_str=prop_str, - full_obj_name=full_obj_name, - invalid_str=invalid_str, - prop_descriptions=self._prop_descriptions, + guessed_prop = None + if len(invalid_props) == 1: + try: + guessed_prop = find_closest_string( + invalid_props[0], self._valid_props + ) + except Exception: + pass + guessed_prop_suggestion = "" + if guessed_prop is not None: + guessed_prop_suggestion = 'Did you mean "%s"?' % (guessed_prop,) + raise _error_to_raise( + "Invalid {prop_str} specified for object of type " + "{full_obj_name}: {invalid_str}\n" + "\n{guessed_prop_suggestion}\n" + "\n Valid properties:\n" + "{prop_descriptions}" + "\n{guessed_prop_suggestion}\n".format( + prop_str=prop_str, + full_obj_name=full_obj_name, + invalid_str=invalid_str, + prop_descriptions=self._prop_descriptions, + guessed_prop_suggestion=guessed_prop_suggestion, + ) ) - ) + + return _ret def update(self, dict1=None, overwrite=False, **kwargs): """ diff --git a/packages/python/plotly/plotly/tests/test_core/test_errors/test_dict_path_errors.py b/packages/python/plotly/plotly/tests/test_core/test_errors/test_dict_path_errors.py new file mode 100644 index 00000000000..271533d23a4 --- /dev/null +++ b/packages/python/plotly/plotly/tests/test_core/test_errors/test_dict_path_errors.py @@ -0,0 +1,658 @@ +import plotly.graph_objects as go +from _plotly_utils.exceptions import PlotlyKeyError +import pytest + + +def error_substr(s, r): + """ remove a part of the error message we don't want to compare """ + return s.replace(r, "") + + +@pytest.fixture +def some_fig(): + fig = go.Figure() + fig.add_trace(go.Scatter(x=[], y=[])) + fig.add_shape(type="rect", x0=1, x1=2, y0=3, y1=4) + fig.add_shape(type="rect", x0=10, x1=20, y0=30, y1=40) + fig.add_trace(go.Scatter(x=[1, 2, 3], y=[4, 5, 6])) + return fig + + +def test_raises_on_bad_index(some_fig): + # Check indexing errors can be detected when path used as key to go.Figure + raised = False + try: + x0 = some_fig["layout.shapes[2].x0"] + except KeyError as e: + raised = True + assert ( + e.args[0].find( + """Bad property path: +layout.shapes[2].x0 + ^""" + ) + >= 0 + ) + assert raised + + +def test_raises_on_bad_dot_property(some_fig): + + # Check . property lookup errors can be detected when path used as key to + # go.Figure + raised = False + try: + x2000 = some_fig["layout.shapes[1].x2000"] + except KeyError as e: + raised = True + assert ( + e.args[0].find( + """Bad property path: +layout.shapes[1].x2000 + ^^^^^""" + ) + and (e.args[0].find("""Did you mean "x0"?""") >= 0) >= 0 + ) + assert raised + + +def test_raises_on_bad_ancestor_dot_property(some_fig): + + # Check . property lookup errors but not on the last part of the path + raised = False + try: + x2000 = some_fig["layout.shapa[1].x2000"] + except KeyError as e: + raised = True + assert ( + e.args[0].find( + """Bad property path: +layout.shapa[1].x2000 + ^^^^^""" + ) + and (e.args[0].find("""Did you mean "shapes"?""") >= 0) >= 0 + ) + assert raised + + +def test_raises_on_bad_indexed_underscore_property(some_fig): + + # The way these tests work is first the error is raised without using + # underscores to get the Exception we expect, then the string showing the + # bad property path is removed (because it will not match the string + # returned when the same error is thrown using underscores). + # Then the error is thrown using underscores and the Exceptions are + # compared, but we adjust the expected bad property error because it will be + # different when underscores are used. + + # finds bad part when using the path as a key to figure and throws the error + # for the last good property it found in the path + raised = False + try: + # get the error without using a path-like key, we compare with this error + some_fig.data[0].line["colr"] = "blue" + except ValueError as e_correct: + raised = True + # remove "Bad property path: + e_correct_substr = error_substr( + e_correct.args[0], + """ +Bad property path: +colr +^^^^""", + ) + # if the string starts with "Bad property path:" then this test cannot work + # this way. + assert len(e_correct_substr) > 0 + assert raised + + raised = False + try: + some_fig["data[0].line_colr"] = "blue" + except ValueError as e: + raised = True + e_substr = error_substr( + e.args[0], + """ +Bad property path: +data[0].line_colr + ^^^^""", + ) + assert ( + ( + e.args[0].find( + """Bad property path: +data[0].line_colr + ^^^^""" + ) + >= 0 + ) + and (e.args[0].find("""Did you mean "color"?""") >= 0) + and (e_substr == e_correct_substr) + ) + assert raised + + raised = False + try: + # get the error without using a path-like key + some_fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4], line=dict(colr="blue"))) + except ValueError as e_correct: + raised = True + e_correct_substr = error_substr( + e_correct.args[0], + """ +Bad property path: +colr +^^^^""", + ) + assert raised + + raised = False + # finds bad part when using the path as a keyword argument to a subclass of + # BasePlotlyType and throws the error for the last good property found in + # the path + try: + some_fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4], line_colr="blue")) + except ValueError as e: + raised = True + e_substr = error_substr( + e.args[0], + """ +Bad property path: +line_colr + ^^^^""", + ) + assert ( + ( + e.args[0].find( + """Bad property path: +line_colr + ^^^^""" + ) + and (e.args[0].find("""Did you mean "color"?""") >= 0) >= 0 + ) + and (e_substr == e_correct_substr) + ) + assert raised + + raised = False + # finds bad part when using the path as a keyword argument to a subclass of + # BaseFigure and throws the error for the last good property found in + # the path + try: + fig2 = go.Figure(layout=dict(title=dict(txt="two"))) + except ValueError as e_correct: + raised = True + e_correct_substr = error_substr( + e_correct.args[0], + """ +Bad property path: +txt +^^^""", + ) + assert raised + + raised = False + try: + fig2 = go.Figure(layout_title_txt="two") + except TypeError as e: + raised = True + # when the Figure constructor sees the same ValueError above, a + # ValueError is raised and adds an error message in front of the same + # ValueError thrown above + e_substr = error_substr( + e.args[0], + """ +Bad property path: +layout_title_txt + ^^^""", + ) + # also remove the invalid Figure property string added by the Figure constructor + e_substr = error_substr( + e_substr, + """invalid Figure property: layout_title_txt +""", + ) + assert ( + ( + e.args[0].find( + """Bad property path: +layout_title_txt + ^^^""", + ) + >= 0 + ) + and (e.args[0].find("""Did you mean "text"?""") >= 0) + and (e_substr == e_correct_substr) + ) + assert raised + + raised = False + # this is like the above test for subclasses of BasePlotlyType but makes sure it + # works when the bad part is not the last part in the path + try: + some_fig.update_layout(geo=dict(ltaxis=dict(showgrid=True))) + except ValueError as e_correct: + raised = True + e_correct_substr = error_substr( + e_correct.args[0], + """ +Bad property path: +ltaxis +^^^^^^""", + ) + assert raised + + raised = False + try: + some_fig.update_layout(geo_ltaxis_showgrid=True) + except ValueError as e: + raised = True + e_substr = error_substr( + e.args[0], + """ +Bad property path: +geo_ltaxis_showgrid + ^^^^^^""", + ) + assert ( + ( + e.args[0].find( + """Bad property path: +geo_ltaxis_showgrid + ^^^^^^""" + ) + >= 0 + ) + and (e.args[0].find("""Did you mean "lataxis"?""") >= 0) + and (e_substr == e_correct_substr) + ) + assert raised + + +def test_describes_subscripting_error(some_fig): + # This test works like test_raises_on_bad_indexed_underscore_property but + # removes the error raised because the property does not support + # subscripting. + # Note that, to raise the error, we try to access the value rather than + # assign something to it. We have to do this, because Plotly.py tries to + # access the value to see if it is valid, so the error raised has to do with + # subscripting and not assignment (even though we are trying to assign it a + # value). + raised = False + try: + # some_fig.update_traces(text_yo="hey") but without using underscores + some_fig.data[0].text["yo"] + except TypeError as e: + raised = True + e_correct_substr = e.args[0] + assert raised + raised = False + try: + some_fig.update_traces(text_yo="hey") + except ValueError as e: + raised = True + print(e.args[0]) + e_substr = error_substr( + e.args[0], + """ + +Invalid value received for the 'text' property of scatter + + The 'text' property is a string and must be specified as: + - A string + - A number that will be converted to a string + - A tuple, list, or one-dimensional numpy array of the above + +Property does not support subscripting: +text_yo +^^^^""", + ) + assert ( + ( + e.args[0].find( + """ +Property does not support subscripting: +text_yo +^^^^""" + ) + >= 0 + ) + and (e_substr == e_correct_substr) + ) + assert raised + + # Same as previous test but tests deeper path + raised = False + try: + # go.Figure(go.Scatter()).update_traces(textfont_family_yo="hey") but + # without using underscores + some_fig.data[0].textfont.family["yo"] + except TypeError as e: + raised = True + e_correct_substr = e.args[0] + assert raised + raised = False + try: + go.Figure(go.Scatter()).update_traces(textfont_family_yo="hey") + except ValueError as e: + raised = True + e_substr = error_substr( + e.args[0], + """ + +Invalid value received for the 'family' property of scatter.textfont + + The 'family' property is a string and must be specified as: + - A non-empty string + - A tuple, list, or one-dimensional numpy array of the above + +Property does not support subscripting: +textfont_family_yo + ^^^^^^""", + ) + assert ( + ( + e.args[0].find( + """ +Property does not support subscripting: +textfont_family_yo + ^^^^^^""" + ) + >= 0 + ) + and (e_substr == e_correct_substr) + ) + assert raised + + +def test_described_subscript_error_on_type_error(some_fig): + # The above tests for subscripting errors did not test for when we attempt + # to subscript an object that is not None, such as a string or a number. + # These do that. + raised = False + try: + # Trying to address with a key an object that doesn't support it (as we + # do below) reports an error listing what are valid assignments to the + # object, like when we try and assign a number to something that expects as string. + some_fig["layout_template_layout_plot_bgcolor"] = 1 + except ValueError as e: + raised = True + # Trim off the beginning of the error string because it is related to + # trying to assign a number to something expecting a string, whereas + # below the error will be due to trying to subscript something that + # doesn't support it. But the list of valid properties should be shown + # for both errors and this is what we extract. + # Trimmed like this because this string is different in Python2 than + # Python3 + e_correct_substr = e.args[0] + start_at = e_correct_substr.find(" The 'plot_bgcolor'") + e_correct_substr = e_correct_substr[start_at:] + e_correct_substr += """ + +Property does not support subscripting: +template_layout_plot_bgcolor_x + ^^^^^^^^^^^^""" + assert raised + raised = False + try: + some_fig.update_layout(template_layout_plot_bgcolor_x=1) + except ValueError as e: + raised = True + print(e.args[0]) + e_substr = error_substr( + e.args[0], + """string indices must be integers + +Invalid value received for the 'plot_bgcolor' property of layout + +""", + ) + assert e_substr == e_correct_substr + assert raised + + +def test_subscript_error_exception_types(some_fig): + # Assert that these raise the expected error types + # when width is None + with pytest.raises(ValueError): + some_fig.update_layout(width_yo=100) + with pytest.raises(KeyError): + yo = some_fig["layout_width_yo"] + + some_fig.update_layout(width=100) + # when width is specified + with pytest.raises(ValueError): + some_fig.update_layout(width_yo=100) + with pytest.raises(KeyError): + yo = some_fig["layout_width_yo"] + + +def form_error_string(call, exception, subs): + """ + call is a function that raises exception. + exception is an exception class, e.g., KeyError. + subs is a list of replacements to be performed on the exception string. Each + replacement is only performed once on the exception string so the + replacement of multiple occurences of a pattern is specified by repeating a + (pattern,relacement) pair in the list. + returns modified exception string + """ + raised = False + try: + call() + except exception as e: + raised = True + msg = e.args[0] + for pat, rep in subs: + msg = msg.replace(pat, rep, 1) + assert raised + return msg + + +def check_error_string(call, exception, correct_str, subs): + raised = False + try: + call() + except exception as e: + raised = True + msg = e.args[0] + for pat, rep in subs: + msg = msg.replace(pat, rep, 1) + print("MSG") + print(msg) + print("CORRECT") + print(correct_str) + assert msg == correct_str + assert raised + + +def test_leading_underscore_errors(some_fig): + # get error string but alter it to form the final expected string + def _raise_bad_property_path_form(): + some_fig.update_layout(bogus=7) + + def _raise_bad_property_path_real(): + some_fig.update_layout(_hey_yall=7) + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + # change last boxgap to geo because bogus is closest to boxgap but _hey + # closest to geo, but remember that boxgap is in the list of valid keys + # displayed by the error string + [ + ("bogus", "_hey"), + ("bogus", "_hey_yall"), + ("^^^^^", "^^^^"), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_trailing_underscore_errors(some_fig): + # get error string but alter it to form the final expected string + def _raise_bad_property_path_form(): + some_fig.update_layout(title_text_bogus="hi") + + def _raise_bad_property_path_real(): + some_fig.update_layout(title_text_="hi") + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ( + "Property does not support subscripting", + "Property does not support subscripting and path has trailing underscores", + ), + ("text_bogus", "text_"), + ("^^^^", "^^^^^"), + ], + ) + # no need to replace ^^^^^ because bogus and text_ are same length + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_embedded_underscore_errors(some_fig): + # get error string but alter it to form the final expected string + def _raise_bad_property_path_form(): + some_fig.update_layout(title_font_bogusey="hi") + + def _raise_bad_property_path_real(): + some_fig.update_layout(title_font__family="hi") + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ("bogusey", "_family"), + ("bogusey", "_family"), + ('Did you mean "color"?', 'Did you mean "family"?'), + ('Did you mean "color"?', 'Did you mean "family"?'), + ], + ) + # no need to replace ^^^^^ because bogus and font_ are same length + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_solo_underscore_errors(some_fig): + # get error string but alter it to form the final expected string + def _raise_bad_property_path_form(): + some_fig.update_layout(bogus="hi") + + def _raise_bad_property_path_real(): + some_fig.update_layout(_="hi") + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ("bogus", "_"), + ("bogus", "_"), + ("^^^^^", "^"), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_repeated_underscore_errors(some_fig): + # get error string but alter it to form the final expected string + def _raise_bad_property_path_form(): + some_fig.update_layout(bogus="hi") + + def _raise_bad_property_path_real(): + some_fig.update_layout(__="hi") + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ("bogus", "__"), + ("bogus", "__"), + ("^^^^^", "^^"), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ('Did you mean "boxgap"', 'Did you mean "geo"'), + ], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_leading_underscore_errors_dots_and_subscripts(some_fig): + # get error string but alter it to form the final expected string + some_fig.add_annotation(text="hi") + + def _raise_bad_property_path_form(): + some_fig["layout.annotations[0].bogus_family"] = "hi" + + def _raise_bad_property_path_real(): + some_fig["layout.annotations[0]._font_family"] = "hi" + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [("bogus", "_font"), ("bogus", "_font"), ("^^^^^", "^^^^^")], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_trailing_underscore_errors_dots_and_subscripts(some_fig): + # get error string but alter it to form the final expected string + some_fig.add_annotation(text="hi") + + def _raise_bad_property_path_form(): + some_fig["layout.annotations[0].font_family_bogus"] = "hi" + + def _raise_bad_property_path_real(): + some_fig["layout.annotations[0].font_family_"] = "hi" + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ( + "Property does not support subscripting", + "Property does not support subscripting and path has trailing underscores", + ), + ("family_bogus", "family_"), + ("^^^^^^", "^^^^^^^"), + ], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_repeated_underscore_errors_dots_and_subscripts(some_fig): + # get error string but alter it to form the final expected string + some_fig.add_annotation(text="hi") + + def _raise_bad_property_path_form(): + some_fig["layout.annotations[0].font_bogusey"] = "hi" + + def _raise_bad_property_path_real(): + some_fig["layout.annotations[0].font__family"] = "hi" + + correct_err_str = form_error_string( + _raise_bad_property_path_form, + ValueError, + [ + ("bogusey", "_family"), + ("bogusey", "_family"), + ('Did you mean "color"?', 'Did you mean "family"?'), + ('Did you mean "color"?', 'Did you mean "family"?'), + ], + ) + check_error_string(_raise_bad_property_path_real, ValueError, correct_err_str, []) + + +def test_single_prop_path_key_guess(some_fig): + raised = False + try: + some_fig.layout.shapes[0]["typ"] = "sandwich" + except ValueError as e: + raised = True + assert e.args[0].find('Did you mean "type"?') >= 0 + assert raised