Skip to content

Commit a5a7576

Browse files
story645anntzer
andcommitted
added support for list of dicts of paths to path.effects rcparams
new path.effects validation of list of patheffect functions and/or dicts specifying functions created xkcd.mplstyle and shimmed it into plt.xkcd() new validate_anydict and validate_path_effects methods in rcsetup Co-authored-by: Antony Lee <anntzer.lee@gmail.com>
1 parent e3a5cee commit a5a7576

File tree

7 files changed

+193
-25
lines changed

7 files changed

+193
-25
lines changed

lib/matplotlib/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
from collections import namedtuple
138138
from collections.abc import MutableMapping
139139
import contextlib
140+
import copy
140141
import functools
141142
import importlib
142143
import inspect
@@ -163,7 +164,6 @@
163164
from matplotlib._api import MatplotlibDeprecationWarning
164165
from matplotlib.rcsetup import validate_backend, cycler
165166

166-
167167
_log = logging.getLogger(__name__)
168168

169169
__bibtex__ = r"""@Article{Hunter:2007,
@@ -759,6 +759,10 @@ def __getitem__(self, key):
759759
from matplotlib import pyplot as plt
760760
plt.switch_backend(rcsetup._auto_backend_sentinel)
761761

762+
elif key == "path.effects" and self is globals().get("rcParams"):
763+
# to avoid circular imports
764+
return self._load_path_effects()
765+
762766
return self._get(key)
763767

764768
def _get_backend_or_none(self):
@@ -809,6 +813,14 @@ def copy(self):
809813
rccopy._set(k, self._get(k))
810814
return rccopy
811815

816+
def _load_path_effects(self):
817+
"""defers loading of patheffects to avoid circular imports"""
818+
import matplotlib.patheffects as path_effects
819+
820+
return [pe if isinstance(pe, path_effects.AbstractPathEffect)
821+
else getattr(path_effects, pe.pop('name'))(**pe)
822+
for pe in copy.deepcopy(self._get('path.effects'))]
823+
812824

813825
def rc_params(fail_on_error=False):
814826
"""Construct a `RcParams` instance from the default Matplotlib rc file."""

lib/matplotlib/mpl-data/matplotlibrc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,10 @@
677677
# line (in pixels).
678678
# - *randomness* is the factor by which the length is
679679
# randomly scaled.
680-
#path.effects:
680+
#path.effects: # patheffects functions, args, and, kwargs, e.g
681+
# {'name': 'withStroke', 'linewidth': 4},
682+
# {'name': 'SimpleLineShadow'}
683+
681684

682685

683686
## ***************************************************************************
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## default xkcd style
2+
3+
# line
4+
lines.linewidth : 2.0
5+
6+
# font
7+
font.family : xkcd, xkcd Script, Humor Sans, Comic Neue, Comic Sans MS
8+
font.size : 14.0
9+
10+
# axes
11+
axes.linewidth : 1.5
12+
axes.grid : False
13+
axes.unicode_minus: False
14+
axes.edgecolor: black
15+
16+
# ticks
17+
xtick.major.size : 8
18+
xtick.major.width: 3
19+
ytick.major.size : 8
20+
ytick.major.width: 3
21+
22+
# grids
23+
grid.linewidth: 0.0
24+
25+
# figure
26+
figure.facecolor: white
27+
28+
# path
29+
path.sketch : 1, 100, 2
30+
path.effects: {'name': 'withStroke', 'linewidth': 4, 'foreground': 'w' }

lib/matplotlib/pyplot.py

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -747,27 +747,8 @@ def xkcd(
747747
stack = ExitStack()
748748
stack.callback(dict.update, rcParams, rcParams.copy()) # type: ignore
749749

750-
from matplotlib import patheffects
751-
rcParams.update({
752-
'font.family': ['xkcd', 'xkcd Script', 'Humor Sans', 'Comic Neue',
753-
'Comic Sans MS'],
754-
'font.size': 14.0,
755-
'path.sketch': (scale, length, randomness),
756-
'path.effects': [
757-
patheffects.withStroke(linewidth=4, foreground="w")],
758-
'axes.linewidth': 1.5,
759-
'lines.linewidth': 2.0,
760-
'figure.facecolor': 'white',
761-
'grid.linewidth': 0.0,
762-
'axes.grid': False,
763-
'axes.unicode_minus': False,
764-
'axes.edgecolor': 'black',
765-
'xtick.major.size': 8,
766-
'xtick.major.width': 3,
767-
'ytick.major.size': 8,
768-
'ytick.major.width': 3,
769-
})
770-
750+
rcParams.update({**style.library["xkcd"],
751+
'path.sketch': (scale, length, randomness)})
771752
return stack
772753

773754

lib/matplotlib/rcsetup.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ def f(s):
9797
val = [scalar_validator(v.strip()) for v in s if v.strip()]
9898
else:
9999
raise
100+
elif isinstance(s, dict):
101+
# assume dict is a value in the iterator and not the iterator
102+
# since iterating over dict only iterates over keys
103+
val = [scalar_validator(s)]
100104
# Allow any ordered sequence type -- generators, np.ndarray, pd.Series
101105
# -- but not sets, whose iteration order is non-deterministic.
102106
elif np.iterable(s) and not isinstance(s, (set, frozenset)):
@@ -125,9 +129,35 @@ def f(s):
125129

126130
def validate_any(s):
127131
return s
132+
128133
validate_anylist = _listify_validator(validate_any)
129134

130135

136+
def validate_anydict(allow_none=True, required_keys=None):
137+
"""Validate dictionary, check if keys are missing"""
138+
139+
required_keys = required_keys if required_keys else set()
140+
141+
def _validate_dict(d):
142+
try:
143+
d = ast.literal_eval(d)
144+
except ValueError:
145+
pass
146+
147+
if allow_none and d is None:
148+
return d
149+
150+
if isinstance(d, dict):
151+
if missing_keys := (required_keys - d.keys()):
152+
raise ValueError(f"Missing required key: {missing_keys!r}")
153+
return d
154+
155+
raise ValueError(f"Input {d!r} must be a dictionary {{'k': v}} "
156+
f"{'or None' if allow_none else ''}")
157+
158+
return _validate_dict
159+
160+
131161
def _validate_date(s):
132162
try:
133163
np.datetime64(s)
@@ -565,6 +595,23 @@ def validate_sketch(s):
565595
raise ValueError("Expected a (scale, length, randomness) triplet")
566596

567597

598+
def validate_path_effects(s):
599+
if not s:
600+
return []
601+
602+
_validate = validate_anydict(allow_none=True, required_keys={'name'})
603+
# string list of dict {k1: 1, k2:2}, {k1:2}
604+
# validate_anylist relies on , for parsing so parse here instead
605+
if isinstance(s, str) and s.startswith("{"):
606+
s = ast.literal_eval(s)
607+
if isinstance(s, dict):
608+
# list of one dict
609+
return _validate(s)
610+
611+
return [pe if getattr(pe, '__module__', "") == 'matplotlib.patheffects'
612+
else _validate(pe) for pe in validate_anylist(s)]
613+
614+
568615
def _validate_greaterthan_minushalf(s):
569616
s = validate_float(s)
570617
if s > -0.5:
@@ -1290,7 +1337,7 @@ def _convert_validator_spec(key, conv):
12901337
"path.simplify_threshold": _validate_greaterequal0_lessequal1,
12911338
"path.snap": validate_bool,
12921339
"path.sketch": validate_sketch,
1293-
"path.effects": validate_anylist,
1340+
"path.effects": validate_path_effects,
12941341
"agg.path.chunksize": validate_int, # 0 to disable chunking
12951342

12961343
# key-mappings (multi-character mappings should be a list/tuple)

lib/matplotlib/rcsetup.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ from cycler import Cycler
22

33
from collections.abc import Callable, Iterable
44
from typing import Any, Literal, TypeVar
5+
from matplotlib.patheffects import AbstractPathEffect
56
from matplotlib.typing import ColorType, LineStyleType, MarkEveryType
67

78
interactive_bk: list[str]
@@ -28,6 +29,8 @@ class ValidateInStrings:
2829

2930
def validate_any(s: Any) -> Any: ...
3031
def validate_anylist(s: Any) -> list[Any]: ...
32+
def validate_anydict(allow_none: bool = True, required_keys: set[str]|None = None
33+
) -> Callable[[dict[str, Any]|None], dict[str, Any]]: ...
3134
def validate_bool(b: Any) -> bool: ...
3235
def validate_axisbelow(s: Any) -> bool | Literal["line"]: ...
3336
def validate_dpi(s: Any) -> Literal["figure"] | float: ...
@@ -140,6 +143,7 @@ def _validate_linestyle(s: Any) -> LineStyleType: ...
140143
def validate_markeverylist(s: Any) -> list[MarkEveryType]: ...
141144
def validate_bbox(s: Any) -> Literal["tight", "standard"] | None: ...
142145
def validate_sketch(s: Any) -> None | tuple[float, float, float]: ...
146+
def validate_path_effects(s: Any) -> list[AbstractPathEffect] | list[dict]: ...
143147
def validate_hatch(s: Any) -> str: ...
144148
def validate_hatchlist(s: Any) -> list[str]: ...
145149
def validate_dashlist(s: Any) -> list[list[float]]: ...

lib/matplotlib/tests/test_rcparams.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
from matplotlib import _api, _c_internal_utils
1313
import matplotlib.pyplot as plt
1414
import matplotlib.colors as mcolors
15+
import matplotlib.patheffects as path_effects
16+
from matplotlib.testing.decorators import check_figures_equal
17+
1518
import numpy as np
1619
from matplotlib.rcsetup import (
20+
validate_anydict,
1721
validate_bool,
1822
validate_color,
1923
validate_colorlist,
@@ -27,8 +31,10 @@
2731
validate_int,
2832
validate_markevery,
2933
validate_stringlist,
34+
validate_path_effects,
3035
_validate_linestyle,
31-
_listify_validator)
36+
_listify_validator,
37+
)
3238

3339

3440
def test_rcparams(tmpdir):
@@ -628,3 +634,88 @@ def test_rcparams_legend_loc_from_file(tmpdir, value):
628634

629635
with mpl.rc_context(fname=rc_path):
630636
assert mpl.rcParams["legend.loc"] == value
637+
638+
639+
@pytest.mark.parametrize("allow_none", [True, False])
640+
def test_validate_dict(allow_none):
641+
fval = validate_anydict(allow_none)
642+
assert fval("{'a': 1, 'b': 2}") == {'a': 1, 'b': 2}
643+
with pytest.raises(ValueError, match=r"Input \['a', 'b'\] "):
644+
fval(['a', 'b'])
645+
646+
fval = validate_anydict(allow_none, required_keys={'a'})
647+
assert fval({'a': 1}) == {'a': 1}
648+
with pytest.raises(ValueError, match="Missing required key: {'a'}"):
649+
fval({'b': 1})
650+
651+
652+
def test_validate_dict_none():
653+
assert validate_anydict()(None) is None
654+
assert validate_anydict(required_keys={'a'})(None) is None
655+
656+
with pytest.raises(ValueError,
657+
match=r"Input None must be a dictionary "):
658+
validate_anydict(False)(None)
659+
with pytest.raises(ValueError,
660+
match=r"Input 0 must be a dictionary {'k': v} or None"):
661+
validate_anydict(True)(0)
662+
663+
664+
ped = [{'name': 'Normal'},
665+
{'name': 'Stroke', 'offset': (1, 2)},
666+
{'name': 'withStroke', 'linewidth': 4, 'foreground': 'w'}]
667+
668+
pel = [path_effects.Normal(),
669+
path_effects.Stroke((1, 2)),
670+
path_effects.withStroke(linewidth=4, foreground='w')]
671+
672+
673+
@pytest.mark.parametrize("value", [pel, ped], ids=["func", "dict"])
674+
def test_path_effects(value):
675+
assert validate_path_effects(value) == value
676+
for v in value:
677+
assert validate_path_effects(value) == value
678+
679+
680+
def test_path_effects_string_dict():
681+
"""test list of dicts properly parsed"""
682+
pstr = "{'name': 'Normal'},"
683+
pstr += "{'name': 'Stroke', 'offset': (1, 2)},"
684+
pstr += "{'name': 'withStroke', 'linewidth': 4, 'foreground': 'w'}"
685+
assert validate_path_effects(pstr) == ped
686+
687+
688+
@pytest.mark.parametrize("fdict, flist",
689+
[([ped[0]], [pel[0]]),
690+
([ped[1]], [pel[1]]),
691+
([ped[2]], [ped[2]]),
692+
(ped, pel)],
693+
ids=['function', 'args', 'kwargs', 'all'])
694+
@check_figures_equal()
695+
def test_path_effects_picture(fig_test, fig_ref, fdict, flist):
696+
with mpl.rc_context({'path.effects': fdict}):
697+
fig_test.subplots().plot([1, 2, 3])
698+
699+
with mpl.rc_context({'path.effects': flist}):
700+
fig_ref.subplots().plot([1, 2, 3])
701+
702+
703+
def test_path_effect_errors():
704+
with pytest.raises(ValueError, match="Missing required key: {'name'}"):
705+
mpl.rcParams['path.effects'] = [{'kwargs': {1, 2, 3}}]
706+
707+
with pytest.raises(ValueError, match=r"Key path.effects: Input 1 "):
708+
mpl.rcParams['path.effects'] = [1, 2, 3]
709+
710+
711+
def test_path_effects_from_file(tmpdir):
712+
# rcParams['legend.loc'] should be settable from matplotlibrc.
713+
# if any of these are not allowed, an exception will be raised.
714+
# test for gh issue #22338
715+
rc_path = tmpdir.join("matplotlibrc")
716+
rc_path.write("path.effects: "
717+
"{'name': 'Normal'}, {'name': 'withStroke', 'linewidth': 2}")
718+
719+
with mpl.rc_context(fname=rc_path):
720+
assert isinstance(mpl.rcParams["path.effects"][0], path_effects.Normal)
721+
assert isinstance(mpl.rcParams["path.effects"][1], path_effects.withStroke)

0 commit comments

Comments
 (0)