-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
FIX: restore creating new axes via plt.subplot with different kwargs #19438
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
Conversation
aae74d7
to
907311c
Compare
If people are happy with this (which seems to be the case) I still need to chase through the docs to make sure they are still correct. |
lib/matplotlib/tests/test_figure.py
Outdated
fig = plt.figure() | ||
ax = plt.subplot(1, 2, 1) | ||
ax1 = plt.subplot(1, 2, 1) | ||
ax2 = plt.subplot(1, 2, 2) | ||
# This will delete ax / ax1 as they fully overlap |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't the reason for deletion actually that the projection keywords are not the same?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is what I thought too, but the logic is
matplotlib/lib/matplotlib/pyplot.py
Lines 1236 to 1244 in ea68032
bbox = ax.bbox | |
axes_to_delete = [] | |
for other_ax in fig.axes: | |
if other_ax == ax: | |
continue | |
if bbox.fully_overlaps(other_ax.bbox): | |
axes_to_delete.append(other_ax) | |
for ax_to_del in axes_to_delete: | |
delaxes(ax_to_del) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tacaswell is correct. I wasn't aware of this behavior, but it's well documented, and it's been around a long time, so this is 👍 for me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me 👍
I believe this suffers from the same underlying problem that caused the old implementation to be so roundabout: some keys are not hashable because they are mutable. To be concrete, if you write e.g.
what are the axes that you think should be the same? (A similar, and probably less artificial, argument applies e.g. for (Holding onto kwargs can be useful, but I think this more or less requires (in this specific case) enforcing that projections be immutable.) |
@anntzer, I'm not following - first this PR doesn't do anything for add_axes etc. it only affects
is that they will lose the original plot because the projection has changed, in which case they should just keep the reference:
That seems pretty esoteric to me. But it is certainly better than breaking the people who expect the axes to clear when doing
|
I wrote the example with add_axes for simplicity, but again something similar can happen with projections if they are not immutable. I don't actually know much about cartopy or WCSAxes, but I don't think it would be incredible to have some projections library that supports e.g. And if the project is actually immutable, then it should normally be hashable too, so that would be one thing to check. |
right, and with this change, the second subplot will overwrite the first. But a) thats hopefully rare, and b) when that happens, presumably its pretty obvious, and we can just tell people to save the reference to the subplot, rather than fragile grabbing from the |
I believe that what will happen is that the second call will return the preexisting axes as is?
Just to be clear, it is far from obvious to me what people using the implicit machinery may be expecting, because it's been too long since I've last used it for this kind of manipulations, but heh. |
907311c
to
2960490
Compare
The key behavior change in #19153 (and what caused the re-write from #18978 is that we moved the re-use or new (and remove) logic into pyplot from the The argument for keeping the "create or query" logic in pyplot is that if the users are going fully down the implicit route (a number of years ago I had an lengthy discussion with a user who was convinced beyond doubt that the implicit API was easier and clearer to use, 30-45 minutes of effort and several drinks had no effect) then this is the only way for them to come back to an Axes via the internal If we have a well behaved non-mutable input (or the users just never mutate it ;) ) we have:
However @anntzer is correct that this is opening is up to some new odd behavior when the values are mutable. Python is too flexible to reliably tell if something is mutable (and even checking if it is hashable is not technically enough as you can over-ride
Reusing the axes may or may not be the right thing because it will end up depending on how the projections work (which is between the objects being based in and the project code we have discovered). If they look at what ever state is in We could make a shallow copy of all of the values, but that would only push the problem "down a layer" or make things wrong in the opposite case as above. We could make a deep copy which may be expensive and inverts the cases where the behavior is wrong. There is already going to be an issue the values are numpy arrays ("truth value of an array is ambiguous!"), but they also didn't work in <=3.3 so I am not worried about that I think on net, the behavior in this patch is the best option (modulo adding some docs). In addition we could:
I'm inclined to leave it as-is and not get too deep on the semantics of this behavior with the "fix" for user problems is "please use the explicit API where you have a variable name to keep track of what Axes you want to use rather than a proxy variable name through some shared state". |
OK, I misunderstood @anntzer. The problem is that the user might mutate the object and think that they should get a new axes when they call subplot the second time. I guess I am OK with that being squishy and I still stand by my approval of this approach... |
I am now a bit worried about |
1ad9e60
to
8972645
Compare
Per the above I don't really know what's best for plt.subplot() users, so as long as the implications with mutable args have been thought out I'm fine with that (I agree with @tacaswell fig.add_subplot should just always add a new axes). |
I have confirmed that, if merged, this will fix the issues in Astropy. See astropy/astropy#11315. |
This looks good to me, thanks @tacaswell! |
84a06d1
to
5d32ae0
Compare
See also #10700. |
3ef9989
to
4c16c0d
Compare
OK, this time I think I got enough of the corner cases. It is a fair bit of code, but I think we have gotten all of the logic layed out in one place now :) Most of the line count is additional tests. |
8e77575
to
d63a18a
Compare
lib/matplotlib/pyplot.py
Outdated
""" | ||
proj_placeholder = object() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps call this unset
, I guess the conditions below read better?
should be returned, but if the parameters are different the old Axes is removed | ||
from the Figure and a new Axes is created and returned. | ||
|
||
Due to an implementation detail, it was previously the case that for all |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this implementation detail what was introduced by my changes in #19153? If so, we need not say that it was "previously the case" because those changes have never been in a release yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no, this is a bug behavior that goes back to 2.2.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- atleast 2.2
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but this text is missing a crucial half-sentence ....
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it. Thanks!
lib/matplotlib/pyplot.py
Outdated
""" | ||
proj_placeholder = object() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the placeholder object necessary? Why not None
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the user can palusibly pass in None
, the goal here is to detect if the user passed us anyting . @anntzer 's suggest of calling this unset
is better.
lib/matplotlib/pyplot.py
Outdated
@@ -1228,11 +1249,29 @@ def subplot(*args, **kwargs): | |||
(ax for ax in fig.axes | |||
if hasattr(ax, 'get_subplotspec') and ax.get_subplotspec() == key), | |||
None) | |||
# if we found an axes at the right position sort out if we |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the ax = ax
below are a bit weird even though I get the logic. Perhaps rewrite starting by removing the call to next()
and then
for ax in fix.axes:
if hasattr(ax, "...") and ax.get_subplotspec() == key:
p_class, pkw = ...
if <reuse>:
break # found it
else: # didn't find an ax we can reuse, so make a new one
ax = fig.add_subplot(...)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, I like that better. I opted to do ax=ax
rather than pass
in the bodies of the if statements.
lib/matplotlib/pyplot.py
Outdated
p_class, pkw = fig._process_projection_requirements(*args, **kwargs) | ||
# if no projection or kwargs were passed, return the axes we | ||
# found. | ||
if projection is proj_placeholder and pkw == {}: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't this case be handled by the more general case below with _projection_init == (Axes, {})
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, because we did not used to include the projection class in the key, only the kwargs, for the matching, and then did isintance checking to see if the Axes was a sub-class of the Axes we found. The first condition is for the "just get me that Axes" logic, the second is for if the user passed anything that we need to resolve.
ax2 = plt.subplot(111, polar=True) | ||
ax3 = plt.subplot(111, polar=True, projection='polar') | ||
assert ax1 is ax2 | ||
assert ax1 is ax3 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My opinion is still that a lot of the logic and copious comments in this PR are to satisfy this case. While I freely admit this worked before, I still do not feel preserving this is worth all this extra logic. The point here is to give the user a handle back to their original object. Already ax4=plt.subplot(111)
will do that for them, as well as calling it with the original kwargs. I'd be shocked if we broke anyone by making the asserts fail, or if we did that they would not have a trivial fix to make it work again.
136bf4f
to
4f54d37
Compare
4f54d37
to
1ced8ca
Compare
Simplified the implementation and line-wrapped the comments to 79 characters. |
This adds a small amount of additional state to the Axes created via Figure.add_axes and Figure.add_subplot (which the other Axes creation methods eventually funnel through) to track the projection class and (processed) kwargs. We then use that state in `pyplot.subplot` to determine if we should re-use an Axes found at a given position or create a new one (and implicitly destroy the existing one). This also changes the behavior of `plt.subplot` when no kwargs are passed to return the Axes at the location without doing any matching of the kwargs. As part of this work we also fixed two additional bugs: - you can now force Axes "back to" rectilinear Axes by passing projection='rectilinear' - Axes3D instances can now be re-selected at all closes matplotlib#19432 closes matplotlib#10700 Co-authored-by: Elliott Sales de Andrade <quantum.analyst@gmail.com>
1ced8ca
to
c189677
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good news, astropy tests are still passing. Just spotted one typo, and suggested one very minor change that is a matter of style choice.
# if we found an axes at the position sort out if we can re-use it | ||
if hasattr(ax, 'get_subplotspec') and ax.get_subplotspec() == key: | ||
# if the user passed no kwargs, re-use | ||
if kwargs == {}: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if kwargs == {}: | |
if not kwargs: |
but I know it's just a matter of style.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can see arguments either way here. I want to say "If kwargs is equal to the empty dictionary" which is equivalent to "kwargs is falsy", but I think the ==
version communicates to the reader better the condition they should be thinking when the read it.
This adds a small amount of additional state to the Axes created via
Figure.add_axes and Figure.add_subplot (which the other Axes creation
methods eventually funnel through) to track the kwargs passed
through.
We then use that state in
pyplot.subplot
to determine if we shouldre-use an Axes found at a given position or create a new one (and
implicitly destroy the existing one).
closes #19432
closes #10700
PR Summary
PR Checklist
pytest
passes).flake8
on changed files to check).flake8-docstrings
and runflake8 --docstring-convention=all
).doc/users/next_whats_new/
(follow instructions in README.rst there).doc/api/next_api_changes/
(follow instructions in README.rst there).