1#-----------------------------------------------------------------------------
2# Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors.
3# All rights reserved.
4#
5# The full license is in the file LICENSE.txt, distributed with this software.
6#-----------------------------------------------------------------------------
7''' Models for representing top-level plot objects.
8
9'''
10
11#-----------------------------------------------------------------------------
12# Boilerplate
13#-----------------------------------------------------------------------------
14import logging # isort:skip
15log = logging.getLogger(__name__)
16
17#-----------------------------------------------------------------------------
18# Imports
19#-----------------------------------------------------------------------------
20
21# Standard library imports
22import warnings
23
24# Bokeh imports
25from ..core.enums import Location, OutputBackend, ResetPolicy
26from ..core.properties import (
27    Alias,
28    Bool,
29    Dict,
30    Either,
31    Enum,
32    Float,
33    Include,
34    Instance,
35    Int,
36    List,
37    Null,
38    Nullable,
39    Override,
40    Readonly,
41    String,
42)
43from ..core.property_mixins import ScalarFillProps, ScalarLineProps
44from ..core.query import find
45from ..core.validation import error, warning
46from ..core.validation.errors import (
47    BAD_EXTRA_RANGE_NAME,
48    INCOMPATIBLE_SCALE_AND_RANGE,
49    REQUIRED_RANGE,
50    REQUIRED_SCALE,
51)
52from ..core.validation.warnings import (
53    FIXED_HEIGHT_POLICY,
54    FIXED_SIZING_MODE,
55    FIXED_WIDTH_POLICY,
56    MISSING_RENDERERS,
57)
58from ..model import Model
59from ..util.string import nice_join
60from .annotations import Annotation, Legend, Title
61from .axes import Axis
62from .glyphs import Glyph
63from .grids import Grid
64from .layouts import LayoutDOM
65from .ranges import DataRange1d, FactorRange, Range, Range1d
66from .renderers import GlyphRenderer, Renderer, TileRenderer
67from .scales import CategoricalScale, LinearScale, LogScale, Scale
68from .sources import ColumnDataSource, DataSource
69from .tools import HoverTool, Tool, Toolbar
70
71#-----------------------------------------------------------------------------
72# Globals and constants
73#-----------------------------------------------------------------------------
74
75__all__ = (
76    'Plot',
77)
78
79_VALID_PLACES = ('left', 'right', 'above', 'below', 'center')
80
81#-----------------------------------------------------------------------------
82# General API
83#-----------------------------------------------------------------------------
84
85class Plot(LayoutDOM):
86    ''' Model representing a plot, containing glyphs, guides, annotations.
87
88    '''
89
90    def select(self, *args, **kwargs):
91        ''' Query this object and all of its references for objects that
92        match the given selector.
93
94        There are a few different ways to call the ``select`` method.
95        The most general is to supply a JSON-like query dictionary as the
96        single argument or as keyword arguments:
97
98        Args:
99            selector (JSON-like) : some sample text
100
101        Keyword Arguments:
102            kwargs : query dict key/values as keyword arguments
103
104        Additionally, for compatibility with ``Model.select``, a selector
105        dict may be passed as ``selector`` keyword argument, in which case
106        the value of ``kwargs['selector']`` is used for the query.
107
108        For convenience, queries on just names can be made by supplying
109        the ``name`` string as the single parameter:
110
111        Args:
112            name (str) : the name to query on
113
114        Also queries on just type can be made simply by supplying the
115        ``Model`` subclass as the single parameter:
116
117        Args:
118            type (Model) : the type to query on
119
120        Returns:
121            seq[Model]
122
123        Examples:
124
125            .. code-block:: python
126
127                # These three are equivalent
128                p.select(selector={"type": HoverTool})
129                p.select({"type": HoverTool})
130                p.select(HoverTool)
131
132                # These two are also equivalent
133                p.select({"name": "mycircle"})
134                p.select("mycircle")
135
136                # Keyword arguments can be supplied in place of selector dict
137                p.select({"name": "foo", "type": HoverTool})
138                p.select(name="foo", type=HoverTool)
139
140        '''
141
142        selector = _select_helper(args, kwargs)
143
144        # Want to pass selector that is a dictionary
145        return _list_attr_splat(find(self.references(), selector, {'plot': self}))
146
147    def row(self, row, gridplot):
148        ''' Return whether this plot is in a given row of a GridPlot.
149
150        Args:
151            row (int) : index of the row to test
152            gridplot (GridPlot) : the GridPlot to check
153
154        Returns:
155            bool
156
157        '''
158        return self in gridplot.row(row)
159
160    def column(self, col, gridplot):
161        ''' Return whether this plot is in a given column of a GridPlot.
162
163        Args:
164            col (int) : index of the column to test
165            gridplot (GridPlot) : the GridPlot to check
166
167        Returns:
168            bool
169
170        '''
171        return self in gridplot.column(col)
172
173    def _axis(self, *sides):
174        objs = []
175        for s in sides:
176            objs.extend(getattr(self, s, []))
177        axis = [obj for obj in objs if isinstance(obj, Axis)]
178        return _list_attr_splat(axis)
179
180    @property
181    def xaxis(self):
182        ''' Splattable list of :class:`~bokeh.models.axes.Axis` objects for the x dimension.
183
184        '''
185        return self._axis("above", "below")
186
187    @property
188    def yaxis(self):
189        ''' Splattable list of :class:`~bokeh.models.axes.Axis` objects for the y dimension.
190
191        '''
192        return self._axis("left", "right")
193
194    @property
195    def axis(self):
196        ''' Splattable list of :class:`~bokeh.models.axes.Axis` objects.
197
198        '''
199        return _list_attr_splat(self.xaxis + self.yaxis)
200
201    @property
202    def legend(self):
203        ''' Splattable list of :class:`~bokeh.models.annotations.Legend` objects.
204
205        '''
206        panels = self.above + self.below + self.left + self.right + self.center
207        legends = [obj for obj in panels if isinstance(obj, Legend)]
208        return _legend_attr_splat(legends)
209
210    @property
211    def hover(self):
212        ''' Splattable list of :class:`~bokeh.models.tools.HoverTool` objects.
213
214        '''
215        hovers = [obj for obj in self.tools if isinstance(obj, HoverTool)]
216        return _list_attr_splat(hovers)
217
218    def _grid(self, dimension):
219        grid = [obj for obj in self.center if isinstance(obj, Grid) and obj.dimension == dimension]
220        return _list_attr_splat(grid)
221
222    @property
223    def xgrid(self):
224        ''' Splattable list of :class:`~bokeh.models.grids.Grid` objects for the x dimension.
225
226        '''
227        return self._grid(0)
228
229    @property
230    def ygrid(self):
231        ''' Splattable list of :class:`~bokeh.models.grids.Grid` objects for the y dimension.
232
233        '''
234        return self._grid(1)
235
236    @property
237    def grid(self):
238        ''' Splattable list of :class:`~bokeh.models.grids.Grid` objects.
239
240        '''
241        return _list_attr_splat(self.xgrid + self.ygrid)
242
243    @property
244    def tools(self):
245        return self.toolbar.tools
246
247    @tools.setter
248    def tools(self, tools):
249        self.toolbar.tools = tools
250
251    def add_layout(self, obj, place='center'):
252        ''' Adds an object to the plot in a specified place.
253
254        Args:
255            obj (Renderer) : the object to add to the Plot
256            place (str, optional) : where to add the object (default: 'center')
257                Valid places are: 'left', 'right', 'above', 'below', 'center'.
258
259        Returns:
260            None
261
262        '''
263        if place not in _VALID_PLACES:
264            raise ValueError(
265                "Invalid place '%s' specified. Valid place values are: %s" % (place, nice_join(_VALID_PLACES))
266            )
267
268        getattr(self, place).append(obj)
269
270    def add_tools(self, *tools):
271        ''' Adds tools to the plot.
272
273        Args:
274            *tools (Tool) : the tools to add to the Plot
275
276        Returns:
277            None
278
279        '''
280        for tool in tools:
281            if not isinstance(tool, Tool):
282                raise ValueError("All arguments to add_tool must be Tool subclasses.")
283
284            self.toolbar.tools.append(tool)
285
286    def add_glyph(self, source_or_glyph, glyph=None, **kw):
287        ''' Adds a glyph to the plot with associated data sources and ranges.
288
289        This function will take care of creating and configuring a Glyph object,
290        and then add it to the plot's list of renderers.
291
292        Args:
293            source (DataSource) : a data source for the glyphs to all use
294            glyph (Glyph) : the glyph to add to the Plot
295
296
297        Keyword Arguments:
298            Any additional keyword arguments are passed on as-is to the
299            Glyph initializer.
300
301        Returns:
302            GlyphRenderer
303
304        '''
305        if glyph is not None:
306            source = source_or_glyph
307        else:
308            source, glyph = ColumnDataSource(), source_or_glyph
309
310        if not isinstance(source, DataSource):
311            raise ValueError("'source' argument to add_glyph() must be DataSource subclass")
312
313        if not isinstance(glyph, Glyph):
314            raise ValueError("'glyph' argument to add_glyph() must be Glyph subclass")
315
316        g = GlyphRenderer(data_source=source, glyph=glyph, **kw)
317        self.renderers.append(g)
318        return g
319
320    def add_tile(self, tile_source, **kw):
321        ''' Adds new ``TileRenderer`` into ``Plot.renderers``
322
323        Args:
324            tile_source (TileSource) : a tile source instance which contain tileset configuration
325
326        Keyword Arguments:
327            Additional keyword arguments are passed on as-is to the tile renderer
328
329        Returns:
330            TileRenderer : TileRenderer
331
332        '''
333        tile_renderer = TileRenderer(tile_source=tile_source, **kw)
334        self.renderers.append(tile_renderer)
335        return tile_renderer
336
337    @error(REQUIRED_RANGE)
338    def _check_required_range(self):
339        missing = []
340        if not self.x_range: missing.append('x_range')
341        if not self.y_range: missing.append('y_range')
342        if missing:
343            return ", ".join(missing) + " [%s]" % self
344
345    @error(REQUIRED_SCALE)
346    def _check_required_scale(self):
347        missing = []
348        if not self.x_scale: missing.append('x_scale')
349        if not self.y_scale: missing.append('y_scale')
350        if missing:
351            return ", ".join(missing) + " [%s]" % self
352
353    @error(INCOMPATIBLE_SCALE_AND_RANGE)
354    def _check_compatible_scale_and_ranges(self):
355        incompatible = []
356        x_ranges = list(self.extra_x_ranges.values())
357        if self.x_range: x_ranges.append(self.x_range)
358        y_ranges = list(self.extra_y_ranges.values())
359        if self.y_range: y_ranges.append(self.y_range)
360
361        if self.x_scale is not None:
362            for rng in x_ranges:
363                if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.x_scale, (LinearScale, LogScale)):
364                    incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
365                elif isinstance(rng, FactorRange) and not isinstance(self.x_scale, CategoricalScale):
366                    incompatible.append("incompatibility on x-dimension: %s/%s" %(rng, self.x_scale))
367                # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
368                if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.x_scale, CategoricalScale):
369                    incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
370
371        if self.y_scale is not None:
372            for rng in y_ranges:
373                if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.y_scale, (LinearScale, LogScale)):
374                    incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
375                elif isinstance(rng, FactorRange) and not isinstance(self.y_scale, CategoricalScale):
376                    incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
377                # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
378                if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.y_scale, CategoricalScale):
379                    incompatible.append("incompatibility on y-dimension: %s, %s" %(rng, self.y_scale))
380
381        if incompatible:
382            return ", ".join(incompatible) + " [%s]" % self
383
384    @warning(MISSING_RENDERERS)
385    def _check_missing_renderers(self):
386        if len(self.renderers) == 0 and len([x for x in self.center if isinstance(x, Annotation)]) == 0:
387            return str(self)
388
389    @error(BAD_EXTRA_RANGE_NAME)
390    def _check_bad_extra_range_name(self):
391        msg   = ""
392        valid = {
393            f'{axis}_name': {'default', *getattr(self, f"extra_{axis}s")}
394            for axis in ("x_range", "y_range")
395        }
396        for place in _VALID_PLACES + ('renderers',):
397            for ref in getattr(self, place):
398                bad = ', '.join(
399                    f"{axis}='{getattr(ref, axis)}'"
400                    for axis, keys in valid.items()
401                    if getattr(ref, axis, 'default') not in keys
402                )
403                if bad:
404                    msg += (", " if msg else "") + f"{bad} [{ref}]"
405        if msg:
406            return msg
407
408    x_range = Instance(Range, default=lambda: DataRange1d(), help="""
409    The (default) data range of the horizontal dimension of the plot.
410    """)
411
412    y_range = Instance(Range, default=lambda: DataRange1d(), help="""
413    The (default) data range of the vertical dimension of the plot.
414    """)
415
416    @classmethod
417    def _scale(cls, scale):
418        if scale in ["auto", "linear"]:
419            return LinearScale()
420        elif scale == "log":
421            return LogScale()
422        if scale == "categorical":
423            return CategoricalScale()
424        else:
425            raise ValueError("Unknown mapper_type: %s" % scale)
426
427    x_scale = Instance(Scale, default=lambda: LinearScale(), help="""
428    What kind of scale to use to convert x-coordinates in data space
429    into x-coordinates in screen space.
430    """)
431
432    y_scale = Instance(Scale, default=lambda: LinearScale(), help="""
433    What kind of scale to use to convert y-coordinates in data space
434    into y-coordinates in screen space.
435    """)
436
437    extra_x_ranges = Dict(String, Instance(Range), help="""
438    Additional named ranges to make available for mapping x-coordinates.
439
440    This is useful for adding additional axes.
441    """)
442
443    extra_y_ranges = Dict(String, Instance(Range), help="""
444    Additional named ranges to make available for mapping y-coordinates.
445
446    This is useful for adding additional axes.
447    """)
448
449    hidpi = Bool(default=True, help="""
450    Whether to use HiDPI mode when available.
451    """)
452
453    title = Either(Null, String, Instance(Title), default=lambda: Title(text=""), help="""
454    A title for the plot. Can be a text string or a Title annotation.
455    """)
456
457    title_location = Nullable(Enum(Location), default="above", help="""
458    Where the title will be located. Titles on the left or right side
459    will be rotated.
460    """)
461
462    outline_props = Include(ScalarLineProps, help="""
463    The %s for the plot border outline.
464    """)
465
466    outline_line_color = Override(default="#e5e5e5")
467
468    renderers = List(Instance(Renderer), help="""
469    A list of all renderers for this plot, including guides and annotations
470    in addition to glyphs.
471
472    This property can be manipulated by hand, but the ``add_glyph`` and
473    ``add_layout`` methods are recommended to help make sure all necessary
474    setup is performed.
475    """)
476
477    toolbar = Instance(Toolbar, default=lambda: Toolbar(), help="""
478    The toolbar associated with this plot which holds all the tools. It is
479    automatically created with the plot if necessary.
480    """)
481
482    toolbar_location = Nullable(Enum(Location), default="right", help="""
483    Where the toolbar will be located. If set to None, no toolbar
484    will be attached to the plot.
485    """)
486
487    toolbar_sticky = Bool(default=True, help="""
488    Stick the toolbar to the edge of the plot. Default: True. If False,
489    the toolbar will be outside of the axes, titles etc.
490    """)
491
492    left = List(Instance(Renderer), help="""
493    A list of renderers to occupy the area to the left of the plot.
494    """)
495
496    right = List(Instance(Renderer), help="""
497    A list of renderers to occupy the area to the right of the plot.
498    """)
499
500    above = List(Instance(Renderer), help="""
501    A list of renderers to occupy the area above of the plot.
502    """)
503
504    below = List(Instance(Renderer), help="""
505    A list of renderers to occupy the area below of the plot.
506    """)
507
508    center = List(Instance(Renderer), help="""
509    A list of renderers to occupy the center area (frame) of the plot.
510    """)
511
512    width = Override(default=600)
513
514    height = Override(default=600)
515
516    plot_width: int = Alias("width", help="""
517    The outer width of a plot, including any axes, titles, border padding, etc.
518    """)
519
520    plot_height: int = Alias("height", help="""
521    The outer height of a plot, including any axes, titles, border padding, etc.
522    """)
523
524    frame_width = Nullable(Int, help="""
525    The width of a plot frame or the inner width of a plot, excluding any
526    axes, titles, border padding, etc.
527    """)
528
529    frame_height = Nullable(Int, help="""
530    The height of a plot frame or the inner height of a plot, excluding any
531    axes, titles, border padding, etc.
532    """)
533
534    inner_width = Readonly(Int, help="""
535    This is the exact width of the plotting canvas, i.e. the width of
536    the actual plot, without toolbars etc. Note this is computed in a
537    web browser, so this property will work only in backends capable of
538    bidirectional communication (server, notebook).
539
540    .. note::
541        This is an experimental feature and the API may change in near future.
542
543    """)
544
545    inner_height = Readonly(Int, help="""
546    This is the exact height of the plotting canvas, i.e. the height of
547    the actual plot, without toolbars etc. Note this is computed in a
548    web browser, so this property will work only in backends capable of
549    bidirectional communication (server, notebook).
550
551    .. note::
552        This is an experimental feature and the API may change in near future.
553
554    """)
555
556    outer_width = Readonly(Int, help="""
557    This is the exact width of the layout, i.e. the height of
558    the actual plot, with toolbars etc. Note this is computed in a
559    web browser, so this property will work only in backends capable of
560    bidirectional communication (server, notebook).
561
562    .. note::
563        This is an experimental feature and the API may change in near future.
564
565    """)
566
567    outer_height = Readonly(Int, help="""
568    This is the exact height of the layout, i.e. the height of
569    the actual plot, with toolbars etc. Note this is computed in a
570    web browser, so this property will work only in backends capable of
571    bidirectional communication (server, notebook).
572
573    .. note::
574        This is an experimental feature and the API may change in near future.
575
576    """)
577
578    background_props = Include(ScalarFillProps, help="""
579    The %s for the plot background style.
580    """)
581
582    background_fill_color = Override(default='#ffffff')
583
584    border_props = Include(ScalarFillProps, help="""
585    The %s for the plot border style.
586    """)
587
588    border_fill_color = Override(default='#ffffff')
589
590    min_border_top = Nullable(Int, help="""
591    Minimum size in pixels of the padding region above the top of the
592    central plot region.
593
594    .. note::
595        This is a *minimum*. The padding region may expand as needed to
596        accommodate titles or axes, etc.
597
598    """)
599
600    min_border_bottom = Nullable(Int, help="""
601    Minimum size in pixels of the padding region below the bottom of
602    the central plot region.
603
604    .. note::
605        This is a *minimum*. The padding region may expand as needed to
606        accommodate titles or axes, etc.
607
608    """)
609
610    min_border_left = Nullable(Int, help="""
611    Minimum size in pixels of the padding region to the left of
612    the central plot region.
613
614    .. note::
615        This is a *minimum*. The padding region may expand as needed to
616        accommodate titles or axes, etc.
617
618    """)
619
620    min_border_right = Nullable(Int, help="""
621    Minimum size in pixels of the padding region to the right of
622    the central plot region.
623
624    .. note::
625        This is a *minimum*. The padding region may expand as needed to
626        accommodate titles or axes, etc.
627
628    """)
629
630    min_border = Nullable(Int, default=5, help="""
631    A convenience property to set all all the ``min_border_X`` properties
632    to the same value. If an individual border property is explicitly set,
633    it will override ``min_border``.
634    """)
635
636    lod_factor = Int(10, help="""
637    Decimation factor to use when applying level-of-detail decimation.
638    """)
639
640    lod_threshold = Nullable(Int, default=2000, help="""
641    A number of data points, above which level-of-detail downsampling may
642    be performed by glyph renderers. Set to ``None`` to disable any
643    level-of-detail downsampling.
644    """)
645
646    lod_interval = Int(300, help="""
647    Interval (in ms) during which an interactive tool event will enable
648    level-of-detail downsampling.
649    """)
650
651    lod_timeout = Int(500, help="""
652    Timeout (in ms) for checking whether interactive tool events are still
653    occurring. Once level-of-detail mode is enabled, a check is made every
654    ``lod_timeout`` ms. If no interactive tool events have happened,
655    level-of-detail mode is disabled.
656    """)
657
658    output_backend = Enum(OutputBackend, default="canvas", help="""
659    Specify the output backend for the plot area. Default is HTML5 Canvas.
660
661    .. note::
662        When set to ``webgl``, glyphs without a WebGL rendering implementation
663        will fall back to rendering onto 2D canvas.
664    """)
665
666    match_aspect = Bool(default=False, help="""
667    Specify the aspect ratio behavior of the plot. Aspect ratio is defined as
668    the ratio of width over height. This property controls whether Bokeh should
669    attempt the match the (width/height) of *data space* to the (width/height)
670    in pixels of *screen space*.
671
672    Default is ``False`` which indicates that the *data* aspect ratio and the
673    *screen* aspect ratio vary independently. ``True`` indicates that the plot
674    aspect ratio of the axes will match the aspect ratio of the pixel extent
675    the axes. The end result is that a 1x1 area in data space is a square in
676    pixels, and conversely that a 1x1 pixel is a square in data units.
677
678    .. note::
679        This setting only takes effect when there are two dataranges. This
680        setting only sets the initial plot draw and subsequent resets. It is
681        possible for tools (single axis zoom, unconstrained box zoom) to
682        change the aspect ratio.
683
684    .. warning::
685        This setting is incompatible with linking dataranges across multiple
686        plots. Doing so may result in undefined behaviour.
687    """)
688
689    aspect_scale = Float(default=1, help="""
690    A value to be given for increased aspect ratio control. This value is added
691    multiplicatively to the calculated value required for ``match_aspect``.
692    ``aspect_scale`` is defined as the ratio of width over height of the figure.
693
694    For example, a plot with ``aspect_scale`` value of 2 will result in a
695    square in *data units* to be drawn on the screen as a rectangle with a
696    pixel width twice as long as its pixel height.
697
698    .. note::
699        This setting only takes effect if ``match_aspect`` is set to ``True``.
700    """)
701
702    reset_policy = Enum(ResetPolicy, default="standard", help="""
703    How a plot should respond to being reset. By deafult, the standard actions
704    are to clear any tool state history, return plot ranges to their original
705    values, undo all selections, and emit a ``Reset`` event. If customization
706    is desired, this property may be set to ``"event_only"``, which will
707    suppress all of the actions except the Reset event.
708    """)
709
710    # XXX: override LayoutDOM's definitions because of plot_{width,height}.
711    @error(FIXED_SIZING_MODE)
712    def _check_fixed_sizing_mode(self):
713        pass
714
715    @error(FIXED_WIDTH_POLICY)
716    def _check_fixed_width_policy(self):
717        pass
718
719    @error(FIXED_HEIGHT_POLICY)
720    def _check_fixed_height_policy(self):
721        pass
722
723#-----------------------------------------------------------------------------
724# Dev API
725#-----------------------------------------------------------------------------
726
727#-----------------------------------------------------------------------------
728# Private API
729#-----------------------------------------------------------------------------
730
731def _check_conflicting_kwargs(a1, a2, kwargs):
732    if a1 in kwargs and a2 in kwargs:
733        raise ValueError("Conflicting properties set on plot: %r and %r" % (a1, a2))
734
735class _list_attr_splat(list):
736    def __setattr__(self, attr, value):
737        for x in self:
738            setattr(x, attr, value)
739    def __getattribute__(self, attr):
740        if attr in dir(list):
741            return list.__getattribute__(self, attr)
742        if len(self) == 0:
743            raise AttributeError("Trying to access %r attribute on an empty 'splattable' list" % attr)
744        if len(self) == 1:
745            return getattr(self[0], attr)
746        try:
747            return _list_attr_splat([getattr(x, attr) for x in self])
748        except Exception:
749            raise AttributeError("Trying to access %r attribute on a 'splattable' list, but list items have no %r attribute" % (attr, attr))
750
751    def __dir__(self):
752        if len({type(x) for x in self}) == 1:
753            return dir(self[0])
754        else:
755            return dir(self)
756
757_LEGEND_EMPTY_WARNING = """
758You are attempting to set `plot.legend.%s` on a plot that has zero legends added, this will have no effect.
759
760Before legend properties can be set, you must add a Legend explicitly, or call a glyph method with a legend parameter set.
761"""
762
763class _legend_attr_splat(_list_attr_splat):
764    def __setattr__(self, attr, value):
765        if not len(self):
766            warnings.warn(_LEGEND_EMPTY_WARNING % attr)
767        return super().__setattr__(attr, value)
768
769def _select_helper(args, kwargs):
770    """ Allow flexible selector syntax.
771
772    Returns:
773        dict
774
775    """
776    if len(args) > 1:
777        raise TypeError("select accepts at most ONE positional argument.")
778
779    if len(args) > 0 and len(kwargs) > 0:
780        raise TypeError("select accepts EITHER a positional argument, OR keyword arguments (not both).")
781
782    if len(args) == 0 and len(kwargs) == 0:
783        raise TypeError("select requires EITHER a positional argument, OR keyword arguments.")
784
785    if args:
786        arg = args[0]
787        if isinstance(arg, dict):
788            selector = arg
789        elif isinstance(arg, str):
790            selector = dict(name=arg)
791        elif isinstance(arg, type) and issubclass(arg, Model):
792            selector = {"type": arg}
793        else:
794            raise TypeError("selector must be a dictionary, string or plot object.")
795
796    elif 'selector' in kwargs:
797        if len(kwargs) == 1:
798            selector = kwargs['selector']
799        else:
800            raise TypeError("when passing 'selector' keyword arg, not other keyword args may be present")
801
802    else:
803        selector = kwargs
804
805    return selector
806
807#-----------------------------------------------------------------------------
808# Code
809#-----------------------------------------------------------------------------
810