diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index ae387927f..39e55a896 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -4120,9 +4120,30 @@ def _parse_cmap( # Parse keyword args cmap_kw = cmap_kw or {} norm_kw = norm_kw or {} - # If norm is given we use it to set vmin and vmax - if (vmin is not None or vmax is not None) and norm is not None: - raise ValueError("If 'norm' is given, 'vmin' and 'vmax' must not be set.") + # Tuple/list specs like ``('linear', 0, 1)`` pack positional args for + # ``constructor.Norm``. Build the Normalize now so downstream code can + # treat it uniformly with pre-constructed Normalize instances instead + # of risking a positional/kwarg collision when vmin/vmax are forwarded. + if ( + np.iterable(norm) + and not isinstance(norm, str) + and not isinstance(norm, mcolors.Normalize) + and len(norm) > 1 + ): + norm = constructor.Norm(norm, **norm_kw) + norm_kw = {} + # A ``Normalize`` instance already carries vmin/vmax, so combining it + # with explicit vmin/vmax is ambiguous. String / single-element list or + # tuple specs are just names for ``constructor.Norm`` and accept + # vmin/vmax as kwargs. + if (vmin is not None or vmax is not None) and isinstance( + norm, mcolors.Normalize + ): + raise ValueError( + "If 'norm' is a Normalize instance, 'vmin' and 'vmax' must not be " + "set. Pass them through the Normalize constructor, or specify " + "'norm' as a string / list / tuple to let vmin and vmax apply." + ) if isinstance(norm, mcolors.Normalize): vmin = norm.vmin vmax = norm.vmax diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index af5866dd8..cbeb410c2 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -1226,3 +1226,49 @@ def test_colorbar_span_position_matches_target_rows(): ), f"Panel y0={panel_pos.y0:.3f} != row1 y0={row1_pos.y0:.3f}" # Sanity: panel must be taller than a single row assert panel_pos.height > row0_pos.height * 1.5 + + +@pytest.mark.parametrize( + "norm", + ["linear", ["linear"], ("linear",)], +) +def test_colorbar_norm_str_with_limits(norm): + """ + Should allow to pass vmin or vmax when we are passing a norm specification + as a string, list, or tuple (per the ``constructor.Norm`` contract). + """ + data = np.random.rand(10, 10) + fig, ax = uplt.subplots() + cm = ax.pcolormesh(data, vmin=0.1, norm=norm, vmax=1) + assert cm.norm.vmin == pytest.approx(0.1) + assert cm.norm.vmax == pytest.approx(1) + + +@pytest.mark.parametrize( + "norm", [("linear", 0.1, 1), ["linear", 0.1, 1], ("linear", 0.1, 1, False)] +) +def test_colorbar_norm_tuple_positional_limits(norm): + """ + Tuple / list form ``(name, vmin, vmax)`` should construct the normalizer + with the positional arguments and not collide with implicit vmin/vmax + kwargs when the user does not separately specify them. + """ + data = np.random.rand(10, 10) + fig, ax = uplt.subplots() + cm = ax.pcolormesh(data, norm=norm) + assert cm.norm.vmin == pytest.approx(0.1) + assert cm.norm.vmax == pytest.approx(1) + + +def test_colorbar_norm_with_limits(): + """ " + Should allow to pass vmin or vmax when we are passing a str formatter + """ + data = np.random.rand(10, 10) + fig = None + with pytest.raises(ValueError): + fig, ax = uplt.subplots() + ax.pcolormesh( + data, vmin=0, norm=uplt.colors.mcolors.Normalize(vmin=0, vmax=1), vmax=1 + ) + return fig