diff --git a/doc/api/next_api_changes/behavior/19438-TAC.rst b/doc/api/next_api_changes/behavior/19438-TAC.rst new file mode 100644 index 000000000000..c491c3a30fbd --- /dev/null +++ b/doc/api/next_api_changes/behavior/19438-TAC.rst @@ -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 diff --git a/doc/users/next_whats_new/axes_kwargs_collision.rst b/doc/users/next_whats_new/axes_kwargs_collision.rst index 35d425a87dcb..350e75f800b5 100644 --- a/doc/users/next_whats_new/axes_kwargs_collision.rst +++ b/doc/users/next_whats_new/axes_kwargs_collision.rst @@ -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. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 84647e459896..728d22cd7d08 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -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 = [] diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 620d936e8e51..fe98dd3f59a8 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -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") @@ -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): @@ -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") @@ -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 @@ -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: diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 534d0f1998bd..f033619182e6 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -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:: @@ -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) @@ -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 -------- @@ -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') @@ -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 " @@ -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 == {}: + 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: diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index b5748491bdcf..b3835ad79759 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -939,30 +939,32 @@ def test_subfigure_double(): axsRight = subfigs[1].subplots(2, 2) -def test_axes_kwargs(): - # plt.axes() always creates new axes, even if axes kwargs differ. - plt.figure() - ax = plt.axes() - ax1 = plt.axes() +def test_add_subplot_kwargs(): + # fig.add_subplot() always creates new axes, even if axes kwargs differ. + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax1 = fig.add_subplot(1, 1, 1) assert ax is not None assert ax1 is not ax plt.close() - plt.figure() - ax = plt.axes(projection='polar') - ax1 = plt.axes(projection='polar') + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection='polar') + ax1 = fig.add_subplot(1, 1, 1, projection='polar') assert ax is not None assert ax1 is not ax plt.close() - plt.figure() - ax = plt.axes(projection='polar') - ax1 = plt.axes() + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection='polar') + ax1 = fig.add_subplot(1, 1, 1) assert ax is not None assert ax1.name == 'rectilinear' assert ax1 is not ax plt.close() + +def test_add_axes_kwargs(): # fig.add_axes() always creates new axes, even if axes kwargs differ. fig = plt.figure() ax = fig.add_axes([0, 0, 1, 1]) @@ -985,73 +987,3 @@ def test_axes_kwargs(): assert ax1.name == 'rectilinear' assert ax1 is not ax plt.close() - - # fig.add_subplot() always creates new axes, even if axes kwargs differ. - fig = plt.figure() - ax = fig.add_subplot(1, 1, 1) - ax1 = fig.add_subplot(1, 1, 1) - assert ax is not None - assert ax1 is not ax - plt.close() - - fig = plt.figure() - ax = fig.add_subplot(1, 1, 1, projection='polar') - ax1 = fig.add_subplot(1, 1, 1, projection='polar') - assert ax is not None - assert ax1 is not ax - plt.close() - - fig = plt.figure() - ax = fig.add_subplot(1, 1, 1, projection='polar') - ax1 = fig.add_subplot(1, 1, 1) - assert ax is not None - assert ax1.name == 'rectilinear' - assert ax1 is not ax - plt.close() - - # plt.subplot() searches for axes with the same subplot spec, and if one - # exists, returns it, regardless of whether the axes kwargs were the same. - fig = plt.figure() - ax = plt.subplot(1, 2, 1) - ax1 = plt.subplot(1, 2, 1) - ax2 = plt.subplot(1, 2, 2) - ax3 = plt.subplot(1, 2, 1, projection='polar') - assert ax is not None - assert ax1 is ax - assert ax2 is not ax - assert ax3 is ax - assert ax.name == 'rectilinear' - assert ax3.name == 'rectilinear' - plt.close() - - # plt.gca() returns an existing axes, unless there were no axes. - plt.figure() - ax = plt.gca() - ax1 = plt.gca() - assert ax is not None - assert ax1 is ax - plt.close() - - # plt.gca() raises a DeprecationWarning if called with kwargs. - plt.figure() - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax = plt.gca(projection='polar') - ax1 = plt.gca() - assert ax is not None - assert ax1 is ax - assert ax1.name == 'polar' - plt.close() - - # plt.gca() ignores keyword arguments if an axes already exists. - plt.figure() - ax = plt.gca() - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax1 = plt.gca(projection='polar') - assert ax is not None - assert ax1 is ax - assert ax1.name == 'rectilinear' - plt.close() diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index b60b86ec6575..c2c71d586715 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -161,3 +161,152 @@ def test_close(): except TypeError as e: assert str(e) == "close() argument must be a Figure, an int, " \ "a string, or None, not " + + +def test_subplot_reuse(): + ax1 = plt.subplot(121) + assert ax1 is plt.gca() + ax2 = plt.subplot(122) + assert ax2 is plt.gca() + ax3 = plt.subplot(121) + assert ax1 is plt.gca() + assert ax1 is ax3 + + +def test_axes_kwargs(): + # plt.axes() always creates new axes, even if axes kwargs differ. + plt.figure() + ax = plt.axes() + ax1 = plt.axes() + assert ax is not None + assert ax1 is not ax + plt.close() + + plt.figure() + ax = plt.axes(projection='polar') + ax1 = plt.axes(projection='polar') + assert ax is not None + assert ax1 is not ax + plt.close() + + plt.figure() + ax = plt.axes(projection='polar') + ax1 = plt.axes() + assert ax is not None + assert ax1.name == 'rectilinear' + assert ax1 is not ax + plt.close() + + +def test_subplot_replace_projection(): + # plt.subplot() searches for axes with the same subplot spec, and if one + # exists, and the kwargs match returns it, create a new one if they do not + 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 + ax3 = plt.subplot(1, 2, 1, projection='polar') + ax4 = plt.subplot(1, 2, 1, projection='polar') + assert ax is not None + assert ax1 is ax + assert ax2 is not ax + assert ax3 is not ax + assert ax3 is ax4 + + assert ax not in fig.axes + assert ax2 in fig.axes + assert ax3 in fig.axes + + assert ax.name == 'rectilinear' + assert ax2.name == 'rectilinear' + assert ax3.name == 'polar' + + +def test_subplot_kwarg_collision(): + ax1 = plt.subplot(projection='polar', theta_offset=0) + ax2 = plt.subplot(projection='polar', theta_offset=0) + assert ax1 is ax2 + ax3 = plt.subplot(projection='polar', theta_offset=1) + assert ax1 is not ax3 + assert ax1 not in plt.gcf().axes + + +def test_gca_kwargs(): + # plt.gca() returns an existing axes, unless there were no axes. + plt.figure() + ax = plt.gca() + ax1 = plt.gca() + assert ax is not None + assert ax1 is ax + plt.close() + + # plt.gca() raises a DeprecationWarning if called with kwargs. + plt.figure() + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments was deprecated'): + ax = plt.gca(projection='polar') + ax1 = plt.gca() + assert ax is not None + assert ax1 is ax + assert ax1.name == 'polar' + plt.close() + + # plt.gca() ignores keyword arguments if an axes already exists. + plt.figure() + ax = plt.gca() + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments was deprecated'): + ax1 = plt.gca(projection='polar') + assert ax is not None + assert ax1 is ax + assert ax1.name == 'rectilinear' + plt.close() + + +def test_subplot_projection_reuse(): + # create an axes + ax1 = plt.subplot(111) + # check that it is current + assert ax1 is plt.gca() + # make sure we get it back if we ask again + assert ax1 is plt.subplot(111) + # create a polar plot + ax2 = plt.subplot(111, projection='polar') + assert ax2 is plt.gca() + # this should have deleted the first axes + assert ax1 not in plt.gcf().axes + # assert we get it back if no extra parameters passed + assert ax2 is plt.subplot(111) + # now check explicitly setting the projection to rectilinear + # makes a new axes + ax3 = plt.subplot(111, projection='rectilinear') + assert ax3 is plt.gca() + assert ax3 is not ax2 + assert ax2 not in plt.gcf().axes + + +def test_subplot_polar_normalization(): + ax1 = plt.subplot(111, projection='polar') + ax2 = plt.subplot(111, polar=True) + ax3 = plt.subplot(111, polar=True, projection='polar') + assert ax1 is ax2 + assert ax1 is ax3 + + with pytest.raises(ValueError, + match="polar=True, yet projection='3d'"): + ax2 = plt.subplot(111, polar=True, projection='3d') + + +def test_subplot_change_projection(): + ax = plt.subplot() + projections = ('aitoff', 'hammer', 'lambert', 'mollweide', + 'polar', 'rectilinear', '3d') + for proj in projections: + ax_next = plt.subplot(projection=proj) + assert ax_next is plt.subplot() + assert ax_next.name == proj + assert ax is not ax_next + ax = ax_next