diff --git a/holoviews/ipython/parser.py b/holoviews/ipython/parser.py index ff6c0b6777..62f23266f3 100644 --- a/holoviews/ipython/parser.py +++ b/holoviews/ipython/parser.py @@ -252,6 +252,59 @@ def process_normalization(cls, parse_group): framewise=framewise) + @classmethod + def _group_paths_without_options(cls, line_parse_result): + """ + Given a parsed options specification as a list of groups, combine + groups without options with the first subsequent group which has + options. + A line of the form + 'A B C [opts] D E [opts_2]' + results in + [({A, B, C}, [opts]), ({D, E}, [opts_2])] + """ + active_pathspecs = set() + for group in line_parse_result: + active_pathspecs.add(group['pathspec']) + + has_options = ( + 'norm_options' in group or + 'plot_options' in group or + 'style_options' in group + ) + if has_options: + yield active_pathspecs, group + active_pathspecs = set() + + if active_pathspecs: + yield active_pathspecs, {} + + + @classmethod + def _merge_options(cls, old_opts, new_opts): + """ + Update the old_opts option dictionary with the options defined in + new_opts. Instead of a shallow update as would be performed by calling + old_opts.update(new_opts), this updates the dictionaries of all option + types separately. + + Given two dictionaries + old_opts = {'a': {'x': 'old', 'y': 'old'}} + and + new_opts = {'a': {'y': 'new', 'z': 'new'}, 'b': {'k': 'new'}} + this returns a dictionary + {'a': {'x': 'old', 'y': 'new', 'z': 'new'}, 'b': {'k': 'new'}} + """ + merged = dict(old_opts) + + for option_type, options in new_opts.items(): + if option_type not in merged: + merged[option_type] = {} + + merged[option_type].update(options) + + return merged + @classmethod def parse(cls, line, ns={}): @@ -268,30 +321,35 @@ def parse(cls, line, ns={}): if (processed.strip() != line.strip()): raise SyntaxError("Failed to parse remainder of string: %r" % line[e:]) + grouped_paths = cls._group_paths_without_options(cls.opts_spec.parseString(line)) parse = {} - for group in cls.opts_spec.parseString(line): + for pathspecs, group in grouped_paths: options = {} normalization = cls.process_normalization(group) if normalization is not None: - options['norm'] = Options(**normalization) + options['norm'] = normalization if 'plot_options' in group: plotopts = group['plot_options'][0] opts = cls.todict(plotopts, 'brackets', ns=ns) - options['plot'] = Options(**{cls.aliases.get(k,k):v for k,v in opts.items()}) + options['plot'] = {cls.aliases.get(k,k):v for k,v in opts.items()} if 'style_options' in group: styleopts = group['style_options'][0] opts = cls.todict(styleopts, 'parens', ns=ns) - options['style'] = Options(**{cls.aliases.get(k,k):v for k,v in opts.items()}) - - if group['pathspec'] in parse: - # Update in case same pathspec accidentally repeated by the user. - parse[group['pathspec']].update(options) - else: - parse[group['pathspec']] = options - return parse + options['style'] = {cls.aliases.get(k,k):v for k,v in opts.items()} + + for pathspec in pathspecs: + parse[pathspec] = cls._merge_options(parse.get(pathspec, {}), options) + + return { + path: { + option_type: Options(**option_pairs) + for option_type, option_pairs in options.items() + } + for path, options in parse.items() + } diff --git a/tests/testparsers.py b/tests/testparsers.py index 566c37c0b4..b2c048070c 100644 --- a/tests/testparsers.py +++ b/tests/testparsers.py @@ -63,6 +63,32 @@ def test_plot_opts_nested_brackets(self): expected = {'Curve': {'plot': Options(title_format='A, B')}} self.assertEqual(OptsSpec.parse(line), expected) + def test_plot_opts_multiple_paths(self): + line = "Image Curve [fig_inches=(3, 3) title_format='foo bar']" + expected = {'Image': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}, + 'Curve': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}} + self.assertEqual(OptsSpec.parse(line), expected) + + def test_plot_opts_multiple_paths_2(self): + line = "Image Curve Layout Overlay[fig_inches=(3, 3) title_format='foo bar']" + expected = {'Image': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}, + 'Curve': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}, + 'Layout': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}, + 'Overlay': + {'plot': + Options(title_format='foo bar', fig_inches=(3, 3))}} + self.assertEqual(OptsSpec.parse(line), expected) + class OptsSpecStyleOptionsTests(ComparisonTestCase): @@ -136,6 +162,16 @@ def test_style_opts_cycle_list(self): expected = {'Curve': {'style': Options(color=Cycle(values=['r', 'g', 'b']))}} self.assertEqual(OptsSpec.parse(line, {'Cycle': Cycle}), expected) + def test_style_opts_multiple_paths(self): + line = "Image Curve (color='beautiful')" + expected = {'Image': + {'style': + Options(color='beautiful')}, + 'Curve': + {'style': + Options(color='beautiful')}} + self.assertEqual(OptsSpec.parse(line), expected) + class OptsNormPlotOptionsTests(ComparisonTestCase): @@ -167,6 +203,16 @@ def test_norm_opts_simple_explicit_2(self): {'norm': Options(axiswise=True, framewise=True)}} self.assertEqual(OptsSpec.parse(line), expected) + def test_norm_opts_multiple_paths(self): + line = "Image Curve {+axiswise +framewise}" + expected = {'Image': + {'norm': + Options(axiswise=True, framewise=True)}, + 'Curve': + {'norm': + Options(axiswise=True, framewise=True)}} + self.assertEqual(OptsSpec.parse(line), expected) + class OptsSpecCombinedOptionsTests(ComparisonTestCase): @@ -197,3 +243,47 @@ def test_combined_two_types_2(self): 'style': Options(string='foo'), 'plot': Options(foo='bar baz')}} self.assertEqual(OptsSpec.parse(line), expected) + + def test_combined_multiple_paths(self): + line = "Image Curve {+framewise} [fig_inches=(3, 3) title_format='foo bar'] (c='b') Layout [string='foo'] Overlay" + expected = {'Image': + {'norm': + Options(framewise=True, axiswise=False), + 'plot': + Options(title_format='foo bar', fig_inches=(3, 3)), + 'style': + Options(c='b')}, + 'Curve': + {'norm': + Options(framewise=True, axiswise=False), + 'plot': + Options(title_format='foo bar', fig_inches=(3, 3)), + 'style': + Options(c='b')}, + 'Layout': + {'plot': + Options(string='foo')}, + 'Overlay': + {}} + self.assertEqual(OptsSpec.parse(line), expected) + + def test_combined_multiple_paths_merge(self): + line = "Image Curve [fig_inches=(3, 3)] (c='b') Image (s=3)" + expected = {'Image': + {'plot': + Options(fig_inches=(3, 3)), + 'style': + Options(c='b', s=3)}, + 'Curve': + {'plot': + Options(fig_inches=(3, 3)), + 'style': + Options(c='b')}} + self.assertEqual(OptsSpec.parse(line), expected) + + def test_combined_multiple_paths_merge_precedence(self): + line = "Image (s=0, c='b') Image (s=3)" + expected = {'Image': + {'style': + Options(c='b', s=3)}} + self.assertEqual(OptsSpec.parse(line), expected)