Skip to content

[MRG] Allow kwarg handles and labels figure.legend and make doc for kwargs the same #9324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 31, 2017

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Oct 8, 2017

PR Summary

Allows fig.legend(handles=hs, labels=labs) keyword calls.

Fixes #9320

PR Checklist

  • Has Pytest style unit tests
  • Code is PEP 8 compliant
  • Check reworked docs are correct...

@dstansby
Copy link
Member

dstansby commented Oct 8, 2017

Thanks for fixing this. I can't think of any reason not to just have them as kwargs with defaults of None as you said in the bug report, but maybe I'm missing something.

warnings.warn('The handle {!r} has a label of {!r} which '
'cannot be automatically added to the '
'legend.'.format(handle, label))
labels.remove(label)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we let this be handled down stream?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The underscore check? Maybe. I just shamelessly copied from axes.legend.

Yes yes I know I should have refactored it... otoh I don’t like that axes.legend if there is only one argument assumes that it is a list of labels. I think a list of handles makes much more sense.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this check into legend.Legend.__init__, and removed from axes.legend and figure.legend

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking labels (not handles) is a bit problematic and leads to issues like benlaken/European_wind#1

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tacaswell. Um yeah that does look to be a bad decision.

I’d suggest we just strip the illegal underscore and warn (or logging.warning 😉) or set it to an empty string.

But I didn’t originate this code. I’m not actually sure why an underscore is a problem in the first place.

@dstansby dstansby added the Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions. label Oct 8, 2017
@dstansby dstansby added this to the 2.1.1 (next bug fix release) milestone Oct 8, 2017
@@ -74,6 +74,56 @@ def test_labels_first():
ax.legend(loc=0, markerfirst=False)


@image_comparison(baseline_images=['legend_fig_noargs'], extensions=['png'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reasonable way to check that this works without an image test?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to try that. Or reduce the tests. I don’t know how you would test it without a plot but that’s just my ignorance. Happy to implement something

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only need to test equivalence between the results of different invocations, not equivalence to some human-verified gold standard output.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK< I think I understand. Use the trick linked above and then assert that the pngs are the same w/o actually having to save them or do the image comparison? That sounds great.

Sorry for being slow, but I don't know anything about writing tests for python...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I'm not an expert at testing with matplotlib, but I would've thought there might be a way to check the calculated legend artists are equivalent. But perhaps not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ax.legend_.get_texts() and ax.legend_.legendHandles seems to get you back the labels and handles used, but that is just from poking at a legend object interactively....

@jklymak
Copy link
Member Author

jklymak commented Oct 8, 2017

@dstansby Yes, I'm still not sure. But I largely copied from axes.legend and its done with *args, *kwargs as well, so I did that here.

@jklymak
Copy link
Member Author

jklymak commented Oct 9, 2017

OK, I refactored. Now figure.legend and axes.legend have exactly the same argument parsing (now in legend._parse_legend_args).

Probably need the docs for figure.legend changed. Its possible this is non-backwards compatible. figure.legend used to allow three positional arguments, with the third being loc, but I don't think axes.legend ever did.

Question: Is there a way for figure.legend and axes.legend to share part of their docs since the calls are the same now?

This is not passing a legend test that I don't understand so I'm having difficulty debugging...

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:
            handles_labels.return_value = lines, ['hello world']
            plt.legend(handler_map={'1': 2})
        handles_labels.assert_called_with({'1': 2})

@jklymak
Copy link
Member Author

jklymak commented Oct 9, 2017

It seems that both instances of legend should have pass-through kwarg documentation.

It seems the right way to do this is for each keyword argument to have a set_arg that has an ACCEPTS: line in the doc. But I'm not doing that. So I assume I can define a kw doc string and then reference it somehow.

Ah, I knew I'd seen it somwhere. colorbar_doc is a great example.

@jklymak jklymak changed the title Allow kwarg handles and labels figure.legend WIP: Allow kwarg handles and labels figure.legend and make doc for kwargs the same Oct 9, 2017
@tacaswell
Copy link
Member

Well, that escalated quickly. I thought this was going to be a ~5 line fix....

@@ -105,6 +106,177 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
self.legend.set_bbox_to_anchor(loc_in_bbox)


legend_kw_doc = '''
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this _private?

@jklymak
Copy link
Member Author

jklymak commented Oct 10, 2017

@tacaswell We could easily go back to the 5-line fix...

But the fundamental problem was that *args, **kwargs were being handled differently between figure.legend and axes.legend for no good reason, and it will just be a maintenance headache in the future. Worse the legend.Legend keywords were described three different places in three different formats, inconsistently in each place.

The changes are actually pretty small.

  1. moved the argument-parsing logic out of axes.legend into legend._parse_legend_args
  2. used the same parsing in figure.legend
  3. changed the three docstrings to refer to legend_kw_doc.

I need to do some cleanup, and I still have no idea how to address the failing mock test (see comment above), but otherwise I hope this is an improvement.

@jklymak
Copy link
Member Author

jklymak commented Oct 11, 2017

Oh, hmmmm. fig.legend() did this funky thing where if you called

fig, axs = plt.subplots(2,1)
l1 = axs[0].plot(x, 'r', label='boo')
l2 = axs[1].plot(x, 'r', label='boo')
fig.legend()

It would only put 'boo' in the legend once. This PR doesn't respect this. Easy to fix, but do we really want to?

@anntzer
Copy link
Contributor

anntzer commented Oct 11, 2017

I guess we "have to" for backcompatibility but that sounds like yet another thing I'd like to get rid of... (for example Axes.legend does not provide the same functionality).

@jklymak
Copy link
Member Author

jklymak commented Oct 11, 2017

@anntzer Ahem, now it does ;-) Well, if this PR gets accepted. I actually think there is some sense to it, but I wouldn't have offered the functionality if it'd just have been up to me...

@anntzer
Copy link
Contributor

anntzer commented Oct 11, 2017

I can't say I really hate this feature (so if others like it I'm fine with it), but in my view it's a bit similar to auto reuse of axes (#9037 and linked isses) and would prefer not having it for the same reason.

handles = []
labels = []

def _in_handles(h, l):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anntzer This is where the logic to remove duplicate label/line pairs is. Easy to remove if there is a consensus.

'(artist handles, figure labels, legend location)')

l = Legend(self, handles, labels, **kwargs)
print("Axes:", self.axes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rouge print


# Two arguments:
# * user defined handles and labels
elif len(args) >= 2:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be ==, passing loc as a positional argument in deprecated for the Axes version but apparently we missed doing the same to the Figure version (as it did not explicitly take loc as a positional, but passed *args through to the Legend.__init__ so it worked.

This should probably return handles, labels, extra_args and in Axes.legend raise if len(extra_args) > 0 and pass them through in Figure.legend

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, this is caught in lib/matplotlib/axes/_axes.py. But it still needs to be in here.

kwargs.pop('handles', None)
kwargs.pop('labels', None)
# check for third arg
if len(args) == 3:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was intentionally deprecated in 1.5 http://matplotlib.org/api/api_changes.html#legend it should not come back!

@tacaswell
Copy link
Member

Modulo the handling of a positional loc being inconsistent between axes and figure 👍

Copy link
Member

@tacaswell tacaswell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raise on positional loc in Axes.legend, still pass it through on Figure.legend (but we should deprecate that on master once this lands).

@jklymak
Copy link
Member Author

jklymak commented Oct 11, 2017

@tacaswell Can we just deprecate the third positional argument in both? Right now the code doesn't care if its called from figure or axes.

That said, I'm not at all arguing for this third positional argument.

@tacaswell
Copy link
Member

In the spirit of minimal change / semver I would rather deprecate loc on Figure.legend in 2.2 rather than 2.1.1. It is already removed from Axes.legend from 1.5 so we don't need to deprecate it there ;)

@jklymak
Copy link
Member Author

jklymak commented Oct 11, 2017

@tacaswell Allowing the third positional argument is back in Axes.legend with this PR, because all the code to handle the arguments is the now same between Axes.legend and figure.legend. Thats how this PR got rather large-looking. If we handle figure and Axes differently, their code bases will diverge again, and thats how we ended up in this mess in the first place!

I'm suggesting we silently go back to allowing the third positional argument in Axes.legend, but deprecate if anyone happens to use it, and deprecate it in figure.legend as well. Then we can take it out again for 2.2. Its not like it hurts anything to handle the third positional argument.

@tacaswell
Copy link
Member

A user will discover it and complain when it goes away. I the current behavior can be maintained on top of this refactor without too much extra complexity.

In Axes.legend()

def legend(self, *args, **kwargs):
    handles, labels, extra_args = _parse_legend_args([self], *args, **kwargs)
    if len(extra_args):
        raise TypeError(....)
    l = legend.Legend(handles, labels, **kwargs)
    ...

and in Figure.legend()

def legend(self, *args, **kwargs):
    handles, labels, extra_args = _parse_legend_args(...)
    l = legend.Legend(handles, labels, *extra_args, **kwargs)
    ...

and in _parse_legend_args

extra_args = ()
if ...:
    ...
elif len(args) >= 2:
    handles, labels = args[:2]
    extra_args = args[2:]

return handles, labels, extra_args

The current behavior of Figure.legend is that all inputs to legend.Legend can be passed as positional so this is a bit worse than I initially thought.

@jklymak
Copy link
Member Author

jklymak commented Oct 11, 2017

Ah OK, I understand. Thats easy enough, if a bit of a PITA.

Not sure I understand your last comment, though. Currently only handles, labels, and loc can be passed as positional, and loc actually gets converted to a kwarg.

@tacaswell
Copy link
Member

In 2.0 the truncated Figure.legend is

def legend(self, handles, labels, *args, **kwargs):
    l = Legend(self, handles, labels, *args, **kwargs)
    self.legends.append(l)
    l._remove_method = lambda h: self.legends.remove(h)
    self.stale = True
    return l

so the API change from 2.0 -> 2.1 is bigger than just breaking handlers and labels as kwargs. If we are going to go back to the < 2.1 behavior we should bring it all back, add deprecation warnings to positional args in 2.2 and then remove in the next release.

@tacaswell
Copy link
Member

Sorry, I was using confusing tense!

@jklymak
Copy link
Member Author

jklymak commented Oct 23, 2017

Believe it or not, ax.legend(arg1) can also accept just a list of handles, a fact I found out only because @choldgraf did it in http://matplotlib.org/devdocs/gallery/text_labels_and_annotations/custom_legends.html#sphx-glr-gallery-text-labels-and-annotations-custom-legends-py

But, there is no way to attach a proper label to the legend when you call the legend like this, even if the underlying handles have labels! In master:

N = 10
data = [np.logspace(0, 1, 100) + np.random.randn(100) + ii for ii in range(N)]
data = np.array(data).T
cmap = plt.cm.coolwarm
rcParams['axes.prop_cycle'] = cycler(color=cmap(np.linspace(0, 1, N)))

fig, ax = plt.subplots()
lines=[]
for i in range(N):
    lines += [ax.plot(data[:, i], label='%s'%i)]
    print(lines[-1])
ax.legend(lines)

figure_1

That is broken beyond belief in my opinion, because:

fig, ax = plt.subplots()
lines=[]
for i in range(N):
    lines += [ax.plot(data[:, i], label='%s'%i)]
    print(lines[-1])
ax.legend(lines[::3])

is a completely reasonable thing to do, but yields:

figure_1

I won't fix in this PR, but wanted to register my dismay.

I think ax.legend(arg) should accept either a list fo strings (old label format), or a list of handles. If the first, it does what it does now and just finds the first N handles to attach the strings to. If the latter, it provides a legend for that list.

The arg = list of handles is not documented, and I think @choldgraf example above should be modified because it is doing something legend isn't really meant to do (all tests and docs passed for my change before this example was put into master). I think we should add support for lists of handles, but only display them if the label is explicitly set, and document this as new behaviour. If you'd like that as a 2.2 separate PR, thats fine. I think the PR here, as it stands, gives us back what we had before 2.1. Pinging @tacaswell @anntzer @dstansby Thanks!

@anntzer
Copy link
Contributor

anntzer commented Oct 23, 2017

As your second example shows, ax.legend is not "accepting a list of handles" (you will note that the artists that went into the legend in the second example are not every third, but the first 4); instead, it is interpreting the (positional first) argument as a list of labels (after forcefully converting them using str) and assigning them as labels to the first n artists.

Yes this is nonsensical, but should really be the object of a separate PR.

@jklymak jklymak changed the title Allow kwarg handles and labels figure.legend and make doc for kwargs the same [MRG] Allow kwarg handles and labels figure.legend and make doc for kwargs the same Oct 24, 2017
@jklymak
Copy link
Member Author

jklymak commented Oct 24, 2017

This is ready to go, unless we find something else whacky that the various versions of legend do...

I have two approvals, but I assume its bad form to self-merge?

@jklymak jklymak mentioned this pull request Oct 24, 2017
@tacaswell
Copy link
Member

@jklymak I think we did something funny with git :(

@jklymak
Copy link
Member Author

jklymak commented Oct 30, 2017

Sorry! My fault. Let me re-push. I made a local change and was pushing onto your change. But I got your change. I won't touch it until tonight, honest!

Copy link
Contributor

@dopplershift dopplershift left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not wild about such a big PR for a patch release, but I get why.

I don't like the doc handling, but that's a bit of a mess in matplotlib throughout, so this is up to par. Let's save the big docstring handling throw down for #9414.

@jklymak
Copy link
Member Author

jklymak commented Oct 30, 2017

@dopplershift Yes fair enough - its easy to fix the docs later. I'll add a note that this is one place to fix in #9414 if its not there already....

# "instead.")
# kwargs['loc'] = extra_args[0]
# extra_args = extra_args[1:]
pass
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tacaswell This now fails Travis - what was the plan here? I thought we were keeping figure.legend w/ three possible args?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't have changed anything other than to prevent fig.legend(a, b, l, loc=c) case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see:

self = <matplotlib.tests.test_legend.TestLegendFigureFunction object at 0x7fffd6b1fc50>
    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'], loc='right')

The test was checking that loc got loaded w/ the third element. I'll fix later. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tacaswell Changed legend_test to check for an Exception should someone call: fig.legend(handles, labels, 'right', loc='left') (test_legend_label_three_args_pluskw) I think thats what you wanted.

Any reason to leave this check/pass in here? Just posterity sake?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid changing the API as much as possible. I am 👍 on deprecating the other positional arguments for 2.2, but not in 2.1.1 (so leave that code in place so we can just un-comment it in a follow on PR).

I don't think we needed a test for what is core-python behavior (you can not pass a value as both positional and keyword) nor should we break it!

Allow kwarg handles and labels figure.legend

Small refactor of check for labels so it is inside legend.Legend

attempt to refactor legend; not passing tests

small typo

Moved kwarg documentation out of child functions and into legend.

Fixed documentation passthroughs so they work properly

Fixed documentation passthroughs so they work properly

Remerge master, small changes

Fixed tests

Remove repeated labels if same linestyle

Remove repeated labels if same linestyle

Removed ability of third positional argument for axes.legend

Deprecate third psotionsal argument for fig.legend

Allow parasite axes to call legend(): parasite_simple.py

Fixed doc

Fixed doc

Added twinx test for legend

merge fix

merge fix

Fixed legend handling of handles-only input; added error message

Fixed legend handling of handles-only input for py27

Fixed small doc change

MNT: do not special-case loc as positional arg

Fixed small doc change

Fix PEP8 and test error

Fix PEP8 and test error
@tacaswell tacaswell merged commit d2a8e67 into matplotlib:master Oct 31, 2017
lumberbot-app bot pushed a commit that referenced this pull request Oct 31, 2017
@jklymak
Copy link
Member Author

jklymak commented Oct 31, 2017

Ummm, help! The original example no longer works!!

fig, ax = plt.subplots(1, 2)
hs = []
lab = ['Boo', 'Hoo']
for nn, axx in enumerate(ax):
    h, = axx.plot(np.arange(10))
    hs += [h]
fig.legend(hs, lab)
plt.show()

/Users/jklymak/matplotlib/lib/matplotlib/legend.py:932: UserWarning: Legend does not support [<matplotlib.lines.Line2D object at 0x107cfc3c8>] instances.
A proxy artist may be used instead.
See: http://matplotlib.org/users/legend_guide.html#creating-artists-specifically-for-adding-to-the-legend-aka-proxy-artists
  "aka-proxy-artists".format(orig_handle)
/Users/jklymak/matplotlib/lib/matplotlib/legend.py:932: UserWarning: Legend does not support [<matplotlib.lines.Line2D object at 0x107cfc908>] instances.
A proxy artist may be used instead.
See: http://matplotlib.org/users/legend_guide.html#creating-artists-specifically-for-adding-to-the-legend-aka-proxy-artists
  "aka-proxy-artists".format(orig_handle)

@jklymak
Copy link
Member Author

jklymak commented Oct 31, 2017

Ooops, false alarm. I had a typo in my test!

import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2)
hs = []
for axx in ax:
    h, = axx.plot(np.arange(10))
    hs += [h]
fig.legend(handles=hs, labels=['Boo', 'Hoo'])
plt.show()

works fine!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2.1 figure.legend broken
8 participants