diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 49595296685..2f791cede68 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -6,6 +6,7 @@ import contextlib import copy from datetime import datetime, timedelta +from difflib import get_close_matches from itertools import cycle import re @@ -508,6 +509,33 @@ def plot_kwargs(data): return kwargs +class ValidationMixin: + """Provides validation of attribute names when set by user.""" + + def __setattr__(self, name, value): + """Set only permitted attributes.""" + allowlist = ['ax', + 'data', + 'handle', + 'notify_change', + 'panel' + ] + + allowlist.extend(self.trait_names()) + if name in allowlist or name.startswith('_'): + super().__setattr__(name, value) + else: + closest = get_close_matches(name, allowlist, n=1) + if closest: + alt = closest[0] + suggest = f" Perhaps you meant '{alt}'?" + else: + suggest = '' + obj = self.__class__ + msg = f"'{name}' is not a valid attribute for {obj}." + suggest + raise AttributeError(msg) + + class MetPyHasTraits(HasTraits): """Provides modification layer on HasTraits for declarative classes.""" @@ -524,7 +552,7 @@ class Panel(MetPyHasTraits): @exporter.export -class PanelContainer(MetPyHasTraits): +class PanelContainer(MetPyHasTraits, ValidationMixin): """Collects panels and set complete figure related settings (e.g., size).""" size = Union([Tuple(Union([Int(), Float()]), Union([Int(), Float()])), @@ -600,7 +628,7 @@ def copy(self): @exporter.export -class MapPanel(Panel): +class MapPanel(Panel, ValidationMixin): """Set figure related elements for an individual panel. Parameters that need to be set include collecting all plotting types @@ -1243,7 +1271,7 @@ class ColorfillTraits(MetPyHasTraits): @exporter.export -class ImagePlot(PlotScalar, ColorfillTraits): +class ImagePlot(PlotScalar, ColorfillTraits, ValidationMixin): """Make raster image using `~matplotlib.pyplot.imshow` for satellite or colored image.""" @observe('colormap', 'image_range') @@ -1298,7 +1326,7 @@ def _build(self): @exporter.export -class ContourPlot(PlotScalar, ContourTraits): +class ContourPlot(PlotScalar, ContourTraits, ValidationMixin): """Make contour plots by defining specific traits.""" linecolor = Unicode('black') @@ -1347,7 +1375,7 @@ def _build(self): @exporter.export -class FilledContourPlot(PlotScalar, ColorfillTraits, ContourTraits): +class FilledContourPlot(PlotScalar, ColorfillTraits, ContourTraits, ValidationMixin): """Make color-filled contours plots by defining appropriate traits.""" @observe('contours', 'colorbar', 'colormap') @@ -1519,7 +1547,7 @@ def draw(self): @exporter.export -class BarbPlot(PlotVector): +class BarbPlot(PlotVector, ValidationMixin): """Make plots of wind barbs on a map with traits to refine the look of plotted elements.""" barblength = Float(default_value=7) @@ -1555,7 +1583,7 @@ def _build(self): @exporter.export -class PlotObs(MetPyHasTraits): +class PlotObs(MetPyHasTraits, ValidationMixin): """The highest level class related to plotting observed surface and upperair data. This class collects all common methods no matter whether plotting a upper-level or diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index f1deadcd276..723ef96e039 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -1713,3 +1713,21 @@ def test_drop_traitlets_dir(): assert not dir(plot_obj())[0].startswith('_') assert 'cross_validation_lock' in dir(plot_obj) assert 'cross_validation_lock' not in dir(plot_obj()) + + +@needs_cartopy +def test_attribute_error_suggest(): + """Test that a mistyped attribute name raises an exception with fix.""" + with pytest.raises(AttributeError) as excinfo: + panel = MapPanel() + panel.pots = [] + assert "Perhaps you meant 'plots'?" in str(excinfo.value) + + +@needs_cartopy +def test_attribute_error_no_suggest(): + """Test that a mistyped attribute name raises an exception w/o a fix.""" + with pytest.raises(AttributeError) as excinfo: + panel = MapPanel() + panel.galaxy = 'Andromeda' + assert 'Perhaps you meant' not in str(excinfo.value)