diff --git a/holoviews/core/options.py b/holoviews/core/options.py index f16dc351b3..99400b6be1 100644 --- a/holoviews/core/options.py +++ b/holoviews/core/options.py @@ -34,14 +34,15 @@ """ import pickle import traceback +import difflib from contextlib import contextmanager -from collections import OrderedDict +from collections import OrderedDict, defaultdict import numpy as np import param from .tree import AttrTree -from .util import sanitize_identifier, group_sanitizer,label_sanitizer +from .util import sanitize_identifier, group_sanitizer,label_sanitizer, basestring from .pprint import InfoPrinter @@ -76,7 +77,7 @@ def __init__(self, invalid_keyword, allowed_keywords, def message(self, invalid_keyword, allowed_keywords, group_name, path): msg = ("Invalid option %s, valid options are: %s" - % (repr(invalid_keyword), str(sorted(list(set(allowed_keywords)))))) + % (repr(invalid_keyword), str(allowed_keywords))) if path and group_name: msg = ("Invalid key for group %r on path %r;\n" % (group_name, path)) + msg @@ -125,6 +126,63 @@ def __exit__(self, etype, value, traceback): raise AbbreviatedException(etype, value, traceback) +class Keywords(param.Parameterized): + """ + A keywords objects represents a set of Python keywords. It is + list-like and ordered but it is also a set without duplicates. When + passed as **kwargs, Python keywords are not ordered but this class + always lists keywords in sorted order. + + In addition to containing the list of keywords, Keywords has an + optional target which describes what the keywords are applicable to. + + This class is for internal use only and should not be in the user + namespace. + """ + + values = param.List(doc="Set of keywords as a sorted list.") + + target = param.String(allow_None=True, doc=""" + Optional string description of what the keywords apply to.""") + + def __init__(self, values=[], target=None): + + strings = [isinstance(v, (str,basestring)) for v in values] + if False in strings: + raise ValueError('All keywords must be strings: {0}'.format(values)) + super(Keywords, self).__init__(values=sorted(values), + target=target) + + def __add__(self, other): + if (self.target and other.target) and (self.target != other.target): + raise Exception('Targets must match to combine Keywords') + target = self.target or other.target + return Keywords(sorted(set(self.values + other.values)), target=target) + + def fuzzy_match(self, kw): + """ + Given a string, fuzzy match against the Keyword values, + returning a list of close matches. + """ + return difflib.get_close_matches(kw, self.values) + + def __repr__(self): + if self.target: + msg = 'Keywords({values}, target={target})' + info = dict(values=self.values, target=self.target) + else: + msg = 'Keywords({values})' + info = dict(values=self.values) + return msg.format(**info) + + def __str__(self): return str(self.values) + def __iter__(self): return iter(self.values) + def __bool__(self): return bool(self.values) + def __nonzero__(self): return bool(self.values) + def __contains__(self, val): return val in self.values + + + class Cycle(param.Parameterized): """ A simple container class that specifies cyclic options. A typical @@ -257,7 +315,7 @@ class Options(param.Parameterized): can create a new Options object inheriting the parent options. """ - allowed_keywords = param.List(default=None, allow_None=True, doc=""" + allowed_keywords = param.ClassSelector(class_=Keywords, doc=""" Optional list of strings corresponding to the allowed keywords.""") key = param.String(default=None, allow_None=True, doc=""" @@ -277,7 +335,7 @@ class Options(param.Parameterized): skipping over invalid keywords or not. May only be specified at the class level.""") - def __init__(self, key=None, allowed_keywords=None, merge_keywords=True, **kwargs): + def __init__(self, key=None, allowed_keywords=[], merge_keywords=True, **kwargs): invalid_kws = [] for kwarg in sorted(kwargs.keys()): @@ -288,16 +346,27 @@ def __init__(self, key=None, allowed_keywords=None, merge_keywords=True, **kwarg else: raise OptionError(kwarg, allowed_keywords) + for invalid_kw in invalid_kws: + error = OptionError(invalid_kw, allowed_keywords, group_name=key) + StoreOptions.record_option_error(error) if invalid_kws and self.warn_on_skip: self.warning("Invalid options %s, valid options are: %s" - % (repr(invalid_kws), str(sorted(list(set(allowed_keywords)))))) + % (repr(invalid_kws), str(allowed_keywords))) self.kwargs = kwargs self._options = self._expand_options(kwargs) - allowed_keywords = sorted(allowed_keywords) if allowed_keywords else None + allowed_keywords = (allowed_keywords if isinstance(allowed_keywords, Keywords) + else Keywords(allowed_keywords)) super(Options, self).__init__(allowed_keywords=allowed_keywords, merge_keywords=merge_keywords, key=key) + def keywords_target(self, target): + """ + Helper method to easily set the target on the allowed_keywords Keywords. + """ + self.allowed_keywords.target = target + return self + def filtered(self, allowed): """ Return a new Options object that is filtered by the specified @@ -314,7 +383,7 @@ def __call__(self, allowed_keywords=None, **kwargs): """ Create a new Options object that inherits the parent options. """ - allowed_keywords=self.allowed_keywords if allowed_keywords is None else allowed_keywords + allowed_keywords=self.allowed_keywords if allowed_keywords in [None,[]] else allowed_keywords inherited_style = dict(allowed_keywords=allowed_keywords, **kwargs) return self.__class__(key=self.key, **dict(self.kwargs, **inherited_style)) @@ -434,12 +503,6 @@ def _merge_options(self, identifier, group_name, options): name from the existing Options on the node and the new Options which are passed in. """ - override_kwargs = dict(options.kwargs) - allowed_kws = [] if options.allowed_keywords is None else options.allowed_keywords - old_allowed = self[identifier][group_name].allowed_keywords if identifier in self.children else [] - old_allowed = [] if old_allowed is None else old_allowed - override_kwargs['allowed_keywords'] = sorted(allowed_kws + old_allowed) - if group_name not in self.groups: raise KeyError("Group %s not defined on SettingTree" % group_name) @@ -450,6 +513,11 @@ def _merge_options(self, identifier, group_name, options): #When creating a node (nothing to merge with) ensure it is empty group_options = Options(group_name, allowed_keywords=self.groups[group_name].allowed_keywords) + + override_kwargs = dict(options.kwargs) + old_allowed = group_options.allowed_keywords + override_kwargs['allowed_keywords'] = options.allowed_keywords + old_allowed + try: return (group_options(**override_kwargs) if options.merge_keywords else Options(group_name, **override_kwargs)) @@ -459,7 +527,6 @@ def _merge_options(self, identifier, group_name, options): group_name=group_name, path = self.path) - def __getitem__(self, item): if item in self.groups: return self.groups[item] @@ -1036,6 +1103,9 @@ def register(cls, associations, backend, style_aliases={}): with param.logging_level('CRITICAL'): plot.style_opts = style_opts + plot_opts = Keywords(plot_opts, target=view_class.__name__) + style_opts = Keywords(style_opts, target=view_class.__name__) + opt_groups = {'plot': Options(allowed_keywords=plot_opts)} if not isinstance(view_class, CompositeOverlay) or hasattr(plot, 'style_opts'): opt_groups.update({'style': Options(allowed_keywords=style_opts), @@ -1051,7 +1121,7 @@ def register(cls, associations, backend, style_aliases={}): class StoreOptions(object): """ A collection of utilities for advanced users for creating and - setting customized option tress on the Store. Designed for use by + setting customized option trees on the Store. Designed for use by either advanced users or the %opts line and cell magics which use this machinery. @@ -1060,8 +1130,42 @@ class StoreOptions(object): access it is best to minimize the number of methods implemented on that class and implement the necessary utilities on StoreOptions instead. + + Lastly this class offers a means to record all OptionErrors + generated by an option specification. This is used for validation + purposes. """ + #=======================# + # OptionError recording # + #=======================# + + _errors_recorded = None + _option_class_settings = None + + @classmethod + def start_recording_errors(cls): + "Start collected errors supplied via record_option_error method" + cls._option_class_settings = (Options.skip_invalid, Options.warn_on_skip) + (Options.skip_invalid, Options.warn_on_skip) = (True, False) + cls._errors_recorded = [] + + @classmethod + def stop_recording_errors(cls): + "Stop recording errors and return recorded errors" + if cls._errors_recorded is None: + raise Exception('Cannot stop recording before it is started') + recorded = cls._errors_recorded[:] + (Options.skip_invalid ,Options.warn_on_skip) = cls._option_class_settings + cls._errors_recorded = None + return recorded + + @classmethod + def record_option_error(cls, error): + "Record an option error if currently recording" + if cls._errors_recorded is not None: + cls._errors_recorded.append(error) + #===============# # ID management # #===============# @@ -1133,21 +1237,47 @@ def apply_customizations(cls, spec, options): else: customization = {k:(Options(**v) if isinstance(v, dict) else v) for k,v in spec[key].items()} + + # Set the Keywords target on Options from the {type} part of the key. + customization = {k:v.keywords_target(key.split('.')[0]) + for k,v in customization.items()} options[str(key)] = customization return options - @classmethod - def validate_spec(cls, spec, skip=Options.skip_invalid): - """ - Given a specification, validated it against the default - options tree (Store.options). Only tends to be useful when - invalid keywords generate exceptions instead of skipping. - """ - if skip: return - options = OptionTree(items=Store.options().data.items(), - groups=Store.options().groups) - return cls.apply_customizations(spec, options) + def validate_spec(cls, spec, backends=None): + """ + Given a specification, validated it against the options tree for + the specified backends by raising OptionError for invalid + options. If backends is None, validates against all the + currently loaded backend. + + Only useful when invalid keywords generate exceptions instead of + skipping i.e Options.skip_invalid is False. + """ + loaded_backends = Store.loaded_backends()if backends is None else backends + + error_info = {} + backend_errors = defaultdict(set) + for backend in loaded_backends: + cls.start_recording_errors() + options = OptionTree(items=Store.options(backend).data.items(), + groups=Store.options(backend).groups) + cls.apply_customizations(spec, options) + for error in cls.stop_recording_errors(): + error_key = (error.invalid_keyword, + error.allowed_keywords.target, + error.group_name) + error_info[error_key+(backend,)] = error.allowed_keywords + backend_errors[error_key].add(backend) + + for ((keyword, target, group_name), backends) in backend_errors.items(): + # If the keyword failed for the target across all loaded backends... + if set(backends) == set(loaded_backends): + key = (keyword, target, group_name, Store.current_backend) + raise OptionError(keyword, + group_name=group_name, + allowed_keywords=error_info[key]) @classmethod diff --git a/holoviews/ipython/display_hooks.py b/holoviews/ipython/display_hooks.py index 8ce2dcc4c5..abaf86c158 100644 --- a/holoviews/ipython/display_hooks.py +++ b/holoviews/ipython/display_hooks.py @@ -45,7 +45,10 @@ def process_object(obj): def render(obj, **kwargs): info = process_object(obj) - if info: return info + if info: + IPython.display.display(IPython.display.HTML(info)) + return + if render_anim is not None: return render_anim(obj) @@ -161,7 +164,10 @@ def wrapped(element): @display_hook def element_display(element, max_frames, max_branches): info = process_object(element) - if info: return info + if info: + IPython.display.display(IPython.display.HTML(info)) + return + backend = Store.current_backend if type(element) not in Store.registry[backend]: @@ -258,7 +264,10 @@ def element_png_display(element, max_frames, max_branches): if 'png' not in Store.display_formats: return None info = process_object(element) - if info: return info + if info: + IPython.display.display(IPython.display.HTML(info)) + return + backend = Store.current_backend if type(element) not in Store.registry[backend]: @@ -280,7 +289,10 @@ def element_svg_display(element, max_frames, max_branches): if 'svg' not in Store.display_formats: return None info = process_object(element) - if info: return info + if info: + IPython.display.display(IPython.display.HTML(info)) + return + backend = Store.current_backend if type(element) not in Store.registry[backend]: diff --git a/holoviews/ipython/magics.py b/holoviews/ipython/magics.py index 9af6d22537..8c238c0fa7 100644 --- a/holoviews/ipython/magics.py +++ b/holoviews/ipython/magics.py @@ -636,8 +636,38 @@ def process_element(cls, obj): @classmethod def _format_options_error(cls, err): - info = (err.invalid_keyword, err.group_name, ', '.join(err.allowed_keywords)) - return "Keyword %r not one of following %s options:

%s" % info + """ + Return a fuzzy match message string based on the supplied OptionError + """ + allowed_keywords = err.allowed_keywords + target = allowed_keywords.target + matches = allowed_keywords.fuzzy_match(err.invalid_keyword) + if not matches: + matches = allowed_keywords.values + similarity = 'Possible' + else: + similarity = 'Similar' + + loaded_backends = Store.loaded_backends() + target = 'for {0}'.format(target) if target else '' + + if len(loaded_backends) == 1: + loaded=' in loaded backend {0!r}'.format(loaded_backends[0]) + else: + backend_list = ', '.join(['%r'% b for b in loaded_backends[:-1]]) + loaded=' in loaded backends {0} and {1!r}'.format(backend_list, + loaded_backends[-1]) + + group = '{0} option'.format(err.group_name) if err.group_name else 'keyword' + msg=('Unexpected {group} {kw} {target}{loaded}.

' + '{similarity} keywords in the currently active ' + '{current_backend} backend are: {matches}') + return msg.format(kw="'%s'" % err.invalid_keyword, + target=target, + group=group, + loaded=loaded, similarity=similarity, + current_backend=repr(Store.current_backend), + matches=matches) @classmethod def register_custom_spec(cls, spec):