From 333bc06c28e785e7972f75368ee04a1ca5c866cd Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sat, 28 Mar 2020 10:59:46 -0600 Subject: [PATCH 1/2] Changing get_cmap to return copies of the registered colormaps. --- doc/api/next_api_changes/behaviour.rst | 14 +++++++++++--- lib/matplotlib/cm.py | 21 +++++++++++++-------- lib/matplotlib/colors.py | 12 ++++++++++++ lib/matplotlib/tests/test_colors.py | 14 +++++++++++++- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/doc/api/next_api_changes/behaviour.rst b/doc/api/next_api_changes/behaviour.rst index ff7fdc3695e4..58c53c274467 100644 --- a/doc/api/next_api_changes/behaviour.rst +++ b/doc/api/next_api_changes/behaviour.rst @@ -98,9 +98,9 @@ deprecation warning. `~.Axes.errorbar` now color cycles when only errorbar color is set ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Previously setting the *ecolor* would turn off automatic color cycling for the plot, leading to the -the lines and markers defaulting to whatever the first color in the color cycle was in the case of -multiple plot calls. +Previously setting the *ecolor* would turn off automatic color cycling for the plot, leading to the +the lines and markers defaulting to whatever the first color in the color cycle was in the case of +multiple plot calls. `.rcsetup.validate_color_for_prop_cycle` now always raises TypeError for bytes input ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -147,3 +147,11 @@ The parameter ``s`` to `.Axes.annotate` and `.pyplot.annotate` is renamed to The old parameter name remains supported, but support for it will be dropped in a future Matplotlib release. +`pyplot.get_cmap()` now returns a copy of the colormap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Previously, calling ``.pyplot.get_cmap()`` would return a pointer to +the built-in Colormap. If you made modifications to that colormap, the +changes would be propagated in the global state. This function now +returns a copy of all registered colormaps to keep the built-in +colormaps untouched. + diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index fc5dacbf7cb8..d1e67c347d1c 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -15,6 +15,7 @@ normalization. """ +import copy import functools import numpy as np @@ -70,7 +71,7 @@ def _gen_cmap_d(): cmap_d = _gen_cmap_d() -locals().update(cmap_d) +locals().update(copy.deepcopy(cmap_d)) # Continue with definitions ... @@ -95,6 +96,9 @@ def register_cmap(name=None, cmap=None, data=None, lut=None): and the resulting colormap is registered. Instead of this implicit colormap creation, create a `.LinearSegmentedColormap` and use the first case: ``register_cmap(cmap=LinearSegmentedColormap(name, data, lut))``. + + If *name* is the same as a built-in colormap this will replace the + built-in Colormap of the same name. """ cbook._check_isinstance((str, None), name=name) if name is None: @@ -124,15 +128,16 @@ def get_cmap(name=None, lut=None): """ Get a colormap instance, defaulting to rc values if *name* is None. - Colormaps added with :func:`register_cmap` take precedence over - built-in colormaps. + Colormaps added with :func:`register_cmap` with the same name as + built-in colormaps will replace them. Parameters ---------- name : `matplotlib.colors.Colormap` or str or None, default: None - If a `.Colormap` instance, it will be returned. Otherwise, the name of - a colormap known to Matplotlib, which will be resampled by *lut*. The - default, None, means :rc:`image.cmap`. + If a `.Colormap` instance, a copy of it will be returned. + Otherwise, the name of a colormap known to Matplotlib, which will + be resampled by *lut*. A copy of the requested Colormap is always + returned. The default, None, means :rc:`image.cmap`. lut : int or None, default: None If *name* is not already a Colormap instance and *lut* is not None, the colormap will be resampled to have *lut* entries in the lookup table. @@ -140,10 +145,10 @@ def get_cmap(name=None, lut=None): if name is None: name = mpl.rcParams['image.cmap'] if isinstance(name, colors.Colormap): - return name + return copy.copy(name) cbook._check_in_list(sorted(cmap_d), name=name) if lut is None: - return cmap_d[name] + return copy.copy(cmap_d[name]) else: return cmap_d[name]._resample(lut) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 86e369f9950a..94014bce5d82 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -601,6 +601,18 @@ def __copy__(self): cmapobject._lut = np.copy(self._lut) return cmapobject + def __eq__(self, other): + if isinstance(other, Colormap): + # To compare lookup tables the Colormaps have to be initialized + if not self._isinit: + self._init() + if not other._isinit: + other._init() + if self._lut.shape != other._lut.shape: + return False + return np.all(self._lut == other._lut) + return False + def set_bad(self, color='k', alpha=None): """Set the color for masked values.""" self._rgba_bad = to_rgba(color, alpha) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 78f2256a3299..ec0d21f5fb2e 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -61,7 +61,7 @@ def test_resample(): def test_register_cmap(): - new_cm = copy.copy(plt.cm.viridis) + new_cm = plt.get_cmap('viridis') cm.register_cmap('viridis2', new_cm) assert plt.get_cmap('viridis2') == new_cm @@ -70,6 +70,18 @@ def test_register_cmap(): cm.register_cmap() +def test_colormap_builtin_immutable(): + new_cm = plt.get_cmap('viridis') + new_cm.set_over('b') + # Make sure that this didn't mess with the original viridis cmap + assert new_cm != plt.get_cmap('viridis') + + # Also test that pyplot access doesn't mess the original up + new_cm = plt.cm.viridis + new_cm.set_over('b') + assert new_cm != plt.get_cmap('viridis') + + def test_colormap_copy(): cm = plt.cm.Reds cm_copy = copy.copy(cm) From 56fc4efddc0df3d87d4b0c76702f3d7537a9be07 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sun, 29 Mar 2020 10:30:05 -0600 Subject: [PATCH 2/2] Adding a deprecation warning period for get_cmap(). --- doc/api/next_api_changes/behaviour.rst | 6 +++--- lib/matplotlib/cm.py | 28 +++++++++++++++++++------- lib/matplotlib/colors.py | 7 +++++++ lib/matplotlib/tests/test_colors.py | 18 +++++++++++++++++ 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/doc/api/next_api_changes/behaviour.rst b/doc/api/next_api_changes/behaviour.rst index 58c53c274467..a49528d84fe3 100644 --- a/doc/api/next_api_changes/behaviour.rst +++ b/doc/api/next_api_changes/behaviour.rst @@ -147,9 +147,9 @@ The parameter ``s`` to `.Axes.annotate` and `.pyplot.annotate` is renamed to The old parameter name remains supported, but support for it will be dropped in a future Matplotlib release. -`pyplot.get_cmap()` now returns a copy of the colormap -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Previously, calling ``.pyplot.get_cmap()`` would return a pointer to +``get_cmap()`` now returns a copy of the colormap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Previously, calling ``get_cmap()`` would return the built-in Colormap. If you made modifications to that colormap, the changes would be propagated in the global state. This function now returns a copy of all registered colormaps to keep the built-in diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index d1e67c347d1c..d872254af3c9 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -71,8 +71,8 @@ def _gen_cmap_d(): cmap_d = _gen_cmap_d() -locals().update(copy.deepcopy(cmap_d)) - +# locals().update(copy.deepcopy(cmap_d)) +locals().update(cmap_d) # Continue with definitions ... @@ -109,6 +109,8 @@ def register_cmap(name=None, cmap=None, data=None, lut=None): "Colormap") from err if isinstance(cmap, colors.Colormap): cmap_d[name] = cmap + # We are overriding the global state, so reinitialize this. + cmap._global_changed = False return if lut is not None or data is not None: cbook.warn_deprecated( @@ -122,6 +124,7 @@ def register_cmap(name=None, cmap=None, data=None, lut=None): lut = mpl.rcParams['image.lut'] cmap = colors.LinearSegmentedColormap(name, data, lut) cmap_d[name] = cmap + cmap._global_changed = False def get_cmap(name=None, lut=None): @@ -134,10 +137,11 @@ def get_cmap(name=None, lut=None): Parameters ---------- name : `matplotlib.colors.Colormap` or str or None, default: None - If a `.Colormap` instance, a copy of it will be returned. + If a `.Colormap` instance, it will be returned. Otherwise, the name of a colormap known to Matplotlib, which will - be resampled by *lut*. A copy of the requested Colormap is always - returned. The default, None, means :rc:`image.cmap`. + be resampled by *lut*. Currently, this returns the global colormap + instance which is deprecated. In Matplotlib 4, a copy of the requested + Colormap will be returned. The default, None, means :rc:`image.cmap`. lut : int or None, default: None If *name* is not already a Colormap instance and *lut* is not None, the colormap will be resampled to have *lut* entries in the lookup table. @@ -145,10 +149,20 @@ def get_cmap(name=None, lut=None): if name is None: name = mpl.rcParams['image.cmap'] if isinstance(name, colors.Colormap): - return copy.copy(name) + return name cbook._check_in_list(sorted(cmap_d), name=name) if lut is None: - return copy.copy(cmap_d[name]) + if cmap_d[name]._global_changed: + cbook.warn_deprecated( + "3.3", + message="The colormap requested has had the global state " + "changed without being registered. Accessing a " + "colormap in this way has been deprecated. " + "Please register the colormap using " + "plt.register_cmap() before requesting " + "the modified colormap.") + return cmap_d[name] + # return copy.copy(cmap_d[name]) else: return cmap_d[name]._resample(lut) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 94014bce5d82..f0acc55828a8 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -515,6 +515,8 @@ def __init__(self, name, N=256): self._i_over = self.N + 1 self._i_bad = self.N + 2 self._isinit = False + # This is to aid in deprecation transition for v3.3 + self._global_changed = False #: When this colormap exists on a scalar mappable and colorbar_extend #: is not False, colorbar creation will pick up ``colorbar_extend`` as @@ -599,6 +601,7 @@ def __copy__(self): cmapobject.__dict__.update(self.__dict__) if self._isinit: cmapobject._lut = np.copy(self._lut) + cmapobject._global_changed = False return cmapobject def __eq__(self, other): @@ -618,6 +621,7 @@ def set_bad(self, color='k', alpha=None): self._rgba_bad = to_rgba(color, alpha) if self._isinit: self._set_extremes() + self._global_changed = True def set_under(self, color='k', alpha=None): """ @@ -626,6 +630,7 @@ def set_under(self, color='k', alpha=None): self._rgba_under = to_rgba(color, alpha) if self._isinit: self._set_extremes() + self._global_changed = True def set_over(self, color='k', alpha=None): """ @@ -634,6 +639,7 @@ def set_over(self, color='k', alpha=None): self._rgba_over = to_rgba(color, alpha) if self._isinit: self._set_extremes() + self._global_changed = True def _set_extremes(self): if self._rgba_under: @@ -2110,6 +2116,7 @@ def from_levels_and_colors(levels, colors, extend='neither'): cmap.set_over('none') cmap.colorbar_extend = extend + cmap._global_changed = False norm = BoundaryNorm(levels, ncolors=n_data_colors) return cmap, norm diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index ec0d21f5fb2e..a38c0f17ae4a 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -70,6 +70,8 @@ def test_register_cmap(): cm.register_cmap() +@pytest.mark.skipif(matplotlib.__version__[0] < "4", + reason="This test modifies the global state of colormaps.") def test_colormap_builtin_immutable(): new_cm = plt.get_cmap('viridis') new_cm.set_over('b') @@ -82,6 +84,22 @@ def test_colormap_builtin_immutable(): assert new_cm != plt.get_cmap('viridis') +def test_colormap_builtin_immutable_warn(): + new_cm = plt.get_cmap('viridis') + # Store the old value so we don't override the state later on. + orig_cmap = copy.copy(new_cm) + with pytest.warns(cbook.MatplotlibDeprecationWarning, + match="The colormap requested has had the global"): + new_cm.set_under('k') + # This should warn now because we've modified the global state + # without registering it + plt.get_cmap('viridis') + + # Test that re-registering the original cmap clears the warning + plt.register_cmap(cmap=orig_cmap) + plt.get_cmap('viridis') + + def test_colormap_copy(): cm = plt.cm.Reds cm_copy = copy.copy(cm)