diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 9731a683bcb7..3b3c5191d80a 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -176,16 +176,14 @@ def _get_version(): return _version.version -@functools.lru_cache(None) -def __getattr__(name): - if name == "__version__": - return _get_version() - elif name == "__version_info__": - return _parse_to_version_info(__getattr__("__version__")) - elif name == "URL_REGEX": # module-level deprecation. - _api.warn_deprecated("3.5", name=name) - return re.compile(r'^http://|^https://|^ftp://|^file:') - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +@_api.caching_module_getattr +class __getattr__: + __version__ = property(lambda self: _get_version()) + __version_info__ = property( + lambda self: _parse_to_version_info(self.__version__)) + # module-level deprecations + URL_REGEX = _api.deprecated("3.5", obj_type="")(property( + lambda self: re.compile(r'^http://|^https://|^ftp://|^file:'))) def _check_versions(): diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py index 2e473c474526..43b25908832e 100644 --- a/lib/matplotlib/_api/__init__.py +++ b/lib/matplotlib/_api/__init__.py @@ -10,6 +10,7 @@ """ +import functools import itertools import re import sys @@ -189,6 +190,41 @@ def check_getitem(_mapping, **kwargs): .format(v, k, ', '.join(map(repr, mapping)))) from None +def caching_module_getattr(cls): + """ + Helper decorator for implementing module-level ``__getattr__`` as a class. + + This decorator must be used at the module toplevel as follows:: + + @caching_module_getattr + class __getattr__: # The class *must* be named ``__getattr__``. + @property # Only properties are taken into account. + def name(self): ... + + The ``__getattr__`` class will be replaced by a ``__getattr__`` + function such that trying to access ``name`` on the module will + resolve the corresponding property (which may be decorated e.g. with + ``_api.deprecated`` for deprecating module globals). The properties are + all implicitly cached. Moreover, a suitable AttributeError is generated + and raised if no property with the given name exists. + """ + + assert cls.__name__ == "__getattr__" + # Don't accidentally export cls dunders. + props = {name: prop for name, prop in vars(cls).items() + if isinstance(prop, property)} + instance = cls() + + @functools.lru_cache(None) + def __getattr__(name): + if name in props: + return props[name].__get__(instance) + raise AttributeError( + f"module {cls.__module__!r} has no attribute {name!r}") + + return __getattr__ + + def select_matching_signature(funcs, *args, **kwargs): """ Select and call the function that accepts ``*args, **kwargs``. diff --git a/lib/matplotlib/_api/deprecation.py b/lib/matplotlib/_api/deprecation.py index 1323103ff541..363bb5f7a023 100644 --- a/lib/matplotlib/_api/deprecation.py +++ b/lib/matplotlib/_api/deprecation.py @@ -150,13 +150,14 @@ def finalize(wrapper, new_doc): return obj elif isinstance(obj, (property, classproperty)): - obj_type = "attribute" + if obj_type is None: + obj_type = "attribute" func = None name = name or obj.fget.__name__ old_doc = obj.__doc__ class _deprecated_property(type(obj)): - def __get__(self, instance, owner): + def __get__(self, instance, owner=None): if instance is not None or owner is not None \ and isinstance(self, classproperty): emit_warning() diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index bf134f33963c..46b48c2200aa 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -37,11 +37,11 @@ Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()) -# module-level deprecations. -@functools.lru_cache(None) -def __getattr__(name): - if name == "cursord": - _api.warn_deprecated("3.5", name=name) +@_api.caching_module_getattr # module-level deprecations +class __getattr__: + @_api.deprecated("3.5", obj_type="") + @property + def cursord(self): try: new_cursor = functools.partial( Gdk.Cursor.new_from_name, Gdk.Display.get_default()) @@ -54,8 +54,6 @@ def __getattr__(name): } except TypeError as exc: return {} - else: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") # Placeholder diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 4ba85f505151..16b152a51689 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -41,25 +41,19 @@ PIXELS_PER_INCH = 75 -# module-level deprecations. -@functools.lru_cache(None) -def __getattr__(name): - if name == "IDLE_DELAY": - _api.warn_deprecated("3.1", name=name) - return 5 - elif name == "cursord": - _api.warn_deprecated("3.5", name=name) - return { # deprecated in Matplotlib 3.5. - cursors.MOVE: wx.CURSOR_HAND, - cursors.HAND: wx.CURSOR_HAND, - cursors.POINTER: wx.CURSOR_ARROW, - cursors.SELECT_REGION: wx.CURSOR_CROSS, - cursors.WAIT: wx.CURSOR_WAIT, - cursors.RESIZE_HORIZONTAL: wx.CURSOR_SIZEWE, - cursors.RESIZE_VERTICAL: wx.CURSOR_SIZENS, - } - else: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +@_api.caching_module_getattr # module-level deprecations +class __getattr__: + IDLE_DELAY = _api.deprecated("3.1", obj_type="", removal="3.6")(property( + lambda self: 5)) + cursord = _api.deprecated("3.5", obj_type="")(property(lambda self: { + cursors.MOVE: wx.CURSOR_HAND, + cursors.HAND: wx.CURSOR_HAND, + cursors.POINTER: wx.CURSOR_ARROW, + cursors.SELECT_REGION: wx.CURSOR_CROSS, + cursors.WAIT: wx.CURSOR_WAIT, + cursors.RESIZE_HORIZONTAL: wx.CURSOR_SIZEWE, + cursors.RESIZE_VERTICAL: wx.CURSOR_SIZENS, + })) def error_msg_wx(msg, parent=None): diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 055a944706ac..0af2f0f327d9 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -16,7 +16,6 @@ """ from collections.abc import Mapping, MutableMapping -import functools import numpy as np from numpy import ma @@ -27,14 +26,11 @@ from matplotlib._cm_listed import cmaps as cmaps_listed -# module-level deprecations. -@functools.lru_cache(None) -def __getattr__(name): - if name == "LUTSIZE": - _api.warn_deprecated("3.5", name=name, - alternative="rcParams['image.lut']") - return _LUTSIZE - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +@_api.caching_module_getattr # module-level deprecations +class __getattr__: + LUTSIZE = _api.deprecated( + "3.5", obj_type="", alternative="rcParams['image.lut']")( + property(lambda self: _LUTSIZE)) _LUTSIZE = mpl.rcParams['image.lut'] diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index a056bf69201d..459b14f6c5a7 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -12,7 +12,6 @@ """ import copy -import functools import logging import textwrap @@ -195,20 +194,14 @@ _colormap_kw_doc)) -# module-level deprecations. -@functools.lru_cache(None) -def __getattr__(name): - if name == "colorbar_doc": - _api.warn_deprecated("3.4", name=name) - return docstring.interpd.params["colorbar_doc"] - elif name == "colormap_kw_doc": - _api.warn_deprecated("3.4", name=name) - return _colormap_kw_doc - elif name == "make_axes_kw_doc": - _api.warn_deprecated("3.4", name=name) - return _make_axes_param_doc + _make_axes_other_param_doc - else: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +@_api.caching_module_getattr # module-level deprecations +class __getattr__: + colorbar_doc = _api.deprecated("3.4", obj_type="")(property( + lambda self: docstring.interpd.params["colorbar_doc"])) + colorbar_kw_doc = _api.deprecated("3.4", obj_type="")(property( + lambda self: _colormap_kw_doc)) + make_axes_kw_doc = _api.deprecated("3.4", obj_type="")(property( + lambda self: _make_axes_param_doc + _make_axes_other_param_doc)) def _set_ticks_on_axis_warn(*args, **kw): diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index 65afa0ca8005..85dcc8bc41fe 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -12,7 +12,6 @@ """ import contextlib -import functools import logging import os from pathlib import Path @@ -27,13 +26,10 @@ __all__ = ['use', 'context', 'available', 'library', 'reload_library'] -# module-level deprecations. -@functools.lru_cache(None) -def __getattr__(name): - if name == "STYLE_FILE_PATTERN": - _api.warn_deprecated("3.5", name=name) - return re.compile(r'([\S]+).%s$' % STYLE_EXTENSION) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +@_api.caching_module_getattr # module-level deprecations +class __getattr__: + STYLE_FILE_PATTERN = _api.deprecated("3.5", obj_type="")(property( + lambda self: re.compile(r'([\S]+).%s$' % STYLE_EXTENSION))) BASE_LIBRARY_PATH = os.path.join(mpl.get_data_path(), 'stylelib') diff --git a/lib/matplotlib/tests/test_getattr.py b/lib/matplotlib/tests/test_getattr.py new file mode 100644 index 000000000000..71556b5b4c71 --- /dev/null +++ b/lib/matplotlib/tests/test_getattr.py @@ -0,0 +1,28 @@ +from importlib import import_module +from pkgutil import walk_packages + +import matplotlib +import pytest + +# Get the names of all matplotlib submodules, except for the unit tests. +module_names = [m.name for m in walk_packages(path=matplotlib.__path__, + prefix=f'{matplotlib.__name__}.') + if not m.name.startswith(__package__)] + + +@pytest.mark.parametrize('module_name', module_names) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_getattr(module_name): + """ + Test that __getattr__ methods raise AttributeError for unknown keys. + See #20822, #20855. + """ + try: + module = import_module(module_name) + except (ImportError, RuntimeError) as e: + # Skip modules that cannot be imported due to missing dependencies + pytest.skip(f'Cannot import {module_name} due to {e}') + + key = 'THIS_SYMBOL_SHOULD_NOT_EXIST' + if hasattr(module, key): + delattr(module, key)