From 92bbb00f6b6dc3945c7a77093a9520a941008d03 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 16:59:23 +0200 Subject: [PATCH] feat: add register_mpl function to register colormaps with matplotlib --- docs/colormaps.md | 24 +++++++++++++++++ src/cmap/__init__.py | 3 ++- src/cmap/_util.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 25 ++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/colormaps.md b/docs/colormaps.md index 1b701eff8..633c1ac21 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -389,6 +389,30 @@ external visualization libraries. To that end, `cmap.Colormap` provides Returns an instance of [`pyqtgraph.ColorMap`](https://pyqtgraph.readthedocs.io/en/latest/api_reference/colormap.html#pyqtgraph.ColorMap) +### Using cmap names as matplotlib strings + +`cmap` does not register its colormaps with matplotlib on import. To use catalog +names as plain strings anywhere matplotlib accepts a colormap name, call [`cmap.register_mpl`][]: + +```python +import cmap +import matplotlib.pyplot as plt + +cmap.register_mpl("tol:*", "batlow") # names or fnmatch patterns +plt.imshow(data, cmap="tol:sunset") +plt.imshow(data, cmap="crameri:batlow_r") # reversed versions are registered too +``` + +Colormaps are registered under their canonical, fully-qualified name (lowercase, with +`_` in place of `-` or spaces: `Colormap(name).name`), so they never collide with +matplotlib's builtin names. + +!!!note "For library authors" + Please don't call `cmap.register_mpl("*")` on behalf of your users: it adds + 1300+ entries to matplotlib's global registry, visible to all code in the + session, and takes a second or more. Prefer registering the specific name(s) + you need. + ## Usage with pydantic `Colormap` can be used as a field type in diff --git a/src/cmap/__init__.py b/src/cmap/__init__.py index 359f24aff..57b40c7fd 100644 --- a/src/cmap/__init__.py +++ b/src/cmap/__init__.py @@ -11,7 +11,7 @@ from ._color import HSLA, HSVA, RGBA, RGBA8, Color, ColorLike from ._colormap import Colormap, ColormapLike, ColorStop, ColorStops -from ._util import to_mpl +from ._util import register_mpl, to_mpl if TYPE_CHECKING: from collections.abc import Iterator, Mapping @@ -77,5 +77,6 @@ def resolve(self, name: str) -> str: "ColorStops", "Colormap", "ColormapLike", + "register_mpl", "to_mpl", ] diff --git a/src/cmap/_util.py b/src/cmap/_util.py index c1d0279a5..313452340 100644 --- a/src/cmap/_util.py +++ b/src/cmap/_util.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from fnmatch import fnmatchcase from typing import TYPE_CHECKING, TypedDict, cast import numpy as np @@ -595,6 +596,68 @@ def to_mpl( return cm.to_matplotlib(N=N, gamma=gamma) +def register_mpl(*patterns: str) -> list[str]: + """Register colormaps from the cmap catalog with `matplotlib.colormaps`. + + Call this explicitly to make catalog names usable as strings anywhere + matplotlib accepts a colormap name. + + Colormaps are registered under their canonical, fully-qualified cmap name + (lowercase, with `_` instead of `-` or spaces; see `Colormap(name).name`), along + with a reversed `_r` version. Short names like "viridis" are never registered, so + as not to collide with matplotlib's colormaps. Names that are already registered + are left untouched, so it is safe to call this multiple times. + + Parameters + ---------- + *patterns : str + Colormap names or `fnmatch`-style patterns (e.g. `"tol:*"`) to register. + Any name accepted by `Colormap` works (including short names and aliases). + `"*"` registers the entire catalog, but be aware that matplotlib's + registration time grows with the size of its registry: this may take many + seconds. + + Returns + ------- + list[str] + The canonical names of the matched colormaps. + + Examples + -------- + >>> import cmap + >>> cmap.register_mpl("tol:*", "batlow") # doctest: +SKIP + >>> plt.imshow(data, cmap="crameri:batlow_r") # doctest: +SKIP + """ + import matplotlib as mpl + + from cmap import Colormap + + if not patterns: + raise TypeError("register_mpl() requires at least one name or pattern.") + catalog = Colormap.catalog() + norm = catalog._norm_name + resolved: set[str] = set() + for pattern in patterns: + matches = { + catalog[catalog.resolve(key)].qualified_name + for key in catalog + if fnmatchcase(norm(key), norm(pattern)) + } + if not matches: + raise KeyError(f"No colormaps in the cmap catalog match {pattern!r}.") + resolved |= matches + + # look up `mpl.colormaps` at call time: downstream libraries (e.g. UltraPlot) + # may have replaced it with their own registry. + existing = set(mpl.colormaps) + for name in resolved - existing: + mpl_cm = Colormap(name).to_mpl() + for cm in (mpl_cm, mpl_cm.reversed()): + if cm.name not in existing: + mpl.colormaps.register(cm) + return sorted(resolved) + + if __name__ == "__main__": # pragma: no cover import matplotlib.pyplot as plt diff --git a/tests/test_utils.py b/tests/test_utils.py index 7663ef07d..98bcf2dce 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,7 @@ except ImportError: MplFigure = None +import cmap from cmap import Colormap, _util CMAP_NAME = "viridis" @@ -78,3 +79,27 @@ def test_report() -> None: report2 = _util.report(Colormap("red")) assert isinstance(report2, dict) + + +def test_register_mpl() -> None: + mpl = pytest.importorskip("matplotlib") + before = set(mpl.colormaps) + try: + names = cmap.register_mpl("tol:*", "batlow", "colorbrewer:Blues") + assert "tol:bright" in names + assert {"crameri:batlow", "colorbrewer:blues"} <= set(names) + for name in names: + assert mpl.colormaps[name].name == name + assert f"{name}_r" in mpl.colormaps + assert "batlow" not in mpl.colormaps + + # idempotent + added = set(mpl.colormaps) - before + assert cmap.register_mpl("tol:*") == [n for n in names if n.startswith("tol")] + assert set(mpl.colormaps) - before == added + + with pytest.raises(KeyError, match="No colormaps"): + cmap.register_mpl("nope:*") + finally: + for name in set(mpl.colormaps) - before: + mpl.colormaps.unregister(name)