diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 53f27c46314a..623b22852e30 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -134,8 +134,8 @@ import atexit -from collections import namedtuple -from collections.abc import MutableMapping +from collections import namedtuple, ChainMap +from collections.abc import MutableMapping, Mapping, KeysView, ValuesView, ItemsView import contextlib import functools import importlib @@ -155,6 +155,7 @@ import numpy from packaging.version import parse as parse_version +from copy import deepcopy # cbook must import matplotlib only within function # definitions, so it is safe to import from it here. @@ -650,7 +651,7 @@ def gen_candidates(): @_docstring.Substitution( "\n".join(map("- {}".format, sorted(rcsetup._validators, key=str.lower))) ) -class RcParams(MutableMapping, dict): +class RcParams(MutableMapping): """ A dict-like key-value store for config parameters, including validation. @@ -665,12 +666,13 @@ class RcParams(MutableMapping, dict): -------- :ref:`customizing-with-matplotlibrc-files` """ - validate = rcsetup._validators - # validate values on the way in def __init__(self, *args, **kwargs): + self._rcvalues = ChainMap({}) self.update(*args, **kwargs) + self._rcvalues = self._rcvalues.new_child() + self._defaults = self._rcvalues.maps[-1] def _set(self, key, val): """ @@ -690,7 +692,7 @@ def _set(self, key, val): :meta public: """ - dict.__setitem__(self, key, val) + self._rcvalues[key] = val def _get(self, key): """ @@ -711,7 +713,7 @@ def _get(self, key): :meta public: """ - return dict.__getitem__(self, key) + return self._rcvalues[key] def __setitem__(self, key, val): try: @@ -766,30 +768,84 @@ def __getitem__(self, key): return self._get(key) + def get_default(self, key): + """Return default value for the key set during initialization.""" + if key in _deprecated_map: + 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(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 self._defaults[alt_key] if alt_key else None + + return self._defaults[key] + + def get_defaults(self): + """Return default values set during initialization.""" + return self._defaults.copy() + def _get_backend_or_none(self): """Get the requested backend, if any, without triggering resolution.""" backend = self._get("backend") return None if backend is rcsetup._auto_backend_sentinel else backend + def __delitem__(self, key): + if key not in self.validate: + raise KeyError( + f"{key} is not a valid rc parameter (see rcParams.keys() for " + f"a list of valid parameters)") + try: + del self._rcvalues[key] + except KeyError as err: + raise KeyError( + f"No custom value set for {key}. Cannot delete default value." + ) from err + + def __contains__(self, key): + return key in self._rcvalues + + def __iter__(self): + """Yield from sorted list of keys""" + yield from sorted(self._rcvalues.keys()) + + def __len__(self): + return len(self._rcvalues) + def __repr__(self): class_name = self.__class__.__name__ indent = len(class_name) + 1 with _api.suppress_matplotlib_deprecation_warning(): - repr_split = pprint.pformat(dict(self), indent=1, + repr_split = pprint.pformat(dict(self._rcvalues.items()), indent=1, width=80 - indent).split('\n') repr_indented = ('\n' + ' ' * indent).join(repr_split) return f'{class_name}({repr_indented})' def __str__(self): - return '\n'.join(map('{0[0]}: {0[1]}'.format, sorted(self.items()))) + return '\n'.join(map('{0[0]}: {0[1]}'.format, sorted(self._rcvalues.items()))) - def __iter__(self): - """Yield sorted list of keys.""" - with _api.suppress_matplotlib_deprecation_warning(): - yield from sorted(dict.__iter__(self)) + @_api.deprecated("3.8") + def clear(self): + pass - def __len__(self): - return dict.__len__(self) + def reset(self): + self._rcvalues.clear() + + def setdefault(self, key, default=None): + """Insert key with a value of default if key is not in the dictionary. + + Return the value for key if key is in the dictionary, else default. + """ + if key in self: + return self[key] + self[key] = default + return default + + def copy(self): + return deepcopy(self) def find_all(self, pattern): """ @@ -807,13 +863,6 @@ def find_all(self, pattern): for key, value in self.items() if pattern_re.search(key)) - def copy(self): - """Copy this RcParams instance.""" - rccopy = RcParams() - for k in self: # Skip deprecations and revalidation. - rccopy._set(k, self._get(k)) - return rccopy - def rc_params(fail_on_error=False): """Construct a `RcParams` instance from the default Matplotlib rc file.""" @@ -894,7 +943,7 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False): fname) raise - config = RcParams() + config = dict() for key, (val, line, line_no) in rc_temp.items(): if key in rcsetup._validators: @@ -923,7 +972,7 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False): or from the matplotlib source distribution""", dict(key=key, fname=fname, line_no=line_no, line=line.rstrip('\n'), version=version)) - return config + return RcParams(config) def rc_params_from_file(fname, fail_on_error=False, use_default_template=True): @@ -947,7 +996,7 @@ def rc_params_from_file(fname, fail_on_error=False, use_default_template=True): return config_from_file with _api.suppress_matplotlib_deprecation_warning(): - config = RcParams({**rcParamsDefault, **config_from_file}) + config = RcParams({**rcParams.get_defaults(), **config_from_file}) if "".join(config['text.latex.preamble']): _log.info(""" @@ -962,24 +1011,21 @@ def rc_params_from_file(fname, fail_on_error=False, use_default_template=True): return config -# When constructing the global instances, we need to perform certain updates -# by explicitly calling the superclass (dict.update, dict.items) to avoid -# triggering resolution of _auto_backend_sentinel. -rcParamsDefault = _rc_params_in_file( +rcParams = _rc_params_in_file( cbook._get_data_path("matplotlibrc"), # Strip leading comment. transform=lambda line: line[1:] if line.startswith("#") else line, fail_on_error=True) -dict.update(rcParamsDefault, rcsetup._hardcoded_defaults) +rcParams._rcvalues = rcParams._rcvalues.parents +rcParams.update(rcsetup._hardcoded_defaults) # Normally, the default matplotlibrc file contains *no* entry for backend (the # corresponding line starts with ##, not #; we fill on _auto_backend_sentinel # in that case. However, packagers can set a different default backend # (resulting in a normal `#backend: foo` line) in which case we should *not* # fill in _auto_backend_sentinel. -dict.setdefault(rcParamsDefault, "backend", rcsetup._auto_backend_sentinel) -rcParams = RcParams() # The global instance. -dict.update(rcParams, dict.items(rcParamsDefault)) -dict.update(rcParams, _rc_params_in_file(matplotlib_fname())) +rcParams.update(_rc_params_in_file(matplotlib_fname())) +rcParams.setdefault("backend", rcsetup._auto_backend_sentinel) +rcParams._rcvalues = rcParams._rcvalues.new_child() rcParamsOrig = rcParams.copy() with _api.suppress_matplotlib_deprecation_warning(): # This also checks that all rcParams are indeed listed in the template. @@ -987,7 +1033,7 @@ def rc_params_from_file(fname, fail_on_error=False, use_default_template=True): defaultParams = rcsetup.defaultParams = { # We want to resolve deprecated rcParams, but not backend... key: [(rcsetup._auto_backend_sentinel if key == "backend" else - rcParamsDefault[key]), + rcParams.get_default(key)), validator] for key, validator in rcsetup._validators.items()} if rcParams['axes.formatter.use_locale']: @@ -1086,13 +1132,10 @@ def rcdefaults(): Use a specific style file. Call ``style.use('default')`` to restore the default style. """ - # Deprecation warnings were already handled when creating rcParamsDefault, - # no need to reemit them here. - with _api.suppress_matplotlib_deprecation_warning(): - from .style.core import STYLE_BLACKLIST - rcParams.clear() - rcParams.update({k: v for k, v in rcParamsDefault.items() - if k not in STYLE_BLACKLIST}) + # # Deprecation warnings were already handled when creating rcParamsDefault, + # # no need to reemit them here. + from .style import core + core.use('default') def rc_file_defaults(): @@ -1133,7 +1176,7 @@ def rc_file(fname, *, use_default_template=True): from .style.core import STYLE_BLACKLIST rc_from_file = rc_params_from_file( fname, use_default_template=use_default_template) - rcParams.update({k: rc_from_file[k] for k in rc_from_file + rcParams.update({k: rc_from_file[k] for k in rc_from_file.keys() if k not in STYLE_BLACKLIST}) @@ -1182,16 +1225,18 @@ def rc_context(rc=None, fname=None): plt.plot(x, y) """ - orig = dict(rcParams.copy()) - del orig['backend'] try: + rcParams._rcvalues = rcParams._rcvalues.new_child() if fname: rc_file(fname) if rc: rcParams.update(rc) yield finally: - dict.update(rcParams, orig) # Revert to the original rcs. + # Revert to the original rcs. + backend = rcParams["backend"] + rcParams._rcvalues = rcParams._rcvalues.parents + rcParams["backend"] = backend def use(backend, *, force=True): diff --git a/lib/matplotlib/__init__.pyi b/lib/matplotlib/__init__.pyi index 8ef23a3dc4c2..d37ac6469770 100644 --- a/lib/matplotlib/__init__.pyi +++ b/lib/matplotlib/__init__.pyi @@ -32,12 +32,12 @@ __all__ = [ import os from pathlib import Path -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, MutableMapping import contextlib from packaging.version import Version from matplotlib._api import MatplotlibDeprecationWarning -from typing import Any, NamedTuple +from typing import Any, NamedTuple, Self class _VersionInfo(NamedTuple): major: int @@ -65,15 +65,22 @@ def get_cachedir() -> str: ... def get_data_path() -> str: ... def matplotlib_fname() -> str: ... -class RcParams(dict[str, Any]): +class RcParams(MutableMapping[str, Any]): validate: dict[str, Callable] + namespaces: tuple + single_key_set: set def __init__(self, *args, **kwargs) -> None: ... + @staticmethod + def _split_key(key: str, sep: str = ...) -> tuple[list, int]: ... + def _set(self, key: str, val: Any) -> None: ... + def _get(self, key: str) -> Any: ... def __setitem__(self, key: str, val: Any) -> None: ... def __getitem__(self, key: str) -> Any: ... + def __delitem__(self, key: str) -> None: ... def __iter__(self) -> Generator[str, None, None]: ... def __len__(self) -> int: ... - def find_all(self, pattern: str) -> RcParams: ... - def copy(self) -> RcParams: ... + def find_all(self, pattern: str) -> Self: ... + def copy(self) -> Self: ... def rc_params(fail_on_error: bool = ...) -> RcParams: ... def rc_params_from_file( diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 7f4aa12c9ed6..5669ee82f2f3 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -65,7 +65,9 @@ FigureCanvasBase, FigureManagerBase, MouseButton) from matplotlib.figure import Figure, FigureBase, figaspect from matplotlib.gridspec import GridSpec, SubplotSpec -from matplotlib import rcsetup, rcParamsDefault, rcParamsOrig +from matplotlib import rcParams, get_backend, rcParamsOrig +from matplotlib.rcsetup import interactive_bk as _interactive_bk +from matplotlib.rcsetup import _auto_backend_sentinel from matplotlib.artist import Artist from matplotlib.axes import Axes, Subplot # type: ignore from matplotlib.projections import PolarAxes # type: ignore @@ -301,7 +303,7 @@ def switch_backend(newbackend: str) -> None: # make sure the init is pulled up so we can assign to it later import matplotlib.backends - if newbackend is rcsetup._auto_backend_sentinel: + if newbackend is _auto_backend_sentinel: current_framework = cbook._get_running_interactive_framework() mapping = {'qt': 'qtagg', 'gtk3': 'gtk3agg', @@ -336,7 +338,7 @@ def switch_backend(newbackend: str) -> None: rcParamsOrig["backend"] = "agg" return # have to escape the switch on access logic - old_backend = dict.__getitem__(rcParams, 'backend') + old_backend = rcParams._get('backend') module = importlib.import_module(cbook._backend_module_name(newbackend)) canvas_class = module.FigureCanvas @@ -413,7 +415,7 @@ def draw_if_interactive() -> None: _log.debug("Loaded backend %s version %s.", newbackend, backend_mod.backend_version) - rcParams['backend'] = rcParamsDefault['backend'] = newbackend + rcParams['backend'] = newbackend _backend_mod = backend_mod for func_name in ["new_figure_manager", "draw_if_interactive", "show"]: globals()[func_name].__signature__ = inspect.signature( @@ -745,7 +747,7 @@ def xkcd( "xkcd mode is not compatible with text.usetex = True") stack = ExitStack() - stack.callback(dict.update, rcParams, rcParams.copy()) # type: ignore + stack.callback(rcParams.update, rcParams.copy()) # type: ignore from matplotlib import patheffects rcParams.update({ @@ -2474,9 +2476,9 @@ def polar(*args, **kwargs) -> list[Line2D]: # is compatible with the current running interactive framework. if (rcParams["backend_fallback"] and rcParams._get_backend_or_none() in ( # type: ignore - set(rcsetup.interactive_bk) - {'WebAgg', 'nbAgg'}) + set(_interactive_bk) - {'WebAgg', 'nbAgg'}) and cbook._get_running_interactive_framework()): # type: ignore - rcParams._set("backend", rcsetup._auto_backend_sentinel) # type: ignore + rcParams._set("backend", _auto_backend_sentinel) # type: ignore # fmt: on diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index 7e9008c56165..57b3a60a0a54 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -26,7 +26,7 @@ import importlib_resources import matplotlib as mpl -from matplotlib import _api, _docstring, _rc_params_in_file, rcParamsDefault +from matplotlib import _api, _docstring, _rc_params_in_file _log = logging.getLogger(__name__) @@ -114,7 +114,7 @@ def use(style): # rcParamsDefault, no need to reemit them here. with _api.suppress_matplotlib_deprecation_warning(): # don't trigger RcParams.__getitem__('backend') - style = {k: rcParamsDefault[k] for k in rcParamsDefault + style = {k: mpl.rcParams.get_default(k) for k in mpl.rcParams if k not in STYLE_BLACKLIST} elif style in library: style = library[style] diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index 65cd823f13a9..dc4ff1f8cff5 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -652,3 +652,39 @@ def test_rcparams_path_sketch_from_file(tmpdir, value): rc_path.write(f"path.sketch: {value}") with mpl.rc_context(fname=rc_path): assert mpl.rcParams["path.sketch"] == (1, 2, 3) + + +def test_rcparams_getdefault(): + with mpl.rc_context({"image.lut": 128}): + assert mpl.rcParams.get_default("image.lut") == 256 + + +def test_rcparams_getdefaults(): + mpl.rc("image", lut=128) + defaults = mpl.rcParams.get_defaults() + assert defaults == mpl.rcParams._defaults + + +def test_rcdefaults(): + # webagg.port is a style blacklisted key that shouldn't be + # updated when resetting rcParams to default values. + mpl.rcParams["webagg.port"] = 9000 + # lines.linewidth is not a style blacklisted key and should be + # reset to the default value. + # breakpoint() + lw = mpl.rcParams.get_default("lines.linewidth") + mpl.rcParams["lines.linewidth"] = lw + 1 + mpl.rcdefaults() + assert mpl.rcParams["webagg.port"] == 9000 + assert mpl.rcParams["lines.linewidth"] == lw + + +def test_rcparams_reset(): + mpl.rcParams["image.lut"] = 128 + mpl.rcParams.reset() + assert mpl.rcParams["image.lut"] == 256 + + +def test_rcparams_clear(): + with pytest.raises(mpl.MatplotlibDeprecationWarning): + mpl.rcParams.clear()