Skip to content

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

Merged
merged 3 commits into from
Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions doc/api/next_api_changes/behavior/19438-TAC.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
``plt.subplot`` re-selection without keyword arguments
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The purpose of `.pyplot.subplot` is to facilitate creating and re-selecting
Axes in a Figure when working strictly in the implicit pyplot API. When
creating new Axes it is possible to select the projection (e.g. polar, 3D, or
various cartographic projections) as well as to pass additional keyword
arguments through to the Axes-subclass that is created.

The first time `.pyplot.subplot` is called for a given position in the Axes
grid it always creates and return a new Axes with the passed arguments and
projection (defaulting to a rectilinear). On subsequent calls to
`.pyplot.subplot` we have to determine if an existing Axes has equivalent
parameters, in which case in should be selected as the current Axes and
returned, or different parameters, in which case a new Axes is created and the
existing Axes is removed. This leaves the question of what is "equivalent
parameters".

Previously it was the case that an existing Axes subclass, except for Axes3D,
would be considered equivalent to a 2D rectilinear Axes, despite having
different projections, if the kwargs (other than *projection*) matched. Thus
::

ax1 = plt.subplot(1, 1, 1, projection='polar')
ax2 = plt.subplots(1, 1, 1)
ax1 is ax2

We are embracing this long standing behavior to ensure that in the case when no
keyword arguments (of any sort) are passed to `.pyplot.subplot` any existing
Axes is returned, without consideration for keywords or projection used to
initially create it. This will cause a change in behavior when additional
keywords were passed to the original axes ::

ax1 = plt.subplot(111, projection='polar', theta_offset=.75)
ax2 = plt.subplots(1, 1, 1)
ax1 is ax2 # new behavior
# ax1 is not ax2 # old behavior, made a new axes

ax1 = plt.subplot(111, label='test')
ax2 = plt.subplots(1, 1, 1)
ax1 is ax2 # new behavior
# ax1 is not ax2 # old behavior, made a new axes


For the same reason, if there was an existing Axes that was not rectilinear,
passing ``projection='rectilinear'`` would reuse the existing Axes ::

ax1 = plt.subplot(projection='polar')
ax2 = plt.subplot(projection='rectilinear')
ax1 is not ax2 # new behavior, makes new axes
# ax1 is ax2 # old behavior


contrary to the users request.

Previously Axes3D could not be re-selected with `.pyplot.subplot` due to an
unrelated bug (also fixed in mpl3.4). While Axes3D are now consistent with all
other projections there is a change in behavior for ::

plt.subplot(projection='3d') # create a 3D Axes

plt.subplot() # now returns existing 3D Axes, but
# previously created new 2D Axes

plt.subplot(projection='rectilinear') # to get a new 2D Axes
28 changes: 14 additions & 14 deletions doc/users/next_whats_new/axes_kwargs_collision.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ Changes to behavior of Axes creation methods (``gca()``, ``add_axes()``, ``add_s

The behavior of the functions to create new axes (`.pyplot.axes`,
`.pyplot.subplot`, `.figure.Figure.add_axes`,
`.figure.Figure.add_subplot`) has changed. In the past, these functions would
detect if you were attempting to create Axes with the same keyword arguments as
already-existing axes in the current figure, and if so, they would return the
existing Axes. Now, these functions will always create new Axes. A special
exception is `.pyplot.subplot`, which will reuse any existing subplot with a
matching subplot spec. However, if there is a subplot with a matching subplot
spec, then that subplot will be returned, even if the keyword arguments with
which it was created differ.
`.figure.Figure.add_subplot`) has changed. In the past, these
functions would detect if you were attempting to create Axes with the
same keyword arguments as already-existing axes in the current figure,
and if so, they would return the existing Axes. Now, `.pyplot.axes`,
`.figure.Figure.add_axes`, and `.figure.Figure.add_subplot` will
always create new Axes. `.pyplot.subplot` will continue to reuse an
existing Axes with a matching subplot spec and equal *kwargs*.

Correspondingly, the behavior of the functions to get the current Axes
(`.pyplot.gca`, `.figure.Figure.gca`) has changed. In the past, these functions
accepted keyword arguments. If the keyword arguments matched an
already-existing Axes, then that Axes would be returned, otherwise new Axes
would be created with those keyword arguments. Now, the keyword arguments are
only considered if there are no axes at all in the current figure. In a future
release, these functions will not accept keyword arguments at all.
(`.pyplot.gca`, `.figure.Figure.gca`) has changed. In the past, these
functions accepted keyword arguments. If the keyword arguments
matched an already-existing Axes, then that Axes would be returned,
otherwise new Axes would be created with those keyword arguments.
Now, the keyword arguments are only considered if there are no axes at
all in the current figure. In a future release, these functions will
not accept keyword arguments at all.
1 change: 1 addition & 0 deletions lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,7 @@ def cla(self):
self._mouseover_set = _OrderedSet()
self.child_axes = []
self._current_image = None # strictly for pyplot via _sci, _gci
self._projection_init = None # strictly for pyplot.subplot
self.legend_ = None
self.collections = [] # collection.Collection instances
self.containers = []
Expand Down
26 changes: 16 additions & 10 deletions lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ def add_axes(self, *args, **kwargs):

if isinstance(args[0], Axes):
a = args[0]
key = a._projection_init
if a.get_figure() is not self:
raise ValueError(
"The Axes must have been created in the present figure")
Expand All @@ -575,12 +576,13 @@ def add_axes(self, *args, **kwargs):
if not np.isfinite(rect).all():
raise ValueError('all entries in rect must be finite '
'not {}'.format(rect))
projection_class, kwargs = self._process_projection_requirements(
projection_class, pkw = self._process_projection_requirements(
*args, **kwargs)

# create the new axes using the axes class given
a = projection_class(self, rect, **kwargs)
return self._add_axes_internal(a)
a = projection_class(self, rect, **pkw)
key = (projection_class, pkw)
return self._add_axes_internal(a, key)

@docstring.dedent_interpd
def add_subplot(self, *args, **kwargs):
Expand Down Expand Up @@ -693,6 +695,7 @@ def add_subplot(self, *args, **kwargs):

if len(args) == 1 and isinstance(args[0], SubplotBase):
ax = args[0]
key = ax._projection_init
if ax.get_figure() is not self:
raise ValueError("The Subplot must have been created in "
"the present figure")
Expand All @@ -705,17 +708,20 @@ def add_subplot(self, *args, **kwargs):
if (len(args) == 1 and isinstance(args[0], Integral)
and 100 <= args[0] <= 999):
args = tuple(map(int, str(args[0])))
projection_class, kwargs = self._process_projection_requirements(
projection_class, pkw = self._process_projection_requirements(
*args, **kwargs)
ax = subplot_class_factory(projection_class)(self, *args, **kwargs)
return self._add_axes_internal(ax)
ax = subplot_class_factory(projection_class)(self, *args, **pkw)
key = (projection_class, pkw)
return self._add_axes_internal(ax, key)

def _add_axes_internal(self, ax):
def _add_axes_internal(self, ax, key):
"""Private helper for `add_axes` and `add_subplot`."""
self._axstack.push(ax)
self._localaxes.push(ax)
self.sca(ax)
ax._remove_method = self.delaxes
# this is to support plt.subplot's re-selection logic
ax._projection_init = key
self.stale = True
ax.stale_callback = _stale_figure_callback
return ax
Expand Down Expand Up @@ -1502,9 +1508,9 @@ def _process_projection_requirements(
if polar:
if projection is not None and projection != 'polar':
raise ValueError(
"polar=True, yet projection=%r. "
"Only one of these arguments should be supplied." %
projection)
f"polar={polar}, yet projection={projection!r}. "
"Only one of these arguments should be supplied."
)
projection = 'polar'

if isinstance(projection, str) or projection is None:
Expand Down
86 changes: 56 additions & 30 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,10 +1072,10 @@ def cla():
@docstring.dedent_interpd
def subplot(*args, **kwargs):
"""
Add a subplot to the current figure.
Add an Axes to the current figure or retrieve an existing Axes.

Wrapper of `.Figure.add_subplot` with a difference in
behavior explained in the notes section.
This is a wrapper of `.Figure.add_subplot` which provides additional
behavior when working with the implicit API (see the notes section).

Call signatures::

Expand Down Expand Up @@ -1142,8 +1142,8 @@ def subplot(*args, **kwargs):

Notes
-----
Creating a subplot will delete any pre-existing subplot that overlaps
with it beyond sharing a boundary::
Creating a new Axes will delete any pre-existing Axes that
overlaps with it beyond sharing a boundary::

import matplotlib.pyplot as plt
# plot a line, implicitly creating a subplot(111)
Expand All @@ -1156,18 +1156,19 @@ def subplot(*args, **kwargs):
If you do not want this behavior, use the `.Figure.add_subplot` method
or the `.pyplot.axes` function instead.

If the figure already has a subplot with key (*args*,
*kwargs*) then it will simply make that subplot current and
return it. This behavior is deprecated. Meanwhile, if you do
not want this behavior (i.e., you want to force the creation of a
new subplot), you must use a unique set of args and kwargs. The axes
*label* attribute has been exposed for this purpose: if you want
two subplots that are otherwise identical to be added to the figure,
make sure you give them unique labels.
If no *kwargs* are passed and there exists an Axes in the location
specified by *args* then that Axes will be returned rather than a new
Axes being created.

In rare circumstances, `.Figure.add_subplot` may be called with a single
argument, a subplot axes instance already created in the
present figure but not in the figure's list of axes.
If *kwargs* are passed and there exists an Axes in the location
specified by *args*, the projection type is the same, and the
*kwargs* match with the existing Axes, then the existing Axes is
returned. Otherwise a new Axes is created with the specified
parameters. We save a reference to the *kwargs* which we us
for this comparison. If any of the values in *kwargs* are
mutable we will not detect the case where they are mutated.
In these cases we suggest using `.Figure.add_subplot` and the
explicit Axes API rather than the implicit pyplot API.

See Also
--------
Expand All @@ -1183,10 +1184,10 @@ def subplot(*args, **kwargs):
plt.subplot(221)

# equivalent but more general
ax1=plt.subplot(2, 2, 1)
ax1 = plt.subplot(2, 2, 1)

# add a subplot with no frame
ax2=plt.subplot(222, frameon=False)
ax2 = plt.subplot(222, frameon=False)

# add a polar subplot
plt.subplot(223, projection='polar')
Expand All @@ -1199,18 +1200,34 @@ def subplot(*args, **kwargs):

# add ax2 to the figure again
plt.subplot(ax2)

# make the first axes "current" again
plt.subplot(221)

"""
# Here we will only normalize `polar=True` vs `projection='polar'` and let
# downstream code deal with the rest.
unset = object()
projection = kwargs.get('projection', unset)
polar = kwargs.pop('polar', unset)
if polar is not unset and polar:
# if we got mixed messages from the user, raise
if projection is not unset and projection != 'polar':
raise ValueError(
f"polar={polar}, yet projection={projection!r}. "
"Only one of these arguments should be supplied."
)
kwargs['projection'] = projection = 'polar'

# if subplot called without arguments, create subplot(1, 1, 1)
if len(args) == 0:
args = (1, 1, 1)

# This check was added because it is very easy to type
# subplot(1, 2, False) when subplots(1, 2, False) was intended
# (sharex=False, that is). In most cases, no error will
# ever occur, but mysterious behavior can result because what was
# intended to be the sharex argument is instead treated as a
# subplot index for subplot()
# This check was added because it is very easy to type subplot(1, 2, False)
# when subplots(1, 2, False) was intended (sharex=False, that is). In most
# cases, no error will ever occur, but mysterious behavior can result
# because what was intended to be the sharex argument is instead treated as
# a subplot index for subplot()
if len(args) >= 3 and isinstance(args[2], bool):
_api.warn_external("The subplot index argument to subplot() appears "
"to be a boolean. Did you intend to use "
Expand All @@ -1224,15 +1241,24 @@ def subplot(*args, **kwargs):

# First, search for an existing subplot with a matching spec.
key = SubplotSpec._from_subplot_args(fig, args)
ax = next(
(ax for ax in fig.axes
if hasattr(ax, 'get_subplotspec') and ax.get_subplotspec() == key),
None)

# If no existing axes match, then create a new one.
if ax is None:
for ax in fig.axes:
# 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 == {}:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if kwargs == {}:
if not kwargs:

but I know it's just a matter of style.

Copy link
Member Author

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.

break
# if the axes class and kwargs are identical, reuse
elif ax._projection_init == fig._process_projection_requirements(
*args, **kwargs
):
break
else:
# we have exhausted the known Axes and none match, make a new one!
ax = fig.add_subplot(*args, **kwargs)

fig.sca(ax)

bbox = ax.bbox
axes_to_delete = []
for other_ax in fig.axes:
Expand Down
Loading