Skip to content

Proposal: Implement RcParams using ChainMap and remove dict inheritance #25617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 91 additions & 46 deletions lib/matplotlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -650,7 +651,7 @@
@_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.

Expand All @@ -665,12 +666,13 @@
--------
: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):
"""
Expand All @@ -690,7 +692,7 @@

:meta public:
"""
dict.__setitem__(self, key, val)
self._rcvalues[key] = val

def _get(self, key):
"""
Expand All @@ -711,7 +713,7 @@

:meta public:
"""
return dict.__getitem__(self, key)
return self._rcvalues[key]

def __setitem__(self, key, val):
try:
Expand Down Expand Up @@ -766,30 +768,84 @@

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(

Check warning on line 775 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L774-L775

Added lines #L774 - L775 were not covered by tests
version, name=key, obj_type="rcparam", alternative=alt_key)
return inverse_alt(self._get(alt_key))

Check warning on line 777 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L777

Added line #L777 was not covered by tests

elif key in _deprecated_ignore_map:
version, alt_key = _deprecated_ignore_map[key]
_api.warn_deprecated(

Check warning on line 781 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L780-L781

Added lines #L780 - L781 were not covered by tests
version, name=key, obj_type="rcparam", alternative=alt_key)
return self._defaults[alt_key] if alt_key else None

Check warning on line 783 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L783

Added line #L783 was not covered by tests

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(

Check warning on line 798 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L798

Added line #L798 was not covered by tests
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(

Check warning on line 804 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L801-L804

Added lines #L801 - L804 were not covered by tests
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

Check warning on line 832 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L832

Added line #L832 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand, clear is current a no-op because popitem raises KeyError which makes the default implementation from MutableMapping conclude that there is nothing to do and it is cleared despite doing nothing.

This is part of the public API of the MutableMapping API so I don't think we can (should) actually deprecate this.

We should either make sure the note says "we are going to make this error in the future" or just warn "this is not doing what you think it is doing".


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]

Check warning on line 843 in lib/matplotlib/__init__.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/__init__.py#L843

Added line #L843 was not covered by tests
self[key] = default
return default
Comment on lines +837 to +845
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current setdefault behavior (side-effect of __setitem__ not accepting new keys): Return the value if key is valid, otherwise raise. We should keep this behavior for compatibility.

Note that this behavoir is identical to rcParams[key]. Optional: Deprecate setdefault and recommend rcParams[key] instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just return the value (maybe catching the KeyError on misses to match the current error message exactly) and explicitly handle the one case we use this internally .


def copy(self):
return deepcopy(self)

def find_all(self, pattern):
"""
Expand All @@ -807,13 +863,6 @@
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."""
Expand Down Expand Up @@ -894,7 +943,7 @@
fname)
raise

config = RcParams()
config = dict()

for key, (val, line, line_no) in rc_temp.items():
if key in rcsetup._validators:
Expand Down Expand Up @@ -923,7 +972,7 @@
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):
Expand All @@ -947,7 +996,7 @@
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("""
Expand All @@ -962,32 +1011,29 @@
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.
# Assigning to rcsetup.defaultParams is left only for backcompat.
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']:
Expand Down Expand Up @@ -1086,13 +1132,10 @@
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():
Expand Down Expand Up @@ -1133,7 +1176,7 @@
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})


Expand Down Expand Up @@ -1182,16 +1225,18 @@
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):
Expand Down
17 changes: 12 additions & 5 deletions lib/matplotlib/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +70 to +71
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these be more specific about the type of the values?

e.g. tuple[str, ...] (this is how you say homogeneous tuple of str, indeterminate length)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks! I wasn't sure how to explain indeterminate length so kept it as just a tuple. I'll update it.

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: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually inherited from MutableMapping, but doesn't hurt to put it here more explicitly...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added these mainly because we overwrite these functions. So, just to be consistent with having stubs for the functions implemented..

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(
Expand Down
16 changes: 9 additions & 7 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -301,7 +303,7 @@
# 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',
Expand Down Expand Up @@ -336,7 +338,7 @@
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
Expand Down Expand Up @@ -413,7 +415,7 @@
_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(
Expand Down Expand Up @@ -745,7 +747,7 @@
"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({
Expand Down Expand Up @@ -2474,9 +2476,9 @@
# 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

Check warning on line 2481 in lib/matplotlib/pyplot.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/pyplot.py#L2481

Added line #L2481 was not covered by tests

# fmt: on

Expand Down
Loading