Skip to content

Commit af68218

Browse files
authored
Merge pull request matplotlib#20857 from anntzer/module-getattr
Propose a less error-prone helper for module-level getattrs.
2 parents 7b9e1ce + d427153 commit af68218

File tree

9 files changed

+110
-70
lines changed

9 files changed

+110
-70
lines changed

lib/matplotlib/__init__.py

+8-10
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,14 @@ def _get_version():
176176
return _version.version
177177

178178

179-
@functools.lru_cache(None)
180-
def __getattr__(name):
181-
if name == "__version__":
182-
return _get_version()
183-
elif name == "__version_info__":
184-
return _parse_to_version_info(__getattr__("__version__"))
185-
elif name == "URL_REGEX": # module-level deprecation.
186-
_api.warn_deprecated("3.5", name=name)
187-
return re.compile(r'^http://|^https://|^ftp://|^file:')
188-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
179+
@_api.caching_module_getattr
180+
class __getattr__:
181+
__version__ = property(lambda self: _get_version())
182+
__version_info__ = property(
183+
lambda self: _parse_to_version_info(self.__version__))
184+
# module-level deprecations
185+
URL_REGEX = _api.deprecated("3.5", obj_type="")(property(
186+
lambda self: re.compile(r'^http://|^https://|^ftp://|^file:')))
189187

190188

191189
def _check_versions():

lib/matplotlib/_api/__init__.py

+36
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
1111
"""
1212

13+
import functools
1314
import itertools
1415
import re
1516
import sys
@@ -189,6 +190,41 @@ def check_getitem(_mapping, **kwargs):
189190
.format(v, k, ', '.join(map(repr, mapping)))) from None
190191

191192

193+
def caching_module_getattr(cls):
194+
"""
195+
Helper decorator for implementing module-level ``__getattr__`` as a class.
196+
197+
This decorator must be used at the module toplevel as follows::
198+
199+
@caching_module_getattr
200+
class __getattr__: # The class *must* be named ``__getattr__``.
201+
@property # Only properties are taken into account.
202+
def name(self): ...
203+
204+
The ``__getattr__`` class will be replaced by a ``__getattr__``
205+
function such that trying to access ``name`` on the module will
206+
resolve the corresponding property (which may be decorated e.g. with
207+
``_api.deprecated`` for deprecating module globals). The properties are
208+
all implicitly cached. Moreover, a suitable AttributeError is generated
209+
and raised if no property with the given name exists.
210+
"""
211+
212+
assert cls.__name__ == "__getattr__"
213+
# Don't accidentally export cls dunders.
214+
props = {name: prop for name, prop in vars(cls).items()
215+
if isinstance(prop, property)}
216+
instance = cls()
217+
218+
@functools.lru_cache(None)
219+
def __getattr__(name):
220+
if name in props:
221+
return props[name].__get__(instance)
222+
raise AttributeError(
223+
f"module {cls.__module__!r} has no attribute {name!r}")
224+
225+
return __getattr__
226+
227+
192228
def select_matching_signature(funcs, *args, **kwargs):
193229
"""
194230
Select and call the function that accepts ``*args, **kwargs``.

lib/matplotlib/_api/deprecation.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,14 @@ def finalize(wrapper, new_doc):
150150
return obj
151151

152152
elif isinstance(obj, (property, classproperty)):
153-
obj_type = "attribute"
153+
if obj_type is None:
154+
obj_type = "attribute"
154155
func = None
155156
name = name or obj.fget.__name__
156157
old_doc = obj.__doc__
157158

158159
class _deprecated_property(type(obj)):
159-
def __get__(self, instance, owner):
160+
def __get__(self, instance, owner=None):
160161
if instance is not None or owner is not None \
161162
and isinstance(self, classproperty):
162163
emit_warning()

lib/matplotlib/backends/backend_gtk3.py

+5-7
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@
3737
Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())
3838

3939

40-
# module-level deprecations.
41-
@functools.lru_cache(None)
42-
def __getattr__(name):
43-
if name == "cursord":
44-
_api.warn_deprecated("3.5", name=name)
40+
@_api.caching_module_getattr # module-level deprecations
41+
class __getattr__:
42+
@_api.deprecated("3.5", obj_type="")
43+
@property
44+
def cursord(self):
4545
try:
4646
new_cursor = functools.partial(
4747
Gdk.Cursor.new_from_name, Gdk.Display.get_default())
@@ -54,8 +54,6 @@ def __getattr__(name):
5454
}
5555
except TypeError as exc:
5656
return {}
57-
else:
58-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
5957

6058

6159
# Placeholder

lib/matplotlib/backends/backend_wx.py

+13-19
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,19 @@
4141
PIXELS_PER_INCH = 75
4242

4343

44-
# module-level deprecations.
45-
@functools.lru_cache(None)
46-
def __getattr__(name):
47-
if name == "IDLE_DELAY":
48-
_api.warn_deprecated("3.1", name=name)
49-
return 5
50-
elif name == "cursord":
51-
_api.warn_deprecated("3.5", name=name)
52-
return { # deprecated in Matplotlib 3.5.
53-
cursors.MOVE: wx.CURSOR_HAND,
54-
cursors.HAND: wx.CURSOR_HAND,
55-
cursors.POINTER: wx.CURSOR_ARROW,
56-
cursors.SELECT_REGION: wx.CURSOR_CROSS,
57-
cursors.WAIT: wx.CURSOR_WAIT,
58-
cursors.RESIZE_HORIZONTAL: wx.CURSOR_SIZEWE,
59-
cursors.RESIZE_VERTICAL: wx.CURSOR_SIZENS,
60-
}
61-
else:
62-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
44+
@_api.caching_module_getattr # module-level deprecations
45+
class __getattr__:
46+
IDLE_DELAY = _api.deprecated("3.1", obj_type="", removal="3.6")(property(
47+
lambda self: 5))
48+
cursord = _api.deprecated("3.5", obj_type="")(property(lambda self: {
49+
cursors.MOVE: wx.CURSOR_HAND,
50+
cursors.HAND: wx.CURSOR_HAND,
51+
cursors.POINTER: wx.CURSOR_ARROW,
52+
cursors.SELECT_REGION: wx.CURSOR_CROSS,
53+
cursors.WAIT: wx.CURSOR_WAIT,
54+
cursors.RESIZE_HORIZONTAL: wx.CURSOR_SIZEWE,
55+
cursors.RESIZE_VERTICAL: wx.CURSOR_SIZENS,
56+
}))
6357

6458

6559
def error_msg_wx(msg, parent=None):

lib/matplotlib/cm.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"""
1717

1818
from collections.abc import Mapping, MutableMapping
19-
import functools
2019

2120
import numpy as np
2221
from numpy import ma
@@ -27,14 +26,11 @@
2726
from matplotlib._cm_listed import cmaps as cmaps_listed
2827

2928

30-
# module-level deprecations.
31-
@functools.lru_cache(None)
32-
def __getattr__(name):
33-
if name == "LUTSIZE":
34-
_api.warn_deprecated("3.5", name=name,
35-
alternative="rcParams['image.lut']")
36-
return _LUTSIZE
37-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29+
@_api.caching_module_getattr # module-level deprecations
30+
class __getattr__:
31+
LUTSIZE = _api.deprecated(
32+
"3.5", obj_type="", alternative="rcParams['image.lut']")(
33+
property(lambda self: _LUTSIZE))
3834

3935

4036
_LUTSIZE = mpl.rcParams['image.lut']

lib/matplotlib/colorbar.py

+8-15
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"""
1313

1414
import copy
15-
import functools
1615
import logging
1716
import textwrap
1817

@@ -195,20 +194,14 @@
195194
_colormap_kw_doc))
196195

197196

198-
# module-level deprecations.
199-
@functools.lru_cache(None)
200-
def __getattr__(name):
201-
if name == "colorbar_doc":
202-
_api.warn_deprecated("3.4", name=name)
203-
return docstring.interpd.params["colorbar_doc"]
204-
elif name == "colormap_kw_doc":
205-
_api.warn_deprecated("3.4", name=name)
206-
return _colormap_kw_doc
207-
elif name == "make_axes_kw_doc":
208-
_api.warn_deprecated("3.4", name=name)
209-
return _make_axes_param_doc + _make_axes_other_param_doc
210-
else:
211-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
197+
@_api.caching_module_getattr # module-level deprecations
198+
class __getattr__:
199+
colorbar_doc = _api.deprecated("3.4", obj_type="")(property(
200+
lambda self: docstring.interpd.params["colorbar_doc"]))
201+
colorbar_kw_doc = _api.deprecated("3.4", obj_type="")(property(
202+
lambda self: _colormap_kw_doc))
203+
make_axes_kw_doc = _api.deprecated("3.4", obj_type="")(property(
204+
lambda self: _make_axes_param_doc + _make_axes_other_param_doc))
212205

213206

214207
def _set_ticks_on_axis_warn(*args, **kw):

lib/matplotlib/style/core.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"""
1313

1414
import contextlib
15-
import functools
1615
import logging
1716
import os
1817
from pathlib import Path
@@ -27,13 +26,10 @@
2726
__all__ = ['use', 'context', 'available', 'library', 'reload_library']
2827

2928

30-
# module-level deprecations.
31-
@functools.lru_cache(None)
32-
def __getattr__(name):
33-
if name == "STYLE_FILE_PATTERN":
34-
_api.warn_deprecated("3.5", name=name)
35-
return re.compile(r'([\S]+).%s$' % STYLE_EXTENSION)
36-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29+
@_api.caching_module_getattr # module-level deprecations
30+
class __getattr__:
31+
STYLE_FILE_PATTERN = _api.deprecated("3.5", obj_type="")(property(
32+
lambda self: re.compile(r'([\S]+).%s$' % STYLE_EXTENSION)))
3733

3834

3935
BASE_LIBRARY_PATH = os.path.join(mpl.get_data_path(), 'stylelib')

lib/matplotlib/tests/test_getattr.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from importlib import import_module
2+
from pkgutil import walk_packages
3+
4+
import matplotlib
5+
import pytest
6+
7+
# Get the names of all matplotlib submodules, except for the unit tests.
8+
module_names = [m.name for m in walk_packages(path=matplotlib.__path__,
9+
prefix=f'{matplotlib.__name__}.')
10+
if not m.name.startswith(__package__)]
11+
12+
13+
@pytest.mark.parametrize('module_name', module_names)
14+
@pytest.mark.filterwarnings('ignore::DeprecationWarning')
15+
def test_getattr(module_name):
16+
"""
17+
Test that __getattr__ methods raise AttributeError for unknown keys.
18+
See #20822, #20855.
19+
"""
20+
try:
21+
module = import_module(module_name)
22+
except (ImportError, RuntimeError) as e:
23+
# Skip modules that cannot be imported due to missing dependencies
24+
pytest.skip(f'Cannot import {module_name} due to {e}')
25+
26+
key = 'THIS_SYMBOL_SHOULD_NOT_EXIST'
27+
if hasattr(module, key):
28+
delattr(module, key)

0 commit comments

Comments
 (0)