From 4f594f4d735ac1540d043b4843f9247111c5a6b6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 31 Oct 2017 10:46:23 -0700 Subject: [PATCH 1/2] Backport PR #9324: [MRG] Allow kwarg handles and labels figure.legend and make doc for kwargs the same --- lib/matplotlib/axes/_axes.py | 95 +--- lib/matplotlib/figure.py | 231 ++++---- lib/matplotlib/legend.py | 552 +++++++++++++++++-- lib/matplotlib/tests/test_legend.py | 121 +++- lib/mpl_toolkits/axes_grid1/parasite_axes.py | 2 +- 5 files changed, 753 insertions(+), 248 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 6494c15995aa..4733736d5889 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -252,27 +252,6 @@ def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): self.yaxis.labelpad = labelpad return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) - def _get_legend_handles(self, legend_handler_map=None): - """ - Return a generator of artists that can be used as handles in - a legend. - - """ - handles_original = (self.lines + self.patches + - self.collections + self.containers) - handler_map = mlegend.Legend.get_default_handler_map() - - if legend_handler_map is not None: - handler_map = handler_map.copy() - handler_map.update(legend_handler_map) - - has_handler = mlegend.Legend.get_legend_handler - - for handle in handles_original: - label = handle.get_label() - if label != '_nolegend_' and has_handler(handler_map, handle): - yield handle - def get_legend_handles_labels(self, legend_handler_map=None): """ Return handles and labels for legend @@ -283,16 +262,13 @@ def get_legend_handles_labels(self, legend_handler_map=None): ax.legend(h, l) """ - handles = [] - labels = [] - for handle in self._get_legend_handles(legend_handler_map): - label = handle.get_label() - if label and not label.startswith('_'): - handles.append(handle) - labels.append(label) + # pass through to legend. + handles, labels = mlegend._get_legend_handles_labels([self], + legend_handler_map) return handles, labels + @docstring.dedent_interpd def legend(self, *args, **kwargs): """ Places a legend on the axes. @@ -328,6 +304,7 @@ def legend(self, *args, **kwargs): Parameters ---------- + loc : int or string or pair of floats, default: 'upper right' The location of the legend. Possible codes are: @@ -498,6 +475,11 @@ def legend(self, *args, **kwargs): handler. This `handler_map` updates the default handler map found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + Returns + ------- + + :class:`matplotlib.legend.Legend` instance + Notes ----- @@ -510,57 +492,12 @@ def legend(self, *args, **kwargs): .. plot:: gallery/api/legend.py """ - handlers = kwargs.get('handler_map', {}) or {} - - # Support handles and labels being passed as keywords. - handles = kwargs.pop('handles', None) - labels = kwargs.pop('labels', None) - - if (handles is not None or labels is not None) and len(args): - warnings.warn("You have mixed positional and keyword " - "arguments, some input will be " - "discarded.") - - # if got both handles and labels as kwargs, make same length - if handles and labels: - handles, labels = zip(*zip(handles, labels)) - - elif handles is not None and labels is None: - labels = [handle.get_label() for handle in handles] - for label, handle in zip(labels[:], handles[:]): - if label.startswith('_'): - warnings.warn('The handle {!r} has a label of {!r} which ' - 'cannot be automatically added to the ' - 'legend.'.format(handle, label)) - labels.remove(label) - handles.remove(handle) - - elif labels is not None and handles is None: - # Get as many handles as there are labels. - handles = [handle for handle, label - in zip(self._get_legend_handles(handlers), labels)] - - # No arguments - automatically detect labels and handles. - elif len(args) == 0: - handles, labels = self.get_legend_handles_labels(handlers) - if not handles: - return None - - # One argument. User defined labels - automatic handle detection. - elif len(args) == 1: - labels, = args - # Get as many handles as there are labels. - handles = [handle for handle, label - in zip(self._get_legend_handles(handlers), labels)] - - # Two arguments: - # * user defined handles and labels - elif len(args) == 2: - handles, labels = args - - else: - raise TypeError('Invalid arguments to legend.') - + handles, labels, extra_args, kwargs = mlegend._parse_legend_args( + [self], + *args, + **kwargs) + if len(extra_args): + raise TypeError('legend only accepts two non-keyword arguments') self.legend_ = mlegend.Legend(self, handles, labels, **kwargs) self.legend_._remove_method = lambda h: setattr(self, 'legend_', None) return self.legend_ diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index bc763834bc8c..4ce27daa86dd 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -39,7 +39,7 @@ from matplotlib.axes import Axes, SubplotBase, subplot_class_factory from matplotlib.blocking_input import BlockingMouseInput, BlockingKeyMouseInput from matplotlib.gridspec import GridSpec -from matplotlib.legend import Legend +import matplotlib.legend as mlegend from matplotlib.patches import Rectangle from matplotlib.projections import (get_projection_names, process_projection_requirements) @@ -1315,6 +1315,7 @@ def draw_artist(self, a): def get_axes(self): return self.axes + @docstring.dedent_interpd def legend(self, *args, **kwargs): """ Place a legend on the figure. @@ -1327,16 +1328,24 @@ def legend(self, *args, **kwargs): legend( (line1, line2, line3), ('label1', 'label2', 'label3'), - 'upper right') + loc='upper right') + + These can also be specified by keyword:: + + legend(handles=(line1, line2, line3), + labels=('label1', 'label2', 'label3'), + loc='upper right') Parameters ---------- - loc : string or integer + + loc : int or string or pair of floats, default: 'upper right' The location of the legend. Possible codes are: =============== ============= Location String Location Code =============== ============= + 'best' 0 'upper right' 1 'upper left' 2 'lower left' 3 @@ -1349,27 +1358,57 @@ def legend(self, *args, **kwargs): 'center' 10 =============== ============= - *loc* can also be an (x,y) tuple in figure coords, which specifies - the lower left of the legend box. In figure coords (0,0) is the - bottom left of the figure, and (1,1) is the top right. - prop : None or FontProperties or dict - A :class:`matplotlib.font_manager.FontProperties` instance. If - *prop* is a dictionary, a new instance will be created with *prop*. - If *None*, use rc settings. + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). - numpoints : integer - The number of points in the legend line, default is 4 + bbox_to_anchor : `~.BboxBase` or pair of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). - scatterpoints : integer - The number of points in the legend line, default is 4 + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: - scatteryoffsets : list of floats - A list of yoffsets for scatter symbols in legend + loc='upper right', bbox_to_anchor=(0.5, 0.5) - markerscale : None or scalar - The relative size of legend markers vs. original. If *None*, use rc - settings. + ncol : integer + The number of columns that the legend has. Default is 1. + + prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + + fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + + numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + + scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + + scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + + markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. markerfirst : bool If *True*, legend marker is placed to the left of the legend label. @@ -1379,79 +1418,96 @@ def legend(self, *args, **kwargs): frameon : None or bool Control whether the legend should be drawn on a patch (frame). - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.frameon`` :data:`rcParam`. fancybox : None or bool - If *True*, draw a frame with a round fancybox. If *None*, use rc - settings. + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. shadow : None or bool - If *True*, draw a shadow behind legend. If *None*, use rc settings. + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. framealpha : None or float Control the alpha transparency of the legend's background. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. facecolor : None or "inherit" or a color spec Control the legend's background color. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.facecolor`` :data:`rcParam`. If ``"inherit"``, it will take the ``axes.facecolor`` :data:`rcParam`. edgecolor : None or "inherit" or a color spec Control the legend's background patch edge color. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.edgecolor`` :data:`rcParam`. If ``"inherit"``, it will take the ``axes.edgecolor`` :data:`rcParam`. - ncol : integer - Number of columns. Default is 1. + mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). - mode : "expand" or None - If mode is "expand", the legend will be horizontally expanded - to fill the axes area (or *bbox_to_anchor*) + bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. - title : string - The legend title + title : str or None + The legend's title. Default is no title (``None``). borderpad : float or None - The fractional whitespace inside the legend border, measured in - font-size units. - Default is *None* which will take the value from the + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.borderpad`` :data:`rcParam`. labelspacing : float or None - The vertical space between the legend entries, measured in - font-size units. - Default is *None* which will take the value from the + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.labelspacing`` :data:`rcParam`. handlelength : float or None - The length of the legend handles, measured in font-size units. - Default is *None* which will take the value from the + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.handlelength`` :data:`rcParam`. handletextpad : float or None - The padding between the legend handle and text, measured in - font-size units. - Default is *None* which will take the value from the + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.handletextpad`` :data:`rcParam`. borderaxespad : float or None - The padding between the axes and legend border, measured in - font-size units. - Default is *None* which will take the value from the + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.borderaxespad`` :data:`rcParam`. columnspacing : float or None - The spacing between columns, measured in font-size units. - Default is *None* which will take the value from the + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.columnspacing`` :data:`rcParam`. + handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + Returns ------- :class:`matplotlib.legend.Legend` instance @@ -1462,67 +1518,22 @@ def legend(self, *args, **kwargs): :ref:`sphx_glr_tutorials_intermediate_legend_guide.py` for details. """ - # If no arguments given, collect up all the artists on the figure - if len(args) == 0: - handles = [] - labels = [] - - def in_handles(h, l): - # Method to check if we already have a given handle and label. - # Consider two handles to be the same if they share a label, - # color, facecolor, and edgecolor. - - # Loop through each handle and label already collected - for f_h, f_l in zip(handles, labels): - if f_l != l: - continue - if type(f_h) != type(h): - continue - try: - if f_h.get_color() != h.get_color(): - continue - except AttributeError: - pass - try: - if f_h.get_facecolor() != h.get_facecolor(): - continue - except AttributeError: - pass - try: - if f_h.get_edgecolor() != h.get_edgecolor(): - continue - except AttributeError: - pass - return True - return False - - for ax in self.axes: - ax_handles, ax_labels = ax.get_legend_handles_labels() - for h, l in zip(ax_handles, ax_labels): - if not in_handles(h, l): - handles.append(h) - labels.append(l) - if len(handles) == 0: - warnings.warn("No labeled objects found. " - "Use label='...' kwarg on individual plots.") - return None - - elif len(args) == 2: - # LINES, LABELS - handles, labels = args - - elif len(args) == 3: - # LINES, LABELS, LOC - handles, labels, loc = args - kwargs['loc'] = loc - - else: - raise TypeError('Invalid number of arguments passed to legend. ' - 'Please specify either 0 args, 2 args ' - '(artist handles, figure labels) or 3 args ' - '(artist handles, figure labels, legend location)') - - l = Legend(self, handles, labels, **kwargs) + handles, labels, extra_args, kwargs = mlegend._parse_legend_args( + self.axes, + *args, + **kwargs) + # check for third arg + if len(extra_args): + # cbook.warn_deprecated( + # "2.1", + # "Figure.legend will accept no more than two " + # "positional arguments in the future. Use " + # "'fig.legend(handles, labels, loc=location)' " + # "instead.") + # kwargs['loc'] = extra_args[0] + # extra_args = extra_args[1:] + pass + l = mlegend.Legend(self, handles, labels, *extra_args, **kwargs) self.legends.append(l) l._remove_method = lambda h: self.legends.remove(h) self.stale = True diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 214d110fd8a1..269f93f345dd 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -28,11 +28,13 @@ import six from six.moves import xrange +import logging import warnings import numpy as np from matplotlib import rcParams +from matplotlib import docstring from matplotlib.artist import Artist, allow_rasterization from matplotlib.cbook import silent_list, is_hashable from matplotlib.font_manager import FontProperties @@ -105,29 +107,187 @@ def _update_bbox_to_anchor(self, loc_in_canvas): self.legend.set_bbox_to_anchor(loc_in_bbox) +_legend_kw_doc = ''' +loc : int or string or pair of floats, default: 'upper right' + The location of the legend. Possible codes are: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + + + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). + +bbox_to_anchor : :class:`matplotlib.transforms.BboxBase` instance \ +or tuple of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). + + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: + + loc='upper right', bbox_to_anchor=(0.5, 0.5) + +ncol : integer + The number of columns that the legend has. Default is 1. + +prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + +fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + +numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + +scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + +scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + +markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. + +markerfirst : bool + If *True*, legend marker is placed to the left of the legend label. + If *False*, legend marker is placed to the right of the legend + label. + Default is *True*. + +frameon : None or bool + Control whether the legend should be drawn on a patch (frame). + Default is ``None`` which will take the value from the + ``legend.frameon`` :data:`rcParam`. + +fancybox : None or bool + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. + +shadow : None or bool + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. + +framealpha : None or float + Control the alpha transparency of the legend's background. + Default is ``None`` which will take the value from the + ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. + +facecolor : None or "inherit" or a color spec + Control the legend's background color. + Default is ``None`` which will take the value from the + ``legend.facecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.facecolor`` + :data:`rcParam`. + +edgecolor : None or "inherit" or a color spec + Control the legend's background patch edge color. + Default is ``None`` which will take the value from the + ``legend.edgecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.edgecolor`` + :data:`rcParam`. + +mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). + +bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. + +title : str or None + The legend's title. Default is no title (``None``). + +borderpad : float or None + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderpad`` :data:`rcParam`. + +labelspacing : float or None + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.labelspacing`` :data:`rcParam`. + +handlelength : float or None + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handlelength`` :data:`rcParam`. + +handletextpad : float or None + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handletextpad`` :data:`rcParam`. + +borderaxespad : float or None + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderaxespad`` :data:`rcParam`. + +columnspacing : float or None + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.columnspacing`` :data:`rcParam`. + +handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + +''' +docstring.interpd.update(_legend_kw_doc=_legend_kw_doc) + + class Legend(Artist): """ Place a legend on the axes at location loc. Labels are a sequence of strings and loc can be a string or an integer specifying the legend location - The location codes are:: - - 'best' : 0, (only implemented for axes legends) - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4, - 'right' : 5, (same as 'center right', for back-compatibility) - 'center left' : 6, - 'center right' : 7, - 'lower center' : 8, - 'upper center' : 9, - 'center' : 10, - - loc can be a tuple of the normalized coordinate values with - respect its parent. - """ codes = {'best': 0, # only implemented for axes legends 'upper right': 1, @@ -147,6 +307,7 @@ class Legend(Artist): def __str__(self): return "Legend" + @docstring.dedent_interpd def __init__(self, parent, handles, labels, loc=None, numpoints=None, # the number of points in the legend line @@ -195,42 +356,180 @@ def __init__(self, parent, handles, labels, legend - *labels*: a list of strings to label the legend - Optional keyword arguments: - - ================ ==================================================== - Keyword Description - ================ ==================================================== - loc Location code string, or tuple (see below). - prop the font property - fontsize the font size (used only if prop is not specified) - markerscale the relative size of legend markers vs. original - markerfirst If True (default), marker is to left of the label. - numpoints the number of points in the legend for line - scatterpoints the number of points in the legend for scatter plot - scatteryoffsets a list of yoffsets for scatter symbols in legend - frameon If True, draw the legend on a patch (frame). - fancybox If True, draw the frame with a round fancybox. - shadow If True, draw a shadow behind legend. - framealpha Transparency of the frame. - edgecolor Frame edgecolor. - facecolor Frame facecolor. - ncol number of columns - borderpad the fractional whitespace inside the legend border - labelspacing the vertical space between the legend entries - handlelength the length of the legend handles - handleheight the height of the legend handles - handletextpad the pad between the legend handle and text - borderaxespad the pad between the axes and legend border - columnspacing the spacing between columns - title the legend title - bbox_to_anchor the bbox that the legend will be anchored. - bbox_transform the transform for the bbox. transAxes if None. - ================ ==================================================== - - - The pad and spacing parameters are measured in font-size units. e.g., - a fontsize of 10 points and a handlelength=5 implies a handlelength of - 50 points. Values from rcParams will be used if None. + Parameters + ---------- + + loc : int or string or pair of floats, default: 'upper right' + The location of the legend. Possible codes are: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + + + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). + + bbox_to_anchor : `~.BboxBase` or pair of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). + + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: + + loc='upper right', bbox_to_anchor=(0.5, 0.5) + + ncol : integer + The number of columns that the legend has. Default is 1. + + prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + + fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + + numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + + scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + + scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + + markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. + + markerfirst : bool + If *True*, legend marker is placed to the left of the legend label. + If *False*, legend marker is placed to the right of the legend + label. + Default is *True*. + + frameon : None or bool + Control whether the legend should be drawn on a patch (frame). + Default is ``None`` which will take the value from the + ``legend.frameon`` :data:`rcParam`. + + fancybox : None or bool + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. + + shadow : None or bool + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. + + framealpha : None or float + Control the alpha transparency of the legend's background. + Default is ``None`` which will take the value from the + ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. + + facecolor : None or "inherit" or a color spec + Control the legend's background color. + Default is ``None`` which will take the value from the + ``legend.facecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.facecolor`` + :data:`rcParam`. + + edgecolor : None or "inherit" or a color spec + Control the legend's background patch edge color. + Default is ``None`` which will take the value from the + ``legend.edgecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.edgecolor`` + :data:`rcParam`. + + mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). + + bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. + + title : str or None + The legend's title. Default is no title (``None``). + + borderpad : float or None + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderpad`` :data:`rcParam`. + + labelspacing : float or None + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.labelspacing`` :data:`rcParam`. + + handlelength : float or None + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handlelength`` :data:`rcParam`. + + handletextpad : float or None + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handletextpad`` :data:`rcParam`. + + borderaxespad : float or None + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderaxespad`` :data:`rcParam`. + + columnspacing : float or None + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.columnspacing`` :data:`rcParam`. + + handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + + Notes + ----- Users can specify any arbitrary location for the legend using the *bbox_to_anchor* keyword argument. bbox_to_anchor can be an instance @@ -280,6 +579,15 @@ def __init__(self, parent, handles, labels, value = locals_view[name] setattr(self, name, value) del locals_view + # trim handles and labels if illegal label... + for label, handle in zip(labels[:], handles[:]): + if (isinstance(label, six.string_types) + and label.startswith('_')): + warnings.warn('The handle {!r} has a label of {!r} which ' + 'cannot be automatically added to the ' + 'legend.'.format(handle, label)) + labels.remove(label) + handles.remove(handle) handles = list(handles) if len(handles) < 2: @@ -974,3 +1282,141 @@ def draggable(self, state=None, use_blit=False, update="loc"): self._draggable = None return self._draggable + + +# Helper functions to parse legend arguments for both `figure.legend` and +# `axes.legend`: +def _get_legend_handles(axs, legend_handler_map=None): + """ + Return a generator of artists that can be used as handles in + a legend. + + """ + handles_original = [] + for ax in axs: + handles_original += (ax.lines + ax.patches + + ax.collections + ax.containers) + # support parasite axes: + if hasattr(ax, 'parasites'): + for axx in ax.parasites: + handles_original += (axx.lines + axx.patches + + axx.collections + axx.containers) + + handler_map = Legend.get_default_handler_map() + + if legend_handler_map is not None: + handler_map = handler_map.copy() + handler_map.update(legend_handler_map) + + has_handler = Legend.get_legend_handler + + for handle in handles_original: + label = handle.get_label() + if label != '_nolegend_' and has_handler(handler_map, handle): + yield handle + + +def _get_legend_handles_labels(axs, legend_handler_map=None): + """ + Return handles and labels for legend, internal method. + + """ + handles = [] + labels = [] + + def _in_handles(h, l): + # Method to check if we already have a given handle and label. + # Consider two handles to be the same if they share a label, + # color, facecolor, and edgecolor. + + # Loop through each handle and label already collected + for f_h, f_l in zip(handles, labels): + if f_l != l: + continue + if type(f_h) != type(h): + continue + try: + if f_h.get_color() != h.get_color(): + continue + except AttributeError: + pass + try: + if f_h.get_facecolor() != h.get_facecolor(): + continue + except AttributeError: + pass + try: + if f_h.get_edgecolor() != h.get_edgecolor(): + continue + except AttributeError: + pass + return True + return False + + for handle in _get_legend_handles(axs, legend_handler_map): + label = handle.get_label() + if (label + and not label.startswith('_') + and not _in_handles(handle, label)): + handles.append(handle) + labels.append(label) + return handles, labels + + +def _parse_legend_args(axs, *args, **kwargs): + """ + Get the handles and labels from the calls to either ``figure.legend`` + or ``axes.legend``. + + ``axs`` is a list of axes (to get legend artists from) + """ + log = logging.getLogger(__name__) + + handlers = kwargs.get('handler_map', {}) or {} + + # Support handles and labels being passed as keywords. + handles = kwargs.pop('handles', None) + labels = kwargs.pop('labels', None) + + extra_args = () + + if (handles is not None or labels is not None) and len(args): + warnings.warn("You have mixed positional and keyword " + "arguments, some input may be " + "discarded.") + + # if got both handles and labels as kwargs, make same length + if handles and labels: + handles, labels = zip(*zip(handles, labels)) + + elif handles is not None and labels is None: + labels = [handle.get_label() for handle in handles] + + elif labels is not None and handles is None: + # Get as many handles as there are labels. + handles = [handle for handle, label + in zip(_get_legend_handles(axs, handlers), labels)] + + # No arguments - automatically detect labels and handles. + elif len(args) == 0: + handles, labels = _get_legend_handles_labels(axs, handlers) + if not handles: + log.warning('No handles with labels found to put in legend.') + + # One argument. User defined labels - automatic handle detection. + elif len(args) == 1: + labels, = args + # Get as many handles as there are labels. + handles = [handle for handle, label + in zip(_get_legend_handles(axs, handlers), labels)] + + # Two arguments: + # * user defined handles and labels + elif len(args) >= 2: + handles, labels = args[:2] + extra_args = args[2:] + + else: + raise TypeError('Invalid arguments to legend.') + + return handles, labels, extra_args, kwargs diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 221acdaa9306..da9b072aeef0 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -7,6 +7,8 @@ except ImportError: import mock import numpy as np +import pytest + from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt @@ -14,6 +16,36 @@ import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections from matplotlib.legend_handler import HandlerTuple +import inspect + + +# test that docstrigs are the same +def get_docstring_section(func, section): + """ extract a section from the docstring of a function """ + ll = inspect.getdoc(func) + lines = ll.splitlines() + insec = False + st = '' + for ind in range(len(lines)): + if lines[ind][:len(section)] == section and lines[ind+1][:3] == '---': + insec = True + ind = ind+1 + if insec: + if len(lines[ind + 1]) > 3 and lines[ind + 1][0:3] == '---': + insec = False + break + else: + st += lines[ind] + '\n' + return st + + +def test_legend_kwdocstrings(): + stleg = get_docstring_section(mpl.legend.Legend.__init__, 'Parameters') + stax = get_docstring_section(mpl.axes.Axes.legend, 'Parameters') + stfig = get_docstring_section(mpl.figure.Figure.legend, 'Parameters') + assert stleg == stax + assert stfig == stax + assert stleg == stfig @image_comparison(baseline_images=['legend_auto1'], remove_text=True) @@ -233,13 +265,19 @@ def test_legend_label_args(self): plt.legend(['foobar']) Legend.assert_called_with(plt.gca(), lines, ['foobar']) + def test_legend_three_args(self): + lines = plt.plot(range(10), label='hello world') + with mock.patch('matplotlib.legend.Legend') as Legend: + plt.legend(lines, ['foobar'], loc='right') + Legend.assert_called_with(plt.gca(), lines, ['foobar'], loc='right') + def test_legend_handler_map(self): lines = plt.plot(range(10), label='hello world') - with mock.patch('matplotlib.axes.Axes.' - 'get_legend_handles_labels') as handles_labels: + with mock.patch('matplotlib.legend.' + '_get_legend_handles_labels') as handles_labels: handles_labels.return_value = lines, ['hello world'] plt.legend(handler_map={'1': 2}) - handles_labels.assert_called_with({'1': 2}) + handles_labels.assert_called_with([plt.gca()], {'1': 2}) def test_kwargs(self): fig, ax = plt.subplots(1, 1) @@ -247,7 +285,7 @@ def test_kwargs(self): lns, = ax.plot(th, np.sin(th), label='sin', lw=5) lnc, = ax.plot(th, np.cos(th), label='cos', lw=5) with mock.patch('matplotlib.legend.Legend') as Legend: - ax.legend(handles=(lnc, lns), labels=('a', 'b')) + ax.legend(labels=('a', 'b'), handles=(lnc, lns)) Legend.assert_called_with(ax, (lnc, lns), ('a', 'b')) def test_warn_args_kwargs(self): @@ -259,7 +297,80 @@ def test_warn_args_kwargs(self): ax.legend((lnc, lns), labels=('a', 'b')) warn.assert_called_with("You have mixed positional and keyword " - "arguments, some input will be " + "arguments, some input may be " + "discarded.") + + def test_parasite(self): + from mpl_toolkits.axes_grid1 import host_subplot + + host = host_subplot(111) + par = host.twinx() + + p1, = host.plot([0, 1, 2], [0, 1, 2], label="Density") + p2, = par.plot([0, 1, 2], [0, 3, 2], label="Temperature") + + with mock.patch('matplotlib.legend.Legend') as Legend: + leg = plt.legend() + Legend.assert_called_with(host, [p1, p2], + ['Density', 'Temperature']) + + +class TestLegendFigureFunction(object): + # Tests the legend function for figure + def test_legend_handle_label(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(lines, ['hello world']) + Legend.assert_called_with(fig, lines, ['hello world']) + + def test_legend_no_args(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10), label='hello world') + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend() + Legend.assert_called_with(fig, lines, ['hello world']) + + def test_legend_label_arg(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(['foobar']) + Legend.assert_called_with(fig, lines, ['foobar']) + + def test_legend_label_three_args(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(lines, ['foobar'], 'right') + Legend.assert_called_with(fig, lines, ['foobar'], 'right') + + def test_legend_label_three_args_pluskw(self): + # test that third argument and loc= called together give + # Exception + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with pytest.raises(Exception): + fig.legend(lines, ['foobar'], 'right', loc='left') + + def test_legend_kw_args(self): + fig, axs = plt.subplots(1, 2) + lines = axs[0].plot(range(10)) + lines2 = axs[1].plot(np.arange(10) * 2.) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(loc='right', labels=('a', 'b'), + handles=(lines, lines2)) + Legend.assert_called_with(fig, (lines, lines2), ('a', 'b'), + loc='right') + + def test_warn_args_kwargs(self): + fig, axs = plt.subplots(1, 2) + lines = axs[0].plot(range(10)) + lines2 = axs[1].plot(np.arange(10) * 2.) + with mock.patch('warnings.warn') as warn: + fig.legend((lines, lines2), labels=('a', 'b')) + warn.assert_called_with("You have mixed positional and keyword " + "arguments, some input may be " "discarded.") diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index 086806b182e3..5d347af1da4c 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -258,8 +258,8 @@ def get_aux_axes(self, tr, viewlim_mode="equal", axes_class=None): ax2._remove_method = lambda h: self.parasites.remove(h) return ax2 - def _get_legend_handles(self, legend_handler_map=None): + # don't use this! Axes_get_legend_handles = self._get_base_axes_attr("_get_legend_handles") all_handles = list(Axes_get_legend_handles(self, legend_handler_map)) From 6d97d0699933eeabcdb13573d1e8159a625abebf Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Thu, 2 Nov 2017 21:15:56 -0700 Subject: [PATCH 2/2] Fix _axes docstring --- lib/matplotlib/axes/_axes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 4733736d5889..8bec967365f9 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -329,8 +329,7 @@ def legend(self, *args, **kwargs): corner of the legend in axes coordinates (in which case ``bbox_to_anchor`` will be ignored). - bbox_to_anchor : :class:`matplotlib.transforms.BboxBase` instance \ -or tuple of floats + bbox_to_anchor : `~.BboxBase` or pair of floats Specify any arbitrary location for the legend in `bbox_transform` coordinates (default Axes coordinates).