From 3a2a224fda8d353726d366786c4ebb1d55d95e7d Mon Sep 17 00:00:00 2001 From: hannah Date: Sun, 5 Nov 2023 13:56:01 -0500 Subject: [PATCH 1/3] adds path.effects rcparam support for list of (funcname, {**kwargs}) adds tests and stricter validation for path.effects parameter Co-authored-by: Thomas A Caswell Co-authored-by: Ben Root Co-authored-by: Antony Lee --- lib/matplotlib/__init__.py | 9 +++- lib/matplotlib/mpl-data/matplotlibrc | 4 +- lib/matplotlib/patheffects.py | 3 ++ lib/matplotlib/rcsetup.py | 37 +++++++++++++- lib/matplotlib/rcsetup.pyi | 3 ++ lib/matplotlib/tests/test_rcparams.py | 74 ++++++++++++++++++++++++++- 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 53f27c46314a..425b9a464916 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -163,7 +163,6 @@ from matplotlib._api import MatplotlibDeprecationWarning from matplotlib.rcsetup import validate_backend, cycler - _log = logging.getLogger(__name__) __bibtex__ = r"""@Article{Hunter:2007, @@ -764,6 +763,14 @@ def __getitem__(self, key): from matplotlib import pyplot as plt plt.switch_backend(rcsetup._auto_backend_sentinel) + elif key == "path.effects" and self is globals().get("rcParams"): + # defers loading of patheffects to avoid circular imports + import matplotlib.patheffects as path_effects + # use patheffects object or instantiate patheffects.object(**kwargs) + return [pe if isinstance(pe, path_effects.AbstractPathEffect) + else getattr(path_effects, pe[0])(**pe[1]) + for pe in self._get('path.effects')] + return self._get(key) def _get_backend_or_none(self): diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 301afc38456b..1137ab16ba1a 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -677,7 +677,9 @@ # line (in pixels). # - *randomness* is the factor by which the length is # randomly scaled. -#path.effects: +#path.effects: # list of (patheffects function name, {**kwargs} tuples + # ('withStroke', {'linewidth': 4}), ('SimpleLineShadow') + ## *************************************************************************** diff --git a/lib/matplotlib/patheffects.py b/lib/matplotlib/patheffects.py index 5bb4c8e2a501..85da66f442d7 100644 --- a/lib/matplotlib/patheffects.py +++ b/lib/matplotlib/patheffects.py @@ -161,6 +161,9 @@ class Normal(AbstractPathEffect): no special path effect. """ + def __init__(self, offset=(0., 0.)): + super().__init__(offset) + def _subclass_with_normal(effect_class): """ diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 38d4606024d3..6246f5c861be 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -568,6 +568,41 @@ def validate_sketch(s): raise ValueError("Expected a (scale, length, randomness) tuple") from exc +def validate_path_effects(s): + if not s: + return [] + if isinstance(s, str) and s.strip().startswith("("): + s = ast.literal_eval(s) + + _validate_name = ValidateInStrings("path.effects.function", + ["Normal", + "PathPatchEffect", + "SimpleLineShadow", + "SimplePatchShadow", + "Stroke", + "TickedStroke", + "withSimplePatchShadow", + "withStroke", + "withTickedStroke"]) + + def _validate_dict(d): + if not isinstance(d, dict): + raise ValueError("Expected a dictionary of keyword arguments") + return d + + try: + # cast to list for the 1 tuple case + s = [s] if isinstance(s[0], str) else s + # patheffects.{AbstractPathEffect} object or (_valid_name, {**kwargs}) + return [pe if getattr(pe, '__module__', "") == 'matplotlib.patheffects' + else (_validate_name(pe[0].strip()), + {} if len(pe) < 2 else _validate_dict(pe[1])) + for pe in s] + except TypeError: + raise ValueError("Expected a list of patheffects functions" + " or (funcname, {**kwargs}) tuples") + + def _validate_greaterthan_minushalf(s): s = validate_float(s) if s > -0.5: @@ -1293,7 +1328,7 @@ def _convert_validator_spec(key, conv): "path.simplify_threshold": _validate_greaterequal0_lessequal1, "path.snap": validate_bool, "path.sketch": validate_sketch, - "path.effects": validate_anylist, + "path.effects": validate_path_effects, "agg.path.chunksize": validate_int, # 0 to disable chunking # key-mappings (multi-character mappings should be a list/tuple) diff --git a/lib/matplotlib/rcsetup.pyi b/lib/matplotlib/rcsetup.pyi index 70e94a7694a9..fcad07d4b3e0 100644 --- a/lib/matplotlib/rcsetup.pyi +++ b/lib/matplotlib/rcsetup.pyi @@ -2,6 +2,7 @@ from cycler import Cycler from collections.abc import Callable, Iterable from typing import Any, Literal, TypeVar +from matplotlib.patheffects import AbstractPathEffect from matplotlib.typing import ColorType, LineStyleType, MarkEveryType interactive_bk: list[str] @@ -140,6 +141,8 @@ def _validate_linestyle(s: Any) -> LineStyleType: ... def validate_markeverylist(s: Any) -> list[MarkEveryType]: ... def validate_bbox(s: Any) -> Literal["tight", "standard"] | None: ... def validate_sketch(s: Any) -> None | tuple[float, float, float]: ... +def validate_path_effects(s: Any +) -> list[None|AbstractPathEffect|tuple[str, dict[str, Any]]]: ... def validate_hatch(s: Any) -> str: ... def validate_hatchlist(s: Any) -> list[str]: ... def validate_dashlist(s: Any) -> list[list[float]]: ... diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index e3e10145533d..25f1984422b6 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -11,6 +11,9 @@ from matplotlib import _api, _c_internal_utils import matplotlib.pyplot as plt import matplotlib.colors as mcolors +import matplotlib.patheffects as path_effects +from matplotlib.testing.decorators import check_figures_equal + import numpy as np from matplotlib.rcsetup import ( validate_bool, @@ -27,8 +30,10 @@ validate_markevery, validate_stringlist, validate_sketch, + validate_path_effects, _validate_linestyle, - _listify_validator) + _listify_validator, + ) def test_rcparams(tmp_path): @@ -629,6 +634,73 @@ def test_rcparams_legend_loc_from_file(tmp_path, value): with mpl.rc_context(fname=rc_path): assert mpl.rcParams["legend.loc"] == value +ped = [('Normal', {}), + ('Stroke', {'offset': (1, 2)}), + ('withStroke', {'linewidth': 4, 'foreground': 'w'})] + +pel = [path_effects.Normal(), + path_effects.Stroke((1, 2)), + path_effects.withStroke(linewidth=4, foreground='w')] + + +@pytest.mark.parametrize("value", [pel, ped], ids=["func", "dict"]) +def test_path_effects(value): + assert validate_path_effects(value) == value + for v in value: + assert validate_path_effects(value) == value + + +def test_path_effects_string(): + """test list of dicts properly parsed""" + pstr = "('Normal', ), " + pstr += "('Stroke', {'offset': (1, 2)})," + pstr += "('withStroke', {'linewidth': 4, 'foreground': 'w'})" + assert validate_path_effects(pstr) == ped + + +@pytest.mark.parametrize("fdict, flist", + [([ped[0]], [pel[0]]), + ([ped[1]], [pel[1]]), + ([ped[2]], [ped[2]]), + (ped, pel)], + ids=['function', 'args', 'kwargs', 'all']) +@check_figures_equal() +def test_path_effects_picture(fig_test, fig_ref, fdict, flist): + with mpl.rc_context({'path.effects': fdict}): + fig_test.subplots().plot([1, 2, 3]) + + with mpl.rc_context({'path.effects': flist}): + fig_ref.subplots().plot([1, 2, 3]) + + +@pytest.mark.parametrize("s, msg", [ + ([1, 2, 3], "Expected a list of patheffects .*"), + (("Happy", ), r".* 'Happy' is not a valid value for path\.effects\.function.*"), + (("Normal", [1, 2, 3]), "Expected a dictionary .*"),]) +def test_validate_path_effect_errors(s, msg): + with pytest.raises(ValueError, match=msg): + mpl.rcParams['path.effects'] = s + + +def test_path_effects_wrong_kwargs(): + mpl.rcParams['path.effects'] = [('Normal', {'invalid_kwarg': 1})] + + msg = ".* got an unexpected keyword argument 'invalid_kwarg'" + with pytest.raises(TypeError, match=msg): + mpl.rcParams.get('path.effects') + + +def test_path_effects_from_file(tmpdir): + # rcParams['legend.loc'] should be settable from matplotlibrc. + # if any of these are not allowed, an exception will be raised. + # test for gh issue #22338 + rc_path = tmpdir.join("matplotlibrc") + rc_path.write("path.effects: ('Normal', {}), ('withStroke', {'linewidth': 2})") + + with mpl.rc_context(fname=rc_path): + assert isinstance(mpl.rcParams["path.effects"][0], path_effects.Normal) + assert isinstance(mpl.rcParams["path.effects"][1], path_effects.withStroke) + @pytest.mark.parametrize("value", [(1, 2, 3), '1, 2, 3', '(1, 2, 3)']) def test_validate_sketch(value): From 0078937c08042342fffb6d0d7f62ed38cd0ce36f Mon Sep 17 00:00:00 2001 From: hannah Date: Sun, 5 Nov 2023 14:02:45 -0500 Subject: [PATCH 2/3] Adds xkcd style sheet, replicates tests using plt.xkcd with xkcd style, Co-authored-by: Antony Lee --- doc/users/next_whats_new/sketch_xkcd.rst | 26 +++++++++++ .../style_sheets/style_sheets_reference.py | 44 +++++++++---------- .../mpl-data/stylelib/xkcd.mplstyle | 30 +++++++++++++ lib/matplotlib/pyplot.py | 22 +--------- lib/matplotlib/tests/test_path.py | 28 ++++++++++++ lib/matplotlib/tests/test_style.py | 28 ++++++++++++ 6 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 doc/users/next_whats_new/sketch_xkcd.rst create mode 100644 lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle diff --git a/doc/users/next_whats_new/sketch_xkcd.rst b/doc/users/next_whats_new/sketch_xkcd.rst new file mode 100644 index 000000000000..c186ed3edaef --- /dev/null +++ b/doc/users/next_whats_new/sketch_xkcd.rst @@ -0,0 +1,26 @@ +path.effects rcParam can be set in stylesheet and new xkcd stylesheet +--------------------------------------------------------------------- + +Can now set the ``path.effects`` :ref:`rcParam in a style sheet ` +using a list of ``(patheffects function name, {**kwargs})``:: + + path.effects: ('Normal', ), ('Stroke', {'offset': (1, 2)}), ('withStroke', {'linewidth': 4, 'foreground': 'w'}) + + +This feature means that the xkcd style can be used like any other stylesheet: + +.. plot:: + :include-source: true + :alt: plot where graph and text appear in a hand drawn comic like style + + import numpy as np + import matplotlib.pyplot as plt + + x = np.linspace(0, 2* np.pi, 100) + y = np.sin(x) + + with plt.style.context('xkcd'): + + fig, ax = plt.subplots() + ax.set_title("sine curve") + ax.plot(x, y) diff --git a/galleries/examples/style_sheets/style_sheets_reference.py b/galleries/examples/style_sheets/style_sheets_reference.py index 43b9c4f941ee..1303ef6e6e07 100644 --- a/galleries/examples/style_sheets/style_sheets_reference.py +++ b/galleries/examples/style_sheets/style_sheets_reference.py @@ -23,6 +23,7 @@ import matplotlib.pyplot as plt import numpy as np +import matplotlib as mpl import matplotlib.colors as mcolors from matplotlib.patches import Rectangle @@ -47,7 +48,7 @@ def plot_colored_lines(ax): def sigmoid(t, t0): return 1 / (1 + np.exp(-(t - t0))) - nb_colors = len(plt.rcParams['axes.prop_cycle']) + nb_colors = len(mpl.rcParams['axes.prop_cycle']) shifts = np.linspace(-5, 5, nb_colors) amplitudes = np.linspace(1, 1.5, nb_colors) for t0, a in zip(shifts, amplitudes): @@ -75,14 +76,15 @@ def plot_colored_circles(ax, prng, nb_samples=15): the color cycle, because different styles may have different numbers of colors. """ - for sty_dict, j in zip(plt.rcParams['axes.prop_cycle'](), + for sty_dict, j in zip(mpl.rcParams['axes.prop_cycle'](), range(nb_samples)): ax.add_patch(plt.Circle(prng.normal(scale=3, size=2), radius=1.0, color=sty_dict['color'])) ax.grid(visible=True) # Add title for enabling grid - plt.title('ax.grid(True)', family='monospace', fontsize='small') + font_family = mpl.rcParams.get('font.family', 'monospace') + ax.set_title('ax.grid(True)', family=font_family, fontsize='medium') ax.set_xlim([-4, 8]) ax.set_ylim([-5, 6]) @@ -133,11 +135,12 @@ def plot_figure(style_label=""): # make a suptitle, in the same style for all subfigures, # except those with dark backgrounds, which get a lighter color: background_color = mcolors.rgb_to_hsv( - mcolors.to_rgb(plt.rcParams['figure.facecolor']))[2] + mcolors.to_rgb(mpl.rcParams['figure.facecolor']))[2] if background_color < 0.5: title_color = [0.8, 0.8, 1] else: title_color = np.array([19, 6, 84]) / 256 + fig.suptitle(style_label, x=0.01, ha='left', color=title_color, fontsize=14, fontfamily='DejaVu Sans', fontweight='normal') @@ -147,28 +150,25 @@ def plot_figure(style_label=""): plot_colored_lines(axs[3]) plot_histograms(axs[4], prng) plot_colored_circles(axs[5], prng) - # add divider rec = Rectangle((1 + 0.025, -2), 0.05, 16, clip_on=False, color='gray') axs[4].add_artist(rec) -if __name__ == "__main__": - - # Set up a list of all available styles, in alphabetical order but - # the `default` and `classic` ones, which will be forced resp. in - # first and second position. - # styles with leading underscores are for internal use such as testing - # and plot types gallery. These are excluded here. - style_list = ['default', 'classic'] + sorted( - style for style in plt.style.available - if style != 'classic' and not style.startswith('_')) - - # Plot a demonstration figure for every available style sheet. - for style_label in style_list: - with plt.rc_context({"figure.max_open_warning": len(style_list)}): - with plt.style.context(style_label): - plot_figure(style_label=style_label) - plt.show() +# Set up a list of all available styles, in alphabetical order but +# the `default` and `classic` ones, which will be forced resp. in +# first and second position. +# styles with leading underscores are for internal use such as testing +# and plot types gallery. These are excluded here. +style_list = ['default', 'classic'] + sorted( + style for style in mpl.style.available + if style != 'classic' and not style.startswith('_')) + +# Plot a demonstration figure for every available style sheet: +for style_label in style_list: + with mpl.rc_context({"figure.max_open_warning": len(style_list)}): + with mpl.style.context(style_label, after_reset=True): + plot_figure(style_label=style_label) + plt.show() diff --git a/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle b/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle new file mode 100644 index 000000000000..7f548d959633 --- /dev/null +++ b/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle @@ -0,0 +1,30 @@ +## default xkcd style + +# line +lines.linewidth : 2.0 + +# font +font.family : xkcd, xkcd Script, Comic Neue, Comic Sans MS +font.size : 14.0 + +# axes +axes.linewidth : 1.5 +axes.grid : False +axes.unicode_minus: False +axes.edgecolor: black + +# ticks +xtick.major.size : 8 +xtick.major.width: 3 +ytick.major.size : 8 +ytick.major.width: 3 + +# grids +grid.linewidth: 0.0 + +# figure +figure.facecolor: white + +# path +path.sketch : 1, 100, 2 +path.effects: ('withStroke', {'linewidth': 4, 'foreground': 'w' }) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 1255818b41b5..40289e24cbee 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -748,26 +748,8 @@ def xkcd( stack = ExitStack() stack.callback(dict.update, rcParams, rcParams.copy()) # type: ignore - from matplotlib import patheffects - rcParams.update({ - 'font.family': ['xkcd', 'xkcd Script', 'Comic Neue', 'Comic Sans MS'], - 'font.size': 14.0, - 'path.sketch': (scale, length, randomness), - 'path.effects': [ - patheffects.withStroke(linewidth=4, foreground="w")], - 'axes.linewidth': 1.5, - 'lines.linewidth': 2.0, - 'figure.facecolor': 'white', - 'grid.linewidth': 0.0, - 'axes.grid': False, - 'axes.unicode_minus': False, - 'axes.edgecolor': 'black', - 'xtick.major.size': 8, - 'xtick.major.width': 3, - 'ytick.major.size': 8, - 'ytick.major.width': 3, - }) - + rcParams.update({**style.library["xkcd"], + 'path.sketch': (scale, length, randomness)}) return stack diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 8c0c32dc133b..3968bd808058 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -253,6 +253,18 @@ def test_xkcd(): ax.plot(x, y) +@image_comparison(['xkcd.png'], remove_text=True) +def test_xkcd_style(): + np.random.seed(0) + + x = np.linspace(0, 2 * np.pi, 100) + y = np.sin(x) + + with plt.style.context('xkcd'): + fig, ax = plt.subplots() + ax.plot(x, y) + + @image_comparison(['xkcd_marker.png'], remove_text=True) def test_xkcd_marker(): np.random.seed(0) @@ -269,6 +281,22 @@ def test_xkcd_marker(): ax.plot(x, y3, '^', ms=10) +@image_comparison(['xkcd_marker.png'], remove_text=True) +def test_xkcd_marker_style(): + np.random.seed(0) + + x = np.linspace(0, 5, 8) + y1 = x + y2 = 5 - x + y3 = 2.5 * np.ones(8) + + with plt.style.context('xkcd'): + fig, ax = plt.subplots() + ax.plot(x, y1, '+', ms=10) + ax.plot(x, y2, 'o', ms=10) + ax.plot(x, y3, '^', ms=10) + + @image_comparison(['marker_paths.pdf'], remove_text=True) def test_marker_paths_pdf(): N = 7 diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py index be038965e33d..1c9318c9a368 100644 --- a/lib/matplotlib/tests/test_style.py +++ b/lib/matplotlib/tests/test_style.py @@ -8,6 +8,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt, style +from matplotlib.testing.decorators import check_figures_equal from matplotlib.style.core import USER_LIBRARY_PATHS, STYLE_EXTENSION @@ -169,6 +170,14 @@ def test_xkcd_no_cm(): assert mpl.rcParams["path.sketch"] == (1, 100, 2) +def test_xkcd_no_cm_style(): + assert mpl.rcParams["path.sketch"] is None + plt.style.use('xkcd') + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + np.testing.break_cycles() + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + + def test_xkcd_cm(): assert mpl.rcParams["path.sketch"] is None with plt.xkcd(): @@ -176,6 +185,25 @@ def test_xkcd_cm(): assert mpl.rcParams["path.sketch"] is None +def test_xkcd_cm_style(): + assert mpl.rcParams["path.sketch"] is None + with style.context('xkcd'): + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + assert mpl.rcParams["path.sketch"] is None + + +@check_figures_equal() +def test_xkcd_style(fig_test, fig_ref): + + with style.context('xkcd'): + fig_test.subplots().plot([1, 2, 3]) + fig_test.text(.5, .5, "Hello World!") + + with plt.xkcd(): + fig_ref.subplots().plot([1, 2, 3]) + fig_ref.text(.5, .5, "Hello World!") + + def test_up_to_date_blacklist(): assert mpl.style.core.STYLE_BLACKLIST <= {*mpl.rcsetup._validators} From 0c9af391e9a329d7cf855674e254a3ec7fe5c04b Mon Sep 17 00:00:00 2001 From: hannah Date: Mon, 13 Nov 2023 18:46:36 -0500 Subject: [PATCH 3/3] add discouraged admonishment to plt.xkcd --- lib/matplotlib/pyplot.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 40289e24cbee..22a62a4ab261 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -705,13 +705,29 @@ def setp(obj, *args, **kwargs): def xkcd( scale: float = 1, length: float = 100, randomness: float = 2 ) -> ExitStack: - """ - Turn on `xkcd `_ sketch-style drawing mode. + r""" + [*Discouraged*] Turn on `xkcd `_ sketch-style drawing mode. + + .. admonition:: Discouraged + + The use of ``plt.xkcd()`` is discouraged; instead use + the ``xkcd`` style sheet:: + + plt.style.use('xkcd') + with plt.style.use('xkcd'): + + Instead of passing in arguments, modify the ``rcParam``:: + + import matplotlib as mpl + + mpl.rcParams['path.sketch'] = (scale, length, randomness) + + For more information, see :ref:`customizing` - This will only have an effect on things drawn after this function is called. - For best results, install the `xkcd script `_ - font; xkcd fonts are not packaged with Matplotlib. + This drawing mode only affects things drawn after this function is called. + For best results, the "xkcd script" font should be installed; it is + not included with Matplotlib. Parameters ---------- pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy