Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit a5a7576

Browse filesBrowse 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
Copy full SHA for a5a7576

File tree

7 files changed

+193
-25
lines changed
Filter options

7 files changed

+193
-25
lines changed

‎lib/matplotlib/__init__.py

Copy file name to clipboardExpand all lines: lib/matplotlib/__init__.py
+13-1Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/mpl-data/matplotlibrc
+4-1Lines 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
## ***************************************************************************
+30Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/pyplot.py
+2-21Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/rcsetup.py
+48-1Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/rcsetup.pyi
+4Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_rcparams.py
+92-1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.