diff --git a/holoviews/core/options.py b/holoviews/core/options.py index 356bb503d6..d7cc4216d9 100644 --- a/holoviews/core/options.py +++ b/holoviews/core/options.py @@ -430,9 +430,13 @@ def _merge_options(self, identifier, group_name, options): if group_name not in self.groups: raise KeyError("Group %s not defined on SettingTree" % group_name) - current_node = self[identifier] if identifier in self.children else self - group_options = current_node.groups[group_name] - + if identifier in self.children: + current_node = self[identifier] + group_options = current_node.groups[group_name] + else: + #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) try: return (group_options(**override_kwargs) if options.merge_keywords else Options(group_name, **override_kwargs)) @@ -465,7 +469,9 @@ def __getattr__(self, identifier): if valid_id in self.children: return self.__dict__[valid_id] - self.__setattr__(identifier, self.groups) + # When creating a intermediate child node, leave kwargs empty + self.__setattr__(identifier, {k:Options(k, allowed_keywords=v.allowed_keywords) + for k,v in self.groups.items()}) return self[identifier] @@ -536,7 +542,8 @@ def closest(self, obj, group): components = (obj.__class__.__name__, group_sanitizer(obj.group), label_sanitizer(obj.label)) - return self.find(components).options(group) + target = '.'.join([c for c in components if c]) + return self.find(components).options(group, target=target) diff --git a/tests/testoptions.py b/tests/testoptions.py index dc124cb24a..cc0b2186eb 100644 --- a/tests/testoptions.py +++ b/tests/testoptions.py @@ -9,6 +9,18 @@ Options.skip_invalid = False +try: + # Needed a backend to register backend and options + from holoviews.plotting import mpl +except: + pass + +try: + # Needed to register backend and options + from holoviews.plotting import bokeh +except: + pass + class TestOptions(ComparisonTestCase): def test_options_init(self): @@ -180,6 +192,199 @@ def test_optiontree_inheritance_flipped(self): {'kw2':'value2', 'kw4':'value4'}) +class TestStoreInheritanceDynamic(ComparisonTestCase): + """ + Tests to prevent regression after fix in PR #646 + """ + + def setUp(self): + self.store_copy = OptionTree(sorted(Store.options().items()), + groups=['style', 'plot', 'norm']) + self.backend = 'matplotlib' + Store.current_backend = self.backend + super(TestStoreInheritanceDynamic, self).setUp() + + def tearDown(self): + Store.options(val=self.store_copy) + super(TestStoreInheritanceDynamic, self).tearDown() + + def initialize_option_tree(self): + Store.options(val=OptionTree(groups=['plot', 'style'])) + options = Store.options() + options.Image = Options('style', cmap='hot', interpolation='nearest') + return options + + def test_merge_keywords(self): + options = self.initialize_option_tree() + options.Image = Options('style', clims=(0, 0.5)) + + expected = {'clims': (0, 0.5), 'cmap': 'hot', 'interpolation': 'nearest'} + direct_kws = options.Image.groups['style'].kwargs + inherited_kws = options.Image.options('style').kwargs + self.assertEqual(direct_kws, expected) + self.assertEqual(inherited_kws, expected) + + def test_merge_keywords_disabled(self): + options = self.initialize_option_tree() + options.Image = Options('style', clims=(0, 0.5), merge_keywords=False) + + expected = {'clims': (0, 0.5)} + direct_kws = options.Image.groups['style'].kwargs + inherited_kws = options.Image.options('style').kwargs + self.assertEqual(direct_kws, expected) + self.assertEqual(inherited_kws, expected) + + def test_specification_general_to_specific_group(self): + """ + Test order of specification starting with general and moving + to specific + """ + if 'matplotlib' not in Store.renderers: + raise SkipTest("General to specific option test requires matplotlib") + + options = self.initialize_option_tree() + + obj = Image(np.random.rand(10,10), group='SomeGroup') + + options.Image = Options('style', cmap='viridis') + options.Image.SomeGroup = Options('style', alpha=0.2) + + expected = {'alpha': 0.2, 'cmap': 'viridis', 'interpolation': 'nearest'} + lookup = Store.lookup_options('matplotlib', obj, 'style') + + self.assertEqual(lookup.kwargs, expected) + # Check the tree is structured as expected + node1 = options.Image.groups['style'] + node2 = options.Image.SomeGroup.groups['style'] + + self.assertEqual(node1.kwargs, {'cmap': 'viridis', 'interpolation': 'nearest'}) + self.assertEqual(node2.kwargs, {'alpha': 0.2}) + + + def test_specification_general_to_specific_group_and_label(self): + """ + Test order of specification starting with general and moving + to specific + """ + if 'matplotlib' not in Store.renderers: + raise SkipTest("General to specific option test requires matplotlib") + + options = self.initialize_option_tree() + + obj = Image(np.random.rand(10,10), group='SomeGroup', label='SomeLabel') + + options.Image = Options('style', cmap='viridis') + options.Image.SomeGroup.SomeLabel = Options('style', alpha=0.2) + + expected = {'alpha': 0.2, 'cmap': 'viridis', 'interpolation': 'nearest'} + lookup = Store.lookup_options('matplotlib', obj, 'style') + + self.assertEqual(lookup.kwargs, expected) + # Check the tree is structured as expected + node1 = options.Image.groups['style'] + node2 = options.Image.SomeGroup.SomeLabel.groups['style'] + + self.assertEqual(node1.kwargs, {'cmap': 'viridis', 'interpolation': 'nearest'}) + self.assertEqual(node2.kwargs, {'alpha': 0.2}) + + def test_specification_specific_to_general_group(self): + """ + Test order of specification starting with a specific option and + then specifying a general one + """ + if 'matplotlib' not in Store.renderers: + raise SkipTest("General to specific option test requires matplotlib") + + options = self.initialize_option_tree() + options.Image.SomeGroup = Options('style', alpha=0.2) + + obj = Image(np.random.rand(10,10), group='SomeGroup') + options.Image = Options('style', cmap='viridis') + + expected = {'alpha': 0.2, 'cmap': 'viridis', 'interpolation': 'nearest'} + lookup = Store.lookup_options('matplotlib', obj, 'style') + + self.assertEqual(lookup.kwargs, expected) + # Check the tree is structured as expected + node1 = options.Image.groups['style'] + node2 = options.Image.SomeGroup.groups['style'] + + self.assertEqual(node1.kwargs, {'cmap': 'viridis', 'interpolation': 'nearest'}) + self.assertEqual(node2.kwargs, {'alpha': 0.2}) + + + def test_specification_specific_to_general_group_and_label(self): + """ + Test order of specification starting with general and moving + to specific + """ + if 'matplotlib' not in Store.renderers: + raise SkipTest("General to specific option test requires matplotlib") + + options = self.initialize_option_tree() + options.Image.SomeGroup.SomeLabel = Options('style', alpha=0.2) + obj = Image(np.random.rand(10,10), group='SomeGroup', label='SomeLabel') + + options.Image = Options('style', cmap='viridis') + expected = {'alpha': 0.2, 'cmap': 'viridis', 'interpolation': 'nearest'} + lookup = Store.lookup_options('matplotlib', obj, 'style') + + self.assertEqual(lookup.kwargs, expected) + # Check the tree is structured as expected + node1 = options.Image.groups['style'] + node2 = options.Image.SomeGroup.SomeLabel.groups['style'] + + self.assertEqual(node1.kwargs, {'cmap': 'viridis', 'interpolation': 'nearest'}) + self.assertEqual(node2.kwargs, {'alpha': 0.2}) + + def test_custom_call_to_default_inheritance(self): + """ + Checks customs inheritance backs off to default tree correctly + using __call__. + """ + options = self.initialize_option_tree() + options.Image.A.B = Options('style', alpha=0.2) + + obj = Image(np.random.rand(10, 10), group='A', label='B') + expected_obj = {'alpha': 0.2, 'cmap': 'hot', 'interpolation': 'nearest'} + obj_lookup = Store.lookup_options('matplotlib', obj, 'style') + self.assertEqual(obj_lookup.kwargs, expected_obj) + + # Customize this particular object + custom_obj = obj(style=dict(clims=(0, 0.5))) + expected_custom_obj = dict(clims=(0,0.5), **expected_obj) + custom_obj_lookup = Store.lookup_options('matplotlib', custom_obj, 'style') + self.assertEqual(custom_obj_lookup.kwargs, expected_custom_obj) + + def test_custom_magic_to_default_inheritance(self): + """ + Checks customs inheritance backs off to default tree correctly + simulating the %%opts cell magic. + """ + if 'matplotlib' not in Store.renderers: + raise SkipTest("Custom magic inheritance test requires matplotlib") + options = self.initialize_option_tree() + options.Image.A.B = Options('style', alpha=0.2) + + obj = Image(np.random.rand(10, 10), group='A', label='B') + + # Before customizing... + expected_obj = {'alpha': 0.2, 'cmap': 'hot', 'interpolation': 'nearest'} + obj_lookup = Store.lookup_options('matplotlib', obj, 'style') + self.assertEqual(obj_lookup.kwargs, expected_obj) + + custom_tree = {0: OptionTree(groups=['plot', 'style', 'norm'], + style={'Image' : dict(clims=(0, 0.5))})} + Store._custom_options['matplotlib'] = custom_tree + obj.id = 0 # Manually set the id to point to the tree above + + # Customize this particular object + expected_custom_obj = dict(clims=(0,0.5), **expected_obj) + custom_obj_lookup = Store.lookup_options('matplotlib', obj, 'style') + self.assertEqual(custom_obj_lookup.kwargs, expected_custom_obj) + + + class TestStoreInheritance(ComparisonTestCase): """ Tests to prevent regression after fix in 71c1f3a that resolves