diff --git a/doc/api/next_api_changes/behaviour.rst b/doc/api/next_api_changes/behaviour.rst index ff7fdc3695e4..a49528d84fe3 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. +``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 +colormaps untouched. + diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index fc5dacbf7cb8..d872254af3c9 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,9 +71,9 @@ def _gen_cmap_d(): cmap_d = _gen_cmap_d() +# locals().update(copy.deepcopy(cmap_d)) locals().update(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: @@ -105,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( @@ -118,21 +124,24 @@ 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): """ 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, it will be returned. + Otherwise, the name of a colormap known to Matplotlib, which will + 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. @@ -143,7 +152,17 @@ def get_cmap(name=None, lut=None): return name cbook._check_in_list(sorted(cmap_d), name=name) if lut is None: + 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 86e369f9950a..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,13 +601,27 @@ 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): + 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) if self._isinit: self._set_extremes() + self._global_changed = True def set_under(self, color='k', alpha=None): """ @@ -614,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): """ @@ -622,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: @@ -2098,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 78f2256a3299..a38c0f17ae4a 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,36 @@ 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') + # 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_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)