From ecf156c1a086def57e77b088b1ab76c7548ee152 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 14 Dec 2022 22:18:24 +0100 Subject: [PATCH] Data access API for rcParams This provides a defined API for accessing rcParams via `rcParams._data[key]` while circumventing any validation logic happening in `rcParams[key]`. Before, direct data access was realized through `dict.__getitem(rcParams, key)` / `dict.__setitem(rcParams, key, val)`, which depends on the implementation detail of `rcParams` being a dict subclass. The new data access API gets rid of this dependence and thus opens up a way to later move away from dict subclassing. We want to move away from dict subclassing and only guarantee the `MutableMapping` interface for `rcParams` in the future. This allows other future restructings like introducing a new configuration management and changing `rcParams` into a backward-compatible adapter. Co-authored-by: Oscar Gustafsson Co-authored-by: Jody Klymak Co-authored-by: Elliott Sales de Andrade Co-authored-by: story645 --- .../deprecations/24730-TH.rst | 24 ++++++++ lib/matplotlib/__init__.py | 57 ++++++++++++++++--- lib/matplotlib/pyplot.py | 10 ++-- .../tests/test_backends_interactive.py | 4 +- lib/matplotlib/tests/test_rcparams.py | 2 +- lib/matplotlib/tests/test_widgets.py | 2 +- 6 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/24730-TH.rst diff --git a/doc/api/next_api_changes/deprecations/24730-TH.rst b/doc/api/next_api_changes/deprecations/24730-TH.rst new file mode 100644 index 000000000000..0edb329e7bc6 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/24730-TH.rst @@ -0,0 +1,24 @@ +rcParams type +~~~~~~~~~~~~~ +Relying on ``rcParams`` being a ``dict`` subclass is deprecated. + +Nothing will change for regular users because ``rcParams`` will continue to +be dict-like (technically fulfill the ``MutableMapping`` interface). + +The `.RcParams` class does validation checking on calls to +``.RcParams.__getitem__`` and ``.RcParams.__setitem__``. However, there are rare +cases where we want to circumvent the validation logic and directly access the +underlying data values. Previously, this could be accomplished via a call to +the parent methods ``dict.__getitem__(rcParams, key)`` and +``dict.__setitem__(rcParams, key, val)``. + +Matplotlib 3.7 introduces ``rcParams._set(key, val)`` and +``rcParams._get(key)`` as a replacement to calling the parent methods. They are +intentionally marked private to discourage external use; However, if direct +`.RcParams` data access is needed, please switch from the dict functions to the +new ``_get()`` and ``_set()``. Even though marked private, we guarantee API +stability for these methods and they are subject to Matplotlib's API and +deprecation policy. + +Please notify the Matplotlib developers if you rely on ``rcParams`` being a +dict subclass in any other way, for which there is no migration path yet. diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index fd78e6f94f19..daa588f2fdcd 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -605,7 +605,7 @@ def gen_candidates(): ) class RcParams(MutableMapping, dict): """ - A dictionary object including validation. + A dict-like key-value store for config parameters, including validation. Validating functions are defined and associated with rc parameters in :mod:`matplotlib.rcsetup`. @@ -625,6 +625,47 @@ class RcParams(MutableMapping, dict): def __init__(self, *args, **kwargs): self.update(*args, **kwargs) + def _set(self, key, val): + """ + Directly write data bypassing deprecation and validation logic. + + Notes + ----- + As end user or downstream library you almost always should use + ``rcParams[key] = val`` and not ``_set()``. + + There are only very few special cases that need direct data access. + These cases previously used ``dict.__setitem__(rcParams, key, val)``, + which is now deprecated and replaced by ``rcParams._set(key, val)``. + + Even though private, we guarantee API stability for ``rcParams._set``, + i.e. it is subject to Matplotlib's API and deprecation policy. + + :meta public: + """ + dict.__setitem__(self, key, val) + + def _get(self, key): + """ + Directly read data bypassing deprecation, backend and validation + logic. + + Notes + ----- + As end user or downstream library you almost always should use + ``val = rcParams[key]`` and not ``_get()``. + + There are only very few special cases that need direct data access. + These cases previously used ``dict.__getitem__(rcParams, key, val)``, + which is now deprecated and replaced by ``rcParams._get(key)``. + + Even though private, we guarantee API stability for ``rcParams._get``, + i.e. it is subject to Matplotlib's API and deprecation policy. + + :meta public: + """ + return dict.__getitem__(self, key) + def __setitem__(self, key, val): try: if key in _deprecated_map: @@ -649,7 +690,7 @@ def __setitem__(self, key, val): cval = self.validate[key](val) except ValueError as ve: raise ValueError(f"Key {key}: {ve}") from None - dict.__setitem__(self, key, cval) + self._set(key, cval) except KeyError as err: raise KeyError( f"{key} is not a valid rc parameter (see rcParams.keys() for " @@ -660,27 +701,27 @@ def __getitem__(self, key): version, alt_key, alt_val, inverse_alt = _deprecated_map[key] _api.warn_deprecated( version, name=key, obj_type="rcparam", alternative=alt_key) - return inverse_alt(dict.__getitem__(self, alt_key)) + return inverse_alt(self._get(alt_key)) elif key in _deprecated_ignore_map: version, alt_key = _deprecated_ignore_map[key] _api.warn_deprecated( version, name=key, obj_type="rcparam", alternative=alt_key) - return dict.__getitem__(self, alt_key) if alt_key else None + return self._get(alt_key) if alt_key else None # In theory, this should only ever be used after the global rcParams # has been set up, but better be safe e.g. in presence of breakpoints. elif key == "backend" and self is globals().get("rcParams"): - val = dict.__getitem__(self, key) + val = self._get(key) if val is rcsetup._auto_backend_sentinel: from matplotlib import pyplot as plt plt.switch_backend(rcsetup._auto_backend_sentinel) - return dict.__getitem__(self, key) + return self._get(key) def _get_backend_or_none(self): """Get the requested backend, if any, without triggering resolution.""" - backend = dict.__getitem__(self, "backend") + backend = self._get("backend") return None if backend is rcsetup._auto_backend_sentinel else backend def __repr__(self): @@ -722,7 +763,7 @@ def find_all(self, pattern): def copy(self): rccopy = RcParams() for k in self: # Skip deprecations and revalidation. - dict.__setitem__(rccopy, k, dict.__getitem__(self, k)) + rccopy._set(k, self._get(k)) return rccopy diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index f11d66844a4c..54597c42852e 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -200,10 +200,10 @@ def _get_backend_mod(): This is currently private, but may be made public in the future. """ if _backend_mod is None: - # Use __getitem__ here to avoid going through the fallback logic (which - # will (re)import pyplot and then call switch_backend if we need to - # resolve the auto sentinel) - switch_backend(dict.__getitem__(rcParams, "backend")) + # Use rcParams._get("backend") to avoid going through the fallback + # logic (which will (re)import pyplot and then call switch_backend if + # we need to resolve the auto sentinel) + switch_backend(rcParams._get("backend")) return _backend_mod @@ -2197,7 +2197,7 @@ def polar(*args, **kwargs): and rcParams._get_backend_or_none() in ( set(_interactive_bk) - {'WebAgg', 'nbAgg'}) and cbook._get_running_interactive_framework()): - dict.__setitem__(rcParams, "backend", rcsetup._auto_backend_sentinel) + rcParams._set("backend", rcsetup._auto_backend_sentinel) ################# REMAINING CONTENT GENERATED BY boilerplate.py ############## diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index e5e6b295e798..5cb007ba946e 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -247,13 +247,13 @@ def _impl_test_lazy_auto_backend_selection(): import matplotlib import matplotlib.pyplot as plt # just importing pyplot should not be enough to trigger resolution - bk = dict.__getitem__(matplotlib.rcParams, 'backend') + bk = matplotlib.rcParams._get('backend') assert not isinstance(bk, str) assert plt._backend_mod is None # but actually plotting should plt.plot(5) assert plt._backend_mod is not None - bk = dict.__getitem__(matplotlib.rcParams, 'backend') + bk = matplotlib.rcParams._get('backend') assert isinstance(bk, str) diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index d5f316f54595..a9a4ccba21fa 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -543,7 +543,7 @@ def test_backend_fallback_headful(tmpdir): "sentinel = mpl.rcsetup._auto_backend_sentinel; " # Check that access on another instance does not resolve the sentinel. "assert mpl.RcParams({'backend': sentinel})['backend'] == sentinel; " - "assert dict.__getitem__(mpl.rcParams, 'backend') == sentinel; " + "assert mpl.rcParams._get('backend') == sentinel; " "import matplotlib.pyplot; " "print(matplotlib.get_backend())"], env=env, universal_newlines=True) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 446e272610ab..fda7a8a05bfb 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -965,7 +965,7 @@ def test_CheckButtons(ax): @pytest.mark.parametrize("toolbar", ["none", "toolbar2", "toolmanager"]) def test_TextBox(ax, toolbar): # Avoid "toolmanager is provisional" warning. - dict.__setitem__(plt.rcParams, "toolbar", toolbar) + plt.rcParams._set("toolbar", toolbar) submit_event = mock.Mock(spec=noop, return_value=None) text_change_event = mock.Mock(spec=noop, return_value=None)