From 2d5e34580fb01358a8f80239e8cd59d096404aed Mon Sep 17 00:00:00 2001 From: Leo Singer Date: Wed, 18 Nov 2020 20:39:28 -0500 Subject: [PATCH 1/2] MNT: Remove deprecated axes kwargs collision detection In Matplotlib 2.1, the behavior of reusing existing axes when created with the same arguments was deprecated (see #9037). This behavior is now removed. Functions that create new axes (`axes`, `add_axes`, `subplot`, etc.) will now always create new axes, regardless of whether the kwargs passed to them match already existing axes. Passing kwargs to `gca` is deprecated. If `gca` is called with kwargs that do not match the current axes, then an exception is raised. Fixes #18832. --- .../deprecations/18978-LPS.rst | 5 + .../development/18978-LPS.rst | 8 ++ .../next_whats_new/axes_kwargs_collision.rst | 18 +++ lib/matplotlib/figure.py | 111 ++++-------------- lib/matplotlib/pyplot.py | 7 +- lib/matplotlib/tests/test_axes.py | 32 +++-- lib/matplotlib/tests/test_figure.py | 33 ++++-- 7 files changed, 104 insertions(+), 110 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/18978-LPS.rst create mode 100644 doc/api/next_api_changes/development/18978-LPS.rst create mode 100644 doc/users/next_whats_new/axes_kwargs_collision.rst diff --git a/doc/api/next_api_changes/deprecations/18978-LPS.rst b/doc/api/next_api_changes/deprecations/18978-LPS.rst new file mode 100644 index 000000000000..705f672d33b5 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/18978-LPS.rst @@ -0,0 +1,5 @@ +pyplot.gca() +~~~~~~~~~~~~ + +Passing keyword arguments to ``.pyplot.gca`` will not be supported in a future +release. diff --git a/doc/api/next_api_changes/development/18978-LPS.rst b/doc/api/next_api_changes/development/18978-LPS.rst new file mode 100644 index 000000000000..cd590764492d --- /dev/null +++ b/doc/api/next_api_changes/development/18978-LPS.rst @@ -0,0 +1,8 @@ +Changes to _AxesStack, preparing for its removal +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The behavior of the internal ``.figure._AxesStack`` class has changed +significantly in the process of removing the old behavior of gca() with regard +to keyword arguments. When the deprecated behavior has been fully removed and +gca() no longer takes keyword arguments, the ``.figure._AxesStack`` class will +be removed. diff --git a/doc/users/next_whats_new/axes_kwargs_collision.rst b/doc/users/next_whats_new/axes_kwargs_collision.rst new file mode 100644 index 000000000000..f04f5e3f82b1 --- /dev/null +++ b/doc/users/next_whats_new/axes_kwargs_collision.rst @@ -0,0 +1,18 @@ +Changes to behavior of Axes creation methods (gca(), add_axes(), add_subplot()) +------------------------------------------------------------------------------- + +The behavior of the functions to create new 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. + +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, an exception is raised if +there are Axes and the current Axes were not created with the same keyword +arguments. In a future release, these functions will not accept keyword +arguments at all. diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 67436b15f444..7c2d723e30b1 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -56,48 +56,22 @@ class _AxesStack(cbook.Stack): Specialization of `.Stack`, to handle all tracking of `~.axes.Axes` in a `.Figure`. - This stack stores ``key, (ind, axes)`` pairs, where: - - * **key** is a hash of the args and kwargs used in generating the Axes. - * **ind** is a serial index tracking the order in which Axes were added. + This stack stores ``key, axes`` pairs, where **key** is a hash of the args + and kwargs used in generating the Axes. AxesStack is a callable; calling it returns the current Axes. The `current_key_axes` method returns the current key and associated Axes. """ - def __init__(self): - super().__init__() - self._ind = 0 - def as_list(self): """ Return a list of the Axes instances that have been added to the figure. """ - ia_list = [a for k, a in self._elements] - ia_list.sort() - return [a for i, a in ia_list] - - def get(self, key): - """ - Return the Axes instance that was added with *key*. - If it is not present, return *None*. - """ - item = dict(self._elements).get(key) - if item is None: - return None - cbook.warn_deprecated( - "2.1", - message="Adding an axes using the same arguments as a previous " - "axes currently reuses the earlier instance. In a future " - "version, a new instance will always be created and returned. " - "Meanwhile, this warning can be suppressed, and the future " - "behavior ensured, by passing a unique label to each axes " - "instance.") - return item[1] + return [a for k, a in self._elements] def _entry_from_axes(self, e): - ind, k = {a: (ind, k) for k, (ind, a) in self._elements}[e] - return (k, (ind, e)) + k = {a: k for k, a in self._elements}[e] + return (k, e) def remove(self, a): """Remove the Axes from the stack.""" @@ -114,30 +88,13 @@ def add(self, key, a): """ Add Axes *a*, with key *key*, to the stack, and return the stack. - If *key* is unhashable, replace it by a unique, arbitrary object. - If *a* is already on the stack, don't add it again, but return *None*. """ # All the error checking may be unnecessary; but this method # is called so seldom that the overhead is negligible. cbook._check_isinstance(Axes, a=a) - try: - hash(key) - except TypeError: - key = object() - - a_existing = self.get(key) - if a_existing is not None: - super().remove((key, a_existing)) - cbook._warn_external( - "key {!r} already existed; Axes is being replaced".format(key)) - # I don't think the above should ever happen. - - if a in self: - return None - self._ind += 1 - return super().push((key, (self._ind, a))) + return super().push((key, a)) def current_key_axes(self): """ @@ -145,14 +102,10 @@ def current_key_axes(self): If no Axes exists on the stack, then returns ``(None, None)``. """ - if not len(self._elements): - return self._default, self._default - else: - key, (index, axes) = self._elements[self._pos] - return key, axes + return super().__call__() or (None, None) def __call__(self): - return self.current_key_axes()[1] + ka = self.current_key_axes()[1] def __contains__(self, a): return a in self.as_list() @@ -689,15 +642,8 @@ def add_axes(self, *args, **kwargs): "add_axes() got multiple values for argument 'rect'") args = (kwargs.pop('rect'), ) - # shortcut the projection "key" modifications later on, if an axes - # with the exact args/kwargs exists, return it immediately. - key = self._make_key(*args, **kwargs) - ax = self._axstack.get(key) - if ax is not None: - self.sca(ax) - return ax - if isinstance(args[0], Axes): + key = self._make_key(*args, **kwargs) a = args[0] if a.get_figure() is not self: raise ValueError( @@ -710,13 +656,6 @@ def add_axes(self, *args, **kwargs): projection_class, kwargs, key = \ self._process_projection_requirements(*args, **kwargs) - # check that an axes of this type doesn't already exist, if it - # does, set it as active and return it - ax = self._axstack.get(key) - if isinstance(ax, projection_class): - self.sca(ax) - return ax - # create the new axes using the axes class given a = projection_class(self, rect, **kwargs) return self._add_axes_internal(key, a) @@ -861,19 +800,6 @@ def add_subplot(self, *args, **kwargs): args = tuple(map(int, str(args[0]))) projection_class, kwargs, key = \ self._process_projection_requirements(*args, **kwargs) - ax = self._axstack.get(key) # search axes with this key in stack - if ax is not None: - if isinstance(ax, projection_class): - # the axes already existed, so set it as active & return - self.sca(ax) - return ax - else: - # Undocumented convenience behavior: - # subplot(111); subplot(111, projection='polar') - # will replace the first with the second. - # Without this, add_subplot would be simpler and - # more similar to add_axes. - self._axstack.remove(ax) ax = subplot_class_factory(projection_class)(self, *args, **kwargs) return self._add_axes_internal(key, ax) @@ -1601,6 +1527,16 @@ def gca(self, **kwargs): %(Axes)s """ + if kwargs: + cbook.warn_deprecated( + "3.4", + message="Calling gca() with keyword arguments is deprecated. " + "In a future version, gca() will take no keyword arguments. " + "The gca() function should only be used to get the current " + "axes, or if no axes exist, create new axes with default " + "keyword arguments. To create a new axes with non-default " + "arguments, use plt.axes() or plt.subplot().") + ckey, cax = self._axstack.current_key_axes() # if there exists an axes on the stack see if it matches # the desired axes configuration @@ -1627,10 +1563,11 @@ def gca(self, **kwargs): if key == ckey and isinstance(cax, projection_class): return cax else: - cbook._warn_external('Requested projection is different ' - 'from current axis projection, ' - 'creating new axis with requested ' - 'projection.') + raise ValueError( + "The arguments passed to gca() did not match the " + "arguments with which the current axes were " + "originally created. To create new axes, use " + "axes() or subplot().") # no axes found, so create one which spans the figure return self.add_subplot(1, 1, 1, **kwargs) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 7b35433bd3c2..a8a9b81a56bf 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2410,10 +2410,13 @@ def polar(*args, **kwargs): """ # If an axis already exists, check if it has a polar projection if gcf().get_axes(): - if not isinstance(gca(), PolarAxes): + ax = gca() + if isinstance(ax, PolarAxes): + return ax + else: cbook._warn_external('Trying to create polar plot on an axis ' 'that does not have a polar projection.') - ax = gca(polar=True) + ax = axes(polar=True) ret = ax.plot(*args, **kwargs) return ret diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 49627e9ce433..e284cff6717e 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2387,28 +2387,36 @@ def _as_mpl_axes(self): # testing axes creation with plt.axes ax = plt.axes([0, 0, 1, 1], projection=prj) assert type(ax) == PolarAxes - ax_via_gca = plt.gca(projection=prj) + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + ax_via_gca = plt.gca(projection=prj) assert ax_via_gca is ax plt.close() # testing axes creation with gca - ax = plt.gca(projection=prj) + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + ax = plt.gca(projection=prj) assert type(ax) == mpl.axes._subplots.subplot_class_factory(PolarAxes) - ax_via_gca = plt.gca(projection=prj) + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + ax_via_gca = plt.gca(projection=prj) assert ax_via_gca is ax # try getting the axes given a different polar projection - with pytest.warns(UserWarning) as rec: + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'), \ + pytest.raises( + ValueError, match=r'arguments passed to gca\(\) did not match'): ax_via_gca = plt.gca(projection=prj2) - assert len(rec) == 1 - assert 'Requested projection is different' in str(rec[0].message) - assert ax_via_gca is not ax - assert ax.get_theta_offset() == 0 - assert ax_via_gca.get_theta_offset() == np.pi # try getting the axes given an == (not is) polar projection - with pytest.warns(UserWarning): + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): ax_via_gca = plt.gca(projection=prj3) - assert len(rec) == 1 - assert 'Requested projection is different' in str(rec[0].message) assert ax_via_gca is ax plt.close() diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c5ab3cf6d232..15a20b1bd08b 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -8,6 +8,7 @@ import matplotlib as mpl from matplotlib import cbook, rcParams +from matplotlib.cbook import MatplotlibDeprecationWarning from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -154,30 +155,44 @@ def test_gca(): assert fig.add_axes() is None ax0 = fig.add_axes([0, 0, 1, 1]) - assert fig.gca(projection='rectilinear') is ax0 + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + assert fig.gca(projection='rectilinear') is ax0 assert fig.gca() is ax0 ax1 = fig.add_axes(rect=[0.1, 0.1, 0.8, 0.8]) - assert fig.gca(projection='rectilinear') is ax1 + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + assert fig.gca(projection='rectilinear') is ax1 assert fig.gca() is ax1 ax2 = fig.add_subplot(121, projection='polar') assert fig.gca() is ax2 - assert fig.gca(polar=True) is ax2 + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + assert fig.gca(polar=True) is ax2 ax3 = fig.add_subplot(122) assert fig.gca() is ax3 # the final request for a polar axes will end up creating one # with a spec of 111. - with pytest.warns(UserWarning): - # Changing the projection will throw a warning - assert fig.gca(polar=True) is not ax3 - assert fig.gca(polar=True) is not ax2 - assert fig.gca().get_subplotspec().get_geometry() == (1, 1, 0, 0) + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'), \ + pytest.raises( + ValueError, match=r'arguments passed to gca\(\) did not match'): + # Changing the projection will raise an exception + fig.gca(polar=True) fig.sca(ax1) - assert fig.gca(projection='rectilinear') is ax1 + with pytest.warns( + MatplotlibDeprecationWarning, + match=r'Calling gca\(\) with keyword arguments is deprecated'): + assert fig.gca(projection='rectilinear') is ax1 assert fig.gca() is ax1 From 41b93d895d8a22cf53a7276618214d3f20fc1870 Mon Sep 17 00:00:00 2001 From: Leo Singer Date: Wed, 2 Dec 2020 08:34:31 -0500 Subject: [PATCH 2/2] WIP --- lib/matplotlib/figure.py | 137 +++++++++++++++------------- lib/matplotlib/tests/test_axes.py | 4 +- lib/matplotlib/tests/test_figure.py | 4 +- 3 files changed, 77 insertions(+), 68 deletions(-) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 7c2d723e30b1..9e7ea15db0dc 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -56,22 +56,40 @@ class _AxesStack(cbook.Stack): Specialization of `.Stack`, to handle all tracking of `~.axes.Axes` in a `.Figure`. - This stack stores ``key, axes`` pairs, where **key** is a hash of the args - and kwargs used in generating the Axes. + This stack stores ``key, (ind, axes)`` pairs, where: + + * **key** is a hash of the args used in generating the Axes. + * **ind** is a serial index tracking the order in which Axes were added. AxesStack is a callable; calling it returns the current Axes. The `current_key_axes` method returns the current key and associated Axes. """ + def __init__(self): + super().__init__() + self._ind = 0 + def as_list(self): """ Return a list of the Axes instances that have been added to the figure. """ - return [a for k, a in self._elements] + ia_list = [a for k, a in self._elements] + ia_list.sort() + return [a for i, a in ia_list] + + def get(self, key): + """ + Return the Axes instance that was added with *key*. + If it is not present, return *None*. + """ + item = dict(self._elements).get(key) + if item is None: + return None + return item[1] def _entry_from_axes(self, e): - k = {a: k for k, a in self._elements}[e] - return (k, e) + ind, k = {a: (ind, k) for k, (ind, a) in self._elements}[e] + return (k, (ind, e)) def remove(self, a): """Remove the Axes from the stack.""" @@ -94,7 +112,18 @@ def add(self, key, a): # All the error checking may be unnecessary; but this method # is called so seldom that the overhead is negligible. cbook._check_isinstance(Axes, a=a) - return super().push((key, a)) + + a_existing = self.get(key) + if a_existing is not None: + super().remove((key, a_existing)) + cbook._warn_external( + "key {!r} already existed; Axes is being replaced".format(key)) + # I don't think the above should ever happen. + + if a in self: + return None + self._ind += 1 + return super().push((key, (self._ind, a))) def current_key_axes(self): """ @@ -102,10 +131,14 @@ def current_key_axes(self): If no Axes exists on the stack, then returns ``(None, None)``. """ - return super().__call__() or (None, None) + if not len(self._elements): + return self._default, self._default + else: + key, (index, axes) = self._elements[self._pos] + return key, axes def __call__(self): - ka = self.current_key_axes()[1] + return self.current_key_axes()[1] def __contains__(self, a): return a in self.as_list() @@ -643,11 +676,11 @@ def add_axes(self, *args, **kwargs): args = (kwargs.pop('rect'), ) if isinstance(args[0], Axes): - key = self._make_key(*args, **kwargs) a = args[0] if a.get_figure() is not self: raise ValueError( "The Axes must have been created in the present figure") + key = self._make_key(*args) else: rect = args[0] if not np.isfinite(rect).all(): @@ -656,6 +689,13 @@ def add_axes(self, *args, **kwargs): projection_class, kwargs, key = \ self._process_projection_requirements(*args, **kwargs) + # check that an axes of this type doesn't already exist, if it + # does, set it as active and return it + ax = self._axstack.get(key) + if isinstance(ax, projection_class): + self.sca(ax) + return ax + # create the new axes using the axes class given a = projection_class(self, rect, **kwargs) return self._add_axes_internal(key, a) @@ -787,7 +827,7 @@ def add_subplot(self, *args, **kwargs): "the present figure") # make a key for the subplot (which includes the axes object id # in the hash) - key = self._make_key(*args, **kwargs) + key = self._make_key(*args) else: if not args: @@ -800,6 +840,19 @@ def add_subplot(self, *args, **kwargs): args = tuple(map(int, str(args[0]))) projection_class, kwargs, key = \ self._process_projection_requirements(*args, **kwargs) + ax = self._axstack.get(key) # search axes with this key in stack + if ax is not None: + if isinstance(ax, projection_class): + # the axes already existed, so set it as active & return + self.sca(ax) + return ax + else: + # Undocumented convenience behavior: + # subplot(111); subplot(111, projection='polar') + # will replace the first with the second. + # Without this, add_subplot would be simpler and + # more similar to add_axes. + self._axstack.remove(ax) ax = subplot_class_factory(projection_class)(self, *args, **kwargs) return self._add_axes_internal(key, ax) @@ -1531,43 +1584,19 @@ def gca(self, **kwargs): cbook.warn_deprecated( "3.4", message="Calling gca() with keyword arguments is deprecated. " - "In a future version, gca() will take no keyword arguments. " - "The gca() function should only be used to get the current " - "axes, or if no axes exist, create new axes with default " - "keyword arguments. To create a new axes with non-default " - "arguments, use plt.axes() or plt.subplot().") + "gca() no longer checks whether the keyword arguments match " + "those with which the current axes were created. In a future " + "version, gca() will take no keyword arguments. The gca() " + "function should only be used to get the current axes, or if " + "no axes exist, create new axes with default keyword " + "arguments. To create a new axes with non-default arguments, " + "use plt.axes() or plt.subplot().") ckey, cax = self._axstack.current_key_axes() # if there exists an axes on the stack see if it matches # the desired axes configuration if cax is not None: - - # if no kwargs are given just return the current axes - # this is a convenience for gca() on axes such as polar etc. - if not kwargs: - return cax - - # if the user has specified particular projection detail - # then build up a key which can represent this - else: - projection_class, _, key = \ - self._process_projection_requirements(**kwargs) - - # let the returned axes have any gridspec by removing it from - # the key - ckey = ckey[1:] - key = key[1:] - - # if the cax matches this key then return the axes, otherwise - # continue and a new axes will be created - if key == ckey and isinstance(cax, projection_class): - return cax - else: - raise ValueError( - "The arguments passed to gca() did not match the " - "arguments with which the current axes were " - "originally created. To create new axes, use " - "axes() or subplot().") + return cax # no axes found, so create one which spans the figure return self.add_subplot(1, 1, 1, **kwargs) @@ -1643,28 +1672,12 @@ def _process_projection_requirements( # Make the key without projection kwargs, this is used as a unique # lookup for axes instances - key = self._make_key(*args, **kwargs) + key = self._make_key(*args) return projection_class, kwargs, key - def _make_key(self, *args, **kwargs): - """Make a hashable key out of args and kwargs.""" - - def fixitems(items): - # items may have arrays and lists in them, so convert them - # to tuples for the key - ret = [] - for k, v in items: - # some objects can define __getitem__ without being - # iterable and in those cases the conversion to tuples - # will fail. So instead of using the np.iterable(v) function - # we simply try and convert to a tuple, and proceed if not. - try: - v = tuple(v) - except Exception: - pass - ret.append((k, v)) - return tuple(ret) + def _make_key(self, *args): + """Make a hashable key out of args.""" def fixlist(args): ret = [] @@ -1674,7 +1687,7 @@ def fixlist(args): ret.append(a) return tuple(ret) - key = fixlist(args), fixitems(kwargs.items()) + key = fixlist(args) return key def get_default_bbox_extra_artists(self): diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index e284cff6717e..8d835d705200 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2408,9 +2408,7 @@ def _as_mpl_axes(self): # try getting the axes given a different polar projection with pytest.warns( MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments is deprecated'), \ - pytest.raises( - ValueError, match=r'arguments passed to gca\(\) did not match'): + match=r'Calling gca\(\) with keyword arguments is deprecated'): ax_via_gca = plt.gca(projection=prj2) # try getting the axes given an == (not is) polar projection with pytest.warns( diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 15a20b1bd08b..ee796049c6d8 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -182,9 +182,7 @@ def test_gca(): # with a spec of 111. with pytest.warns( MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments is deprecated'), \ - pytest.raises( - ValueError, match=r'arguments passed to gca\(\) did not match'): + match=r'Calling gca\(\) with keyword arguments is deprecated'): # Changing the projection will raise an exception fig.gca(polar=True)