From 0abe0ce2f2748d1d0383154d045da3609a4b871b Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 3 Feb 2022 23:22:50 +0100 Subject: [PATCH] Add a registry for color sequences Color sequences are simply lists of colors, that we store by name in a registry. The registry is modelled similar to the ColormapRegistry to 1) support immutable builtin color sequences and 2) to return copies so that one cannot mess with the global definition of the color sequence through an obtained instance. For now, I've made the sequences used for `ListedColormap`s available as builtin sequences, but that's open for discussion. More usage documentation should be added in the color examples and/or tutorials, but I'll wait with that till after the general approval of the structure and API. One common use case will be ``` plt.rc_params['axes.prop_cycle'] = plt.cycler(color=plt.color_sequences['Pastel1') ``` Co-authored-by: Elliott Sales de Andrade --- doc/api/colors_api.rst | 35 +++++-- doc/api/matplotlib_configuration_api.rst | 7 +- doc/api/pyplot_summary.rst | 3 + lib/matplotlib/__init__.py | 1 + lib/matplotlib/colors.py | 111 ++++++++++++++++++++++- lib/matplotlib/pyplot.py | 1 + lib/matplotlib/tests/test_colors.py | 40 ++++++++ 7 files changed, 185 insertions(+), 13 deletions(-) diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 44f8cca303fd..970986ff4438 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -14,27 +14,44 @@ :no-members: :no-inherited-members: -Classes -------- +Color norms +----------- .. autosummary:: :toctree: _as_gen/ :template: autosummary.rst + Normalize + NoNorm AsinhNorm BoundaryNorm - Colormap CenteredNorm - LightSource - LinearSegmentedColormap - ListedColormap + FuncNorm LogNorm - NoNorm - Normalize PowerNorm SymLogNorm TwoSlopeNorm - FuncNorm + +Colormaps +--------- + +.. autosummary:: + :toctree: _as_gen/ + :template: autosummary.rst + + Colormap + LinearSegmentedColormap + ListedColormap + +Other classes +------------- + +.. autosummary:: + :toctree: _as_gen/ + :template: autosummary.rst + + ColorSequenceRegistry + LightSource Functions --------- diff --git a/doc/api/matplotlib_configuration_api.rst b/doc/api/matplotlib_configuration_api.rst index 3636c45d0c71..c301149b8050 100644 --- a/doc/api/matplotlib_configuration_api.rst +++ b/doc/api/matplotlib_configuration_api.rst @@ -52,12 +52,15 @@ Logging .. autofunction:: set_loglevel -Colormaps -========= +Colormaps and color sequences +============================= .. autodata:: colormaps :no-value: +.. autodata:: color_sequences + :no-value: + Miscellaneous ============= diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index 8d18c8b67e3e..30454486f14a 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -31,3 +31,6 @@ For a more in-depth look at colormaps, see the .. autodata:: colormaps :no-value: + +.. autodata:: color_sequences + :no-value: diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 2a495a91eb0d..7e8f6efa9af4 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1455,3 +1455,4 @@ def inner(ax, *args, data=None, **kwargs): # workaround: we must defer colormaps import to after loading rcParams, because # colormap creation depends on rcParams from matplotlib.cm import _colormaps as colormaps +from matplotlib.colors import _color_sequences as color_sequences diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 2e519149527f..ed051e304405 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -40,7 +40,7 @@ """ import base64 -from collections.abc import Sized, Sequence +from collections.abc import Sized, Sequence, Mapping import copy import functools import importlib @@ -54,7 +54,7 @@ import matplotlib as mpl import numpy as np -from matplotlib import _api, cbook, scale +from matplotlib import _api, _cm, cbook, scale from ._color_data import BASE_COLORS, TABLEAU_COLORS, CSS4_COLORS, XKCD_COLORS @@ -94,6 +94,113 @@ def get_named_colors_mapping(): return _colors_full_map +class ColorSequenceRegistry(Mapping): + r""" + Container for sequences of colors that are known to Matplotlib by name. + + The universal registry instance is `matplotlib.color_sequences`. There + should be no need for users to instantiate `.ColorSequenceRegistry` + themselves. + + Read access uses a dict-like interface mapping names to lists of colors:: + + import matplotlib as mpl + cmap = mpl.color_sequences['tab10'] + + The returned lists are copies, so that their modification does not change + the global definition of the color sequence. + + Additional color sequences can be added via + `.ColorSequenceRegistry.register`:: + + mpl.color_sequences.register('rgb', ['r', 'g', 'b']) + """ + + _BUILTIN_COLOR_SEQUENCES = { + 'tab10': _cm._tab10_data, + 'tab20': _cm._tab20_data, + 'tab20b': _cm._tab20b_data, + 'tab20c': _cm._tab20c_data, + 'Pastel1': _cm._Pastel1_data, + 'Pastel2': _cm._Pastel2_data, + 'Paired': _cm._Paired_data, + 'Accent': _cm._Accent_data, + 'Dark2': _cm._Dark2_data, + 'Set1': _cm._Set1_data, + 'Set2': _cm._Set1_data, + 'Set3': _cm._Set1_data, + } + + def __init__(self): + self._color_sequences = {**self._BUILTIN_COLOR_SEQUENCES} + + def __getitem__(self, item): + try: + return list(self._color_sequences[item]) + except KeyError: + raise KeyError(f"{item!r} is not a known color sequence name") + + def __iter__(self): + return iter(self._color_sequences) + + def __len__(self): + return len(self._color_sequences) + + def __str__(self): + return ('ColorSequenceRegistry; available colormaps:\n' + + ', '.join(f"'{name}'" for name in self)) + + def register(self, name, color_list): + """ + Register a new color sequence. + + The color sequence registry stores a copy of the given *color_list*, so + that future changes to the original list do not affect the registered + color sequence. Think of this as the registry taking a snapshot + of *color_list* at registration. + + Parameters + ---------- + name : str + The name for the color sequence. + + color_list : list of colors + An iterable returning valid Matplotlib colors when iterating over. + Note however that the returned color sequence will always be a + list regardless of the input type. + + """ + if name in self._BUILTIN_COLOR_SEQUENCES: + raise ValueError(f"{name!r} is a reserved name for a builtin " + "color sequence") + + color_list = list(color_list) # force copy and coerce type to list + for color in color_list: + try: + to_rgba(color) + except ValueError: + raise ValueError( + f"{color!r} is not a valid color specification") + + self._color_sequences[name] = color_list + + def unregister(self, name): + """ + Remove a sequence from the registry. + + You cannot remove built-in color sequences. + + If the name is not registered, returns with no error. + """ + if name in self._BUILTIN_COLOR_SEQUENCES: + raise ValueError( + f"Cannot unregister builtin color sequence {name!r}") + self._color_sequences.pop(name, None) + + +_color_sequences = ColorSequenceRegistry() + + def _sanitize_extrema(ex): if ex is None: return ex diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 577956bedce6..a06daab90a7d 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -71,6 +71,7 @@ from matplotlib import cm from matplotlib.cm import _colormaps as colormaps, get_cmap, register_cmap +from matplotlib.colors import _color_sequences as color_sequences import numpy as np diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 80f8b663bdce..c9aed221108e 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1510,3 +1510,43 @@ def test_make_norm_from_scale_name(): logitnorm = mcolors.make_norm_from_scale( mscale.LogitScale, mcolors.Normalize) assert logitnorm.__name__ == logitnorm.__qualname__ == "LogitScaleNorm" + + +def test_color_sequences(): + # basic access + assert plt.color_sequences is matplotlib.color_sequences # same registry + assert list(plt.color_sequences) == [ + 'tab10', 'tab20', 'tab20b', 'tab20c', 'Pastel1', 'Pastel2', 'Paired', + 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3'] + assert len(plt.color_sequences['tab10']) == 10 + assert len(plt.color_sequences['tab20']) == 20 + + tab_colors = [ + 'tab:blue', 'tab:orange', 'tab:green', 'tab:red', 'tab:purple', + 'tab:brown', 'tab:pink', 'tab:gray', 'tab:olive', 'tab:cyan'] + for seq_color, tab_color in zip(plt.color_sequences['tab10'], tab_colors): + assert mcolors.same_color(seq_color, tab_color) + + # registering + with pytest.raises(ValueError, match="reserved name"): + plt.color_sequences.register('tab10', ['r', 'g', 'b']) + with pytest.raises(ValueError, match="not a valid color specification"): + plt.color_sequences.register('invalid', ['not a color']) + + rgb_colors = ['r', 'g', 'b'] + plt.color_sequences.register('rgb', rgb_colors) + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + # should not affect the registered sequence because input is copied + rgb_colors.append('c') + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + # should not affect the registered sequence because returned list is a copy + plt.color_sequences['rgb'].append('c') + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + + # unregister + plt.color_sequences.unregister('rgb') + with pytest.raises(KeyError): + plt.color_sequences['rgb'] # rgb is gone + plt.color_sequences.unregister('rgb') # multiple unregisters are ok + with pytest.raises(ValueError, match="Cannot unregister builtin"): + plt.color_sequences.unregister('tab10')