diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py index 251ca83a426a..c393fa7df4bf 100644 --- a/lib/matplotlib/_api/__init__.py +++ b/lib/matplotlib/_api/__init__.py @@ -107,7 +107,37 @@ def type_name(tp): type_name(type(v)))) -def check_in_list(values, /, *, _print_supported_values=True, **kwargs): +def list_suggestion_error_msg(name, potential, values): + """ + Generate an error message that a potential setting is not an acceptable value. + + If the acceptable values are all strings, and sufficiently large, then add just a + few suggestions to the end of the message. Otherwise list the supported values. + + Parameters + ---------- + name : str + The name of the setting, keyword argument, etc. to generate the message for. + potential + The potential value from the user that is not a valid choice. + values : iterable + Sequence of values to check on. + """ + if len(values) > 5 and all(isinstance(v, str) for v in [potential, *values]): + best = difflib.get_close_matches(potential, values, cutoff=0.5) + match len(best): + case 0: + suggestion = "" + case 1: + suggestion = f" Did you mean: {best[0]!r}?" + case _: + suggestion = f" Did you mean one of: {', '.join(map(repr, best))}?" + else: + suggestion = f" Supported values are {', '.join(map(repr, values))}" + return f"{potential!r} is not a valid value for {name}.{suggestion}" + + +def check_in_list(values, /, **kwargs): """ For each *key, value* pair in *kwargs*, check that *value* is in *values*; if not, raise an appropriate ValueError. @@ -119,8 +149,6 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): Note: All values must support == comparisons. This means in particular the entries must not be numpy arrays. - _print_supported_values : bool, default: True - Whether to print *values* when raising ValueError. **kwargs : dict *key, value* pairs as keyword arguments to find in *values*. @@ -148,10 +176,7 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): # the individual `val == values[i]` ValueError surface. exists = False if not exists: - msg = f"{val!r} is not a valid value for {key}" - if _print_supported_values: - msg += f"; supported values are {', '.join(map(repr, values))}" - raise ValueError(msg) + raise ValueError(list_suggestion_error_msg(key, val, values)) def check_shape(shape, /, **kwargs): @@ -210,14 +235,7 @@ def getitem_checked(mapping, /, _error_cls=ValueError, **kwargs): try: return mapping[v] except KeyError: - if len(mapping) > 5: - if len(best := difflib.get_close_matches(v, mapping.keys(), cutoff=0.5)): - suggestion = f"Did you mean one of {best}?" - else: - suggestion = "" - else: - suggestion = f"Supported values are {', '.join(map(repr, mapping))}" - raise _error_cls(f"{v!r} is not a valid value for {k}. {suggestion}") from None + raise _error_cls(list_suggestion_error_msg(k, v, mapping.keys())) from None def caching_module_getattr(cls): diff --git a/lib/matplotlib/_api/__init__.pyi b/lib/matplotlib/_api/__init__.pyi index 5bc8d4a150a1..0bcce210634f 100644 --- a/lib/matplotlib/_api/__init__.pyi +++ b/lib/matplotlib/_api/__init__.pyi @@ -38,9 +38,8 @@ class classproperty(Any): def check_isinstance( types: type | tuple[type | None, ...], /, **kwargs: Any ) -> None: ... -def check_in_list( - values: Sequence[Any], /, *, _print_supported_values: bool = ..., **kwargs: Any -) -> None: ... +def list_suggestion_error_msg(name: str, potential: Any, values: Sequence[Any]) -> str: ... +def check_in_list(values: Sequence[Any], /, **kwargs: Any) -> None: ... def check_shape(shape: tuple[int | None, ...], /, **kwargs: NDArray) -> None: ... def getitem_checked( mapping: Mapping[Any, _T], /, _error_cls: type[Exception], **kwargs: Any diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index c9f2d292f78f..7146dc28fcc9 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -117,6 +117,7 @@ def list(self): def __getitem__(self, name): """Get an available writer class from its name.""" + _api.check_in_list(self._registered, writer=name) if self.is_available(name): return self._registered[name] raise RuntimeError(f"Requested MovieWriter ({name}) not available") diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index d741bc58574f..095b93ccfe85 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -826,10 +826,8 @@ def _ensure_cmap(cmap, accept_multivariate=False): # this error message is a variant of _api.check_in_list but gives # additional hints as to how to access multivariate colormaps - raise ValueError(f"{cmap!r} is not a valid value for cmap" - "; supported values for scalar colormaps are " - f"{', '.join(map(repr, sorted(mpl.colormaps)))}\n" - "See `matplotlib.bivar_colormaps()` and" + raise ValueError(_api.list_suggestion_error_msg('cmap', cmap, mpl.colormaps) + + "\nSee `matplotlib.bivar_colormaps()` and" " `matplotlib.multivar_colormaps()` for" " bivariate and multivariate colormaps") diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index b04b8c6ca7a3..02ea1d3723e9 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -142,10 +142,8 @@ 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") + return list(_api.getitem_checked(self._color_sequences, _error_cls=KeyError, + sequence_name=item)) def __iter__(self): return iter(self._color_sequences) diff --git a/lib/matplotlib/projections/__init__.py b/lib/matplotlib/projections/__init__.py index f7b46192a84e..294e5542d706 100644 --- a/lib/matplotlib/projections/__init__.py +++ b/lib/matplotlib/projections/__init__.py @@ -52,7 +52,7 @@ `matplotlib.projections.polar` may also be of interest. """ -from .. import axes, _docstring +from .. import _api, axes, _docstring from .geo import AitoffAxes, HammerAxes, LambertAxes, MollweideAxes from .polar import PolarAxes @@ -78,9 +78,10 @@ def register(self, *projections): name = projection.name self._all_projection_types[name] = projection - def get_projection_class(self, name): + def get_projection_class(self, name, _error_cls=KeyError): """Get a projection class from its *name*.""" - return self._all_projection_types[name] + return _api.getitem_checked(self._all_projection_types, _error_cls=_error_cls, + projection=name) def get_projection_names(self): """Return the names of all projections currently registered.""" @@ -116,10 +117,7 @@ def get_projection_class(projection=None): if projection is None: projection = 'rectilinear' - try: - return projection_registry.get_projection_class(projection) - except KeyError as err: - raise ValueError("Unknown projection %r" % projection) from err + return projection_registry.get_projection_class(projection, _error_cls=ValueError) get_projection_names = projection_registry.get_projection_names diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index 4ca5c1220972..a00adcdf95f0 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -44,6 +44,14 @@ def animate(i): return klass(fig=fig, func=animate, init_func=init, **kwargs) +def test_invalid_writer(): + # Note, this triggers for Animation.save as well, but this is a lighter test. + with pytest.raises(ValueError, + match=r"'pllow' is not a valid value for writer\. " + r"Did you mean: 'pillow'\?"): + animation.writers['pllow'] + + class NullMovieWriter(animation.AbstractMovieWriter): """ A minimal MovieWriter. It doesn't actually write anything. diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index f006c624f8d7..290035dc564d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3338,6 +3338,13 @@ def test_scatter_c_facecolor_warning_integration(c, facecolor): ax.scatter(x, y, c=c, facecolor=facecolor) +def test_invalid_projection(): + with pytest.raises(ValueError, + match=r"'aitof' is not a valid value for projection\. " + r"Did you mean: 'aitoff'\?"): + plt.subplots(subplot_kw={'projection': 'aitof'}) + + def test_as_mpl_axes_api(): # tests the _as_mpl_axes api class Polar: diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 98b46e08a171..5bc1f8aea973 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1882,10 +1882,15 @@ def test_close_error_name(): with pytest.raises( KeyError, match=( - "'grays' is not a valid value for colormap. " - "Did you mean one of ['gray', 'Grays', 'gray_r']?" - )): + r"'grays' is not a valid value for colormap\. " + r"Did you mean one of: 'gray', 'Grays', 'gray_r'\?")): matplotlib.colormaps["grays"] + with pytest.raises( + KeyError, + match=( + "'set' is not a valid value for sequence_name. " + "Did you mean one of: 'Set3', 'Set2', 'Set1'?")): + matplotlib.color_sequences["set"] def test_multi_norm_creation(): diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py index 14110209fa15..25ebb965d643 100644 --- a/lib/matplotlib/tests/test_style.py +++ b/lib/matplotlib/tests/test_style.py @@ -150,9 +150,8 @@ def test_context_with_badparam(): with style.context({PARAM: other_value}): assert mpl.rcParams[PARAM] == other_value x = style.context({PARAM: original_value, 'badparam': None}) - with pytest.raises( - KeyError, match="\'badparam\' is not a valid value for rcParam. " - ): + with pytest.raises(KeyError, + match=r"'badparam' is not a valid value for rcParam\."): with x: pass assert mpl.rcParams[PARAM] == other_value diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 2b4351a5cfbb..4655a8b5ebc7 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -1072,8 +1072,8 @@ def test_scale_swapping(fig_test, fig_ref): def test_offset_copy_errors(): with pytest.raises(ValueError, - match="'fontsize' is not a valid value for units;" - " supported values are 'dots', 'points', 'inches'"): + match="'fontsize' is not a valid value for units. " + "Supported values are 'dots', 'points', 'inches'"): mtransforms.offset_copy(None, units='fontsize') with pytest.raises(ValueError,