From a59d1d27c90d1bb7da203251febca72ea04f0eaa Mon Sep 17 00:00:00 2001 From: Steve Berardi <6608085+steveberardi@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:28:51 -0800 Subject: [PATCH 01/18] Backport PR #30960: SVG backend - handle font weight as integer --- lib/matplotlib/backends/backend_svg.py | 3 ++- lib/matplotlib/font_manager.py | 2 +- lib/matplotlib/tests/test_backend_svg.py | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 2193dc6b6cdc..7789ec2cd882 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1132,7 +1132,8 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): font_style['font-style'] = prop.get_style() if prop.get_variant() != 'normal': font_style['font-variant'] = prop.get_variant() - weight = fm.weight_dict[prop.get_weight()] + weight = prop.get_weight() + weight = fm.weight_dict.get(weight, weight) # convert to int if weight != 400: font_style['font-weight'] = f'{weight}' diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 9aa8dccde444..a44d5bd76b9a 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -744,7 +744,7 @@ def get_variant(self): def get_weight(self): """ - Set the font weight. Options are: A numeric value in the + Get the font weight. Options are: A numeric value in the range 0-1000 or one of 'light', 'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', 'extra bold', 'black' diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 509136fe323e..0f9f5f19afb5 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -73,7 +73,8 @@ def test_bold_font_output(): ax.plot(np.arange(10), np.arange(10)) ax.set_xlabel('nonbold-xlabel') ax.set_ylabel('bold-ylabel', fontweight='bold') - ax.set_title('bold-title', fontweight='bold') + # set weight as integer to assert it's handled properly + ax.set_title('bold-title', fontweight=600) @image_comparison(['bold_font_output_with_none_fonttype.svg']) @@ -83,7 +84,8 @@ def test_bold_font_output_with_none_fonttype(): ax.plot(np.arange(10), np.arange(10)) ax.set_xlabel('nonbold-xlabel') ax.set_ylabel('bold-ylabel', fontweight='bold') - ax.set_title('bold-title', fontweight='bold') + # set weight as integer to assert it's handled properly + ax.set_title('bold-title', fontweight=600) @check_figures_equal(tol=20) From bc4431823a9d02dd35367c04aee0a9a4d8ad5c6d Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:07:29 +0100 Subject: [PATCH 02/18] Backport PR #30952: DOC: Tutorial on API shortcuts --- galleries/tutorials/coding_shortcuts.py | 172 ++++++++++++++++++++++++ galleries/tutorials/index.rst | 18 +++ 2 files changed, 190 insertions(+) create mode 100644 galleries/tutorials/coding_shortcuts.py diff --git a/galleries/tutorials/coding_shortcuts.py b/galleries/tutorials/coding_shortcuts.py new file mode 100644 index 000000000000..46868482598f --- /dev/null +++ b/galleries/tutorials/coding_shortcuts.py @@ -0,0 +1,172 @@ +""" +================ +Coding shortcuts +================ + +Matplotlib's primary and universal API is the :ref:`Axes interface `. +While it is clearly structured and powerful, it can sometimes feel overly verbose and +thus cumbersome to write. This page collects patterns for condensing the code +of the Axes-based API and achieving the same results with less typing for many simpler +plots. + +.. note:: + + The :ref:`pyplot interface ` is an alternative more compact + interface, and was historically modeled to be similar to MATLAB. It remains a + valid approach for those who want to use it. However, it has the disadvantage that + it achieves its brevity through implicit assumptions that you have to understand. + + Since it follows a different paradigm, switching between the Axes interface and + the pyplot interface requires a shift of the mental model, and some code rewrite, + if the code develops to a point at which pyplot no longer provides enough + flexibility. + +This tutorial goes the other way round, starting from the standard verbose Axes +interface and using its capabilities for shortcuts when you don't need all the +generality. + +Let's assume we want to make a plot of the number of daylight hours per day over the +year in London. + +The standard approach with the Axes interface looks like this. +""" + +import matplotlib.pyplot as plt +import numpy as np + +day = np.arange(365) +hours = 4.276 * np.sin(2 * np.pi * (day - 80)/365) + 12.203 + +fig, ax = plt.subplots() +ax.plot(day, hours, color="orange") +ax.set_xlabel("day") +ax.set_ylabel("daylight hours") +ax.set_title("London") +plt.show() + +# %% +# Note that we've included ``plt.show()`` here. This is needed to show the plot window +# when running from a command line or in a Python script. If you run a Jupyter notebook, +# this command is automatically executed at the end of each cell. +# +# For the rest of the tutorial, we'll assume that we are in a notebook and leave this +# out for brevity. Depending on your context you may still need it. +# +# If you instead want to save to a file, use ``fig.savefig("daylight.png")``. +# +# +# Collect Axes properties into a single ``set()`` call +# ==================================================== +# +# The properties of Matplotlib Artists can be modified through their respective +# ``set_*()`` methods. Artists additionally have a generic ``set()`` method, that takes +# keyword arguments and is equivalent to calling all the respective ``set_*()`` methods. +# :: +# +# ax.set_xlabel("day") +# ax.set_ylabel("daylight hours") +# +# can also be written as :: +# +# ax.set(xlabel="day", ylabel="daylight hours") +# +# This is the most simple and effective reduction you can do. With that we can shorten +# the above plot to + +fig, ax = plt.subplots() +ax.plot(day, hours, color="orange") +ax.set(xlabel="day", ylabel="daylight hours", title="London") + +# %% +# +# This works as long as you only need to pass one parameter to the ``set_*()`` function. +# The individual functions are still necessary if you want more control, e.g. +# ``set_title("London", fontsize=16)``. +# +# +# Not storing a reference to the figure +# ===================================== +# Another nuisance of ``fig, ax = plt.subplots()`` is that you always create a ``fig`` +# variable, even if you don't use it. A slightly shorter version would be using the +# standard variable for unused value in Python (``_``): ``_, ax = plt.subplots()``. +# However, that's only marginally better. +# +# You can work around this by separating figure and Axes creation and chaining them :: +# +# ax = plt.figure().add_subplot() +# +# This is a bit cleaner logically and has the slight advantage that you could set +# figure properties inline as well; e.g. ``plt.figure(facecolor="lightgoldenrod")``. +# But it has the disadvantage that it's longer than ``fig, ax = plt.subplots()``. +# +# You can still obtain the figure from the Axes if needed, e.g. :: +# +# ax.figure.savefig("daylight_hours.png") +# +# The example code now looks like this: + +ax = plt.figure().add_subplot() +ax.plot(day, hours, color="orange") +ax.set(xlabel="day", ylabel="daylight hours", title="London") + +# %% +# Define Axes properties during axes creation +# =========================================== +# The ``set_*`` methods as well as ``set`` modify existing objects. You can +# alternatively define them right at creation. Since you typically don't instantiate +# classes yourself in Matplotlib, but rather call some factory function that creates +# the object and wires it up correctly with the plot, this may seem less obvious. But +# in fact you just pass the desired properties to the factory functions. You are likely +# doing this already in some places without realizing. Consider the function to create +# a line :: +# +# ax.plot(x, y, color="orange") +# +# This is equivalent to :: +# +# line, = ax.plot(x, y) +# line.set_color("orange") +# +# The same can be done with functions that create Axes. + +ax = plt.figure().add_subplot(xlabel="day", ylabel="daylight hours", title="London") +ax.plot(day, hours, color="orange") + +# %% +# .. important:: +# The Axes properties are only accepted as keyword arguments by +# `.Figure.add_subplot`, which creates a single Axes. +# +# For `.Figure.subplots` and `.pyplot.subplots`, you'd have to pass the properties +# as a dict via the keyword argument ``subplot_kw``. The limitation here is that +# such parameters are given to all Axes. For example, if you need two subplots +# (``fig, (ax1, ax2) = plt.subplots(1, 2)``) with different labels, you have to +# set them individually. +# +# Defining Axes properties during creation is best used for single subplots or when +# all subplots share the same properties. +# +# +# Using implicit figure creation +# ============================== +# You can go even further by tapping into the pyplot logic and use `.pyplot.axes` to +# create the axes: + +ax = plt.axes(xlabel="day", ylabel="daylight hours", title="London") +ax.plot(day, hours, color="orange") + +# %% +# .. warning:: +# When using this, you have to be aware of the implicit figure semantics of pyplot. +# ``plt.axes()`` will only create a new figure if no figure exists. Otherwise, it +# will add the Axes to the current existing figure, which is likely not what you +# want. +# +# Not storing a reference to the Axes +# =================================== +# If you only need to visualize one dataset, you can append the plot command +# directly to the Axes creation. This may be useful e.g. in notebooks, +# where you want to create a plot with some configuration, but as little distracting +# code as possible: + +plt.axes(xlabel="day", ylabel="daylight hours").plot(day, hours, color="orange") diff --git a/galleries/tutorials/index.rst b/galleries/tutorials/index.rst index 48187a862a2e..76c0037dca11 100644 --- a/galleries/tutorials/index.rst +++ b/galleries/tutorials/index.rst @@ -32,6 +32,23 @@ a :ref:`FAQ ` in our :ref:`user guide `. +.. raw:: html + +
+ +.. only:: html + + .. image:: /tutorials/images/thumb/sphx_glr_coding_shortcuts_thumb.png + :alt: Coding shortcuts + + :ref:`sphx_glr_tutorials_coding_shortcuts.py` + +.. raw:: html + +
Coding shortcuts
+
+ + .. raw:: html
@@ -92,6 +109,7 @@ a :ref:`FAQ ` in our :ref:`user guide `. :hidden: /tutorials/pyplot + /tutorials/coding_shortcuts /tutorials/images /tutorials/lifecycle /tutorials/artists From 8c7d40fb2771abf78e9a1391c938e840c65aead0 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:34:01 +0000 Subject: [PATCH 03/18] Backport PR #30969: DOC: Simplify barh() example --- galleries/examples/lines_bars_and_markers/barh.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/galleries/examples/lines_bars_and_markers/barh.py b/galleries/examples/lines_bars_and_markers/barh.py index 5493c7456c75..c8834e252cb6 100644 --- a/galleries/examples/lines_bars_and_markers/barh.py +++ b/galleries/examples/lines_bars_and_markers/barh.py @@ -15,12 +15,10 @@ # Example data people = ('Tom', 'Dick', 'Harry', 'Slim', 'Jim') -y_pos = np.arange(len(people)) performance = 3 + 10 * np.random.rand(len(people)) error = np.random.rand(len(people)) -ax.barh(y_pos, performance, xerr=error, align='center') -ax.set_yticks(y_pos, labels=people) +ax.barh(people, performance, xerr=error, align='center') ax.invert_yaxis() # labels read top-to-bottom ax.set_xlabel('Performance') ax.set_title('How fast do you want to go today?') From 89a35d8aaf4c7c4d15f0cc0876293f4c6156bbd9 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:01:03 -0700 Subject: [PATCH 04/18] Backport PR #30985: MNT: do not assign a numpy array shape --- lib/matplotlib/tests/test_axes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 159fc70282b8..b9eb145b1410 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2182,9 +2182,8 @@ def test_pcolor_regression(pd): time_axis, y_axis = np.meshgrid(times, y_vals) shape = (len(y_vals) - 1, len(times) - 1) - z_data = np.arange(shape[0] * shape[1]) + z_data = np.arange(shape[0] * shape[1]).reshape(shape) - z_data.shape = shape try: register_matplotlib_converters() From 2deb54c8d78ba071ba4042af1be81bea79b6a164 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:37:14 +0000 Subject: [PATCH 05/18] Backport PR #31035: DOCS: Fix typo in time array step size comment --- galleries/examples/animation/double_pendulum.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galleries/examples/animation/double_pendulum.py b/galleries/examples/animation/double_pendulum.py index 7a42a6d989ba..76076341d5c2 100644 --- a/galleries/examples/animation/double_pendulum.py +++ b/galleries/examples/animation/double_pendulum.py @@ -51,7 +51,7 @@ def derivs(t, state): return dydx -# create a time array from 0..t_stop sampled at 0.02 second steps +# create a time array from 0..t_stop sampled at 0.01 second steps dt = 0.01 t = np.arange(0, t_stop, dt) From 313a44d28fea53da5db65a69d41b7009af91a780 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 4 Feb 2026 15:41:41 -0500 Subject: [PATCH 06/18] Backport PR #31075: Fix remove method for figure title and xy-labels --- lib/matplotlib/figure.py | 7 +++++++ lib/matplotlib/tests/test_figure.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 089141727189..e5794954abb8 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -376,10 +376,17 @@ def _suplabels(self, t, info, **kwargs): else: suplab = self.text(x, y, t, **kwargs) setattr(self, info['name'], suplab) + suplab._remove_method = functools.partial(self._remove_suplabel, + name=info['name']) + suplab._autopos = autopos self.stale = True return suplab + def _remove_suplabel(self, label, name): + self.texts.remove(label) + setattr(self, name, None) + @_docstring.Substitution(x0=0.5, y0=0.98, name='super title', ha='center', va='top', rc='title') @_docstring.copy(_suplabels) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 3a4ced254091..d24f701b4085 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -364,6 +364,28 @@ def test_get_suptitle_supxlabel_supylabel(): assert fig.get_supylabel() == 'supylabel' +def test_remove_suptitle_supxlabel_supylabel(): + fig = plt.figure() + + title = fig.suptitle('suptitle') + xlabel = fig.supxlabel('supxlabel') + ylabel = fig.supylabel('supylabel') + + assert len(fig.texts) == 3 + assert fig._suptitle is not None + assert fig._supxlabel is not None + assert fig._supylabel is not None + + title.remove() + assert fig._suptitle is None + xlabel.remove() + assert fig._supxlabel is None + ylabel.remove() + assert fig._supylabel is None + + assert not fig.texts + + @image_comparison(['alpha_background'], # only test png and svg. The PDF output appears correct, # but Ghostscript does not preserve the background color. From b9d7e7f73eee09ebd33e9c8505c5d0c4d8866dcf Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 13 Feb 2026 13:22:13 -0600 Subject: [PATCH 07/18] Backport PR #31153: TST: Use correct method of clearing mock objects --- lib/matplotlib/tests/test_backends_interactive.py | 2 +- lib/matplotlib/tests/test_colors.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 4e3c1bbc2bb5..594681391e61 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -648,7 +648,7 @@ def _impl_test_interactive_timers(): assert mock.call_count > 1 # Now turn it into a single shot timer and verify only one gets triggered - mock.call_count = 0 + mock.reset_mock() timer.single_shot = True timer.start() plt.pause(pause_time) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 7da4bab69c15..7ca9c0f77858 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1590,7 +1590,7 @@ def test_norm_callback(): assert increment.call_count == 2 # We only want autoscale() calls to send out one update signal - increment.call_count = 0 + increment.reset_mock() norm.autoscale([0, 1, 2]) assert increment.call_count == 1 From ef19bdcc9a1b199c00d9948cf73044b9cdfea435 Mon Sep 17 00:00:00 2001 From: Aman Srivastava <160766756+aman-coder03@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:04:28 +0530 Subject: [PATCH 08/18] Backport PR #31278: Fix `clabel` manual argument not accepting unit-typed coordinates --- lib/matplotlib/contour.py | 2 ++ lib/matplotlib/tests/test_datetime.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index f7318d578121..4f3e594e9202 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -446,6 +446,8 @@ def add_label_near(self, x, y, inline=True, inline_spacing=5, if transform is None: transform = self.axes.transData if transform: + x = self.axes.convert_xunits(x) + y = self.axes.convert_yunits(y) x, y = transform.transform((x, y)) idx_level_min, idx_vtx_min, proj = self._find_nearest_contour( diff --git a/lib/matplotlib/tests/test_datetime.py b/lib/matplotlib/tests/test_datetime.py index 276056d044ae..9fc133549017 100644 --- a/lib/matplotlib/tests/test_datetime.py +++ b/lib/matplotlib/tests/test_datetime.py @@ -259,11 +259,22 @@ def test_bxp(self): ax.xaxis.set_major_formatter(mpl.dates.DateFormatter("%Y-%m-%d")) ax.set_title('Box plot with datetime data') - @pytest.mark.xfail(reason="Test for clabel not written yet") @mpl.style.context("default") def test_clabel(self): + dates = [datetime.datetime(2023, 10, 1) + datetime.timedelta(days=i) + for i in range(10)] + x = np.arange(-10.0, 5.0, 0.5) + X, Y = np.meshgrid(x, dates) + Z = np.arange(X.size).reshape(X.shape) + fig, ax = plt.subplots() - ax.clabel(...) + CS = ax.contour(X, Y, Z) + labels = ax.clabel(CS, manual=[(x[0], dates[0])]) + assert len(labels) == 1 + assert labels[0].get_text() == '0' + x_pos, y_pos = labels[0].get_position() + assert x_pos == pytest.approx(-10.0, abs=1e-3) + assert y_pos == pytest.approx(mpl.dates.date2num(dates[0]), abs=1e-3) @mpl.style.context("default") def test_contour(self): From f32af1d8e71ee524453155c0265a46f913028860 Mon Sep 17 00:00:00 2001 From: Vikash Kumar <163628932+Vikash-Kumar-23@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:38:39 +0530 Subject: [PATCH 09/18] Backport PR #31323: FIX: Prevent crash when removing a subfigure containing subplots --- lib/matplotlib/artist.py | 8 +++++--- lib/matplotlib/tests/test_figure.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index c87c789048c4..25515da77ed7 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -240,10 +240,12 @@ def remove(self): # clear stale callback self.stale_callback = None _ax_flag = False - if hasattr(self, 'axes') and self.axes: + ax = getattr(self, 'axes', None) + mouseover_set = getattr(ax, '_mouseover_set', None) + if mouseover_set is not None: # remove from the mouse hit list - self.axes._mouseover_set.discard(self) - self.axes.stale = True + mouseover_set.discard(self) + ax.stale = True self.axes = None # decouple the artist from the Axes _ax_flag = True diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 3a4ced254091..7fb3598e84bb 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1551,6 +1551,7 @@ def test_subfigures_wspace_hspace(): def test_subfigure_remove(): fig = plt.figure() sfs = fig.subfigures(2, 2) + sfs[1, 1].subplots() sfs[1, 1].remove() assert len(fig.subfigs) == 3 From 6aac3dad99575575744972f7b68c80be28ec736a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 27 Mar 2026 01:43:56 -0400 Subject: [PATCH 10/18] Backport PR #31401: BLD: Temporarily pin setuptools-scm<10 BLD: Temporarily pin setuptools-scm<10 (cherry picked from commit 443c728e3af423a882d51e758c88577959e0c7d1) --- .github/workflows/cygwin.yml | 2 +- .github/workflows/tests.yml | 2 +- environment.yml | 2 +- pyproject.toml | 4 ++-- requirements/dev/build-requirements.txt | 2 +- requirements/testing/mypy.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml index bde902013412..befc83162075 100644 --- a/.github/workflows/cygwin.yml +++ b/.github/workflows/cygwin.yml @@ -181,7 +181,7 @@ jobs: export PATH="/usr/local/bin:$PATH" python -m pip install --no-build-isolation 'contourpy>=1.0.1' python -m pip install --upgrade cycler fonttools \ - packaging pyparsing python-dateutil setuptools-scm \ + packaging pyparsing python-dateutil 'setuptools-scm<10' \ -r requirements_test.txt sphinx ipython python -m pip install --upgrade pycairo 'cairocffi>=0.8' PyGObject && python -c 'import gi; gi.require_version("Gtk", "3.0"); from gi.repository import Gtk' && diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea402d954137..906c6c4408d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -243,7 +243,7 @@ jobs: # Preinstall build requirements to enable no-build-isolation builds. python -m pip install --upgrade $PRE \ 'contourpy>=1.0.1' cycler fonttools kiwisolver importlib_resources \ - packaging pillow 'pyparsing!=3.1.0' python-dateutil setuptools-scm \ + packaging pillow 'pyparsing!=3.1.0' python-dateutil 'setuptools-scm<10' \ 'meson-python>=0.13.1' 'pybind11>=2.13.2' \ -r requirements/testing/all.txt \ ${{ matrix.extra-requirements }} diff --git a/environment.yml b/environment.yml index 2a4f3eff69ea..f39ba3f93f2f 100644 --- a/environment.yml +++ b/environment.yml @@ -26,7 +26,7 @@ dependencies: - pyqt - python>=3.10 - python-dateutil>=2.1 - - setuptools_scm + - setuptools_scm<10 - wxpython # building documentation - colorspacious diff --git a/pyproject.toml b/pyproject.toml index 23c441b52c9c..96b69829e674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ requires-python = ">=3.10" dev = [ "meson-python>=0.13.1,<0.17.0", "pybind11>=2.13.2,!=2.13.3", - "setuptools_scm>=7", + "setuptools_scm>=7,<10", # Not required by us but setuptools_scm without a version, cso _if_ # installed, then setuptools_scm 8 requires at least this version. # Unfortunately, we can't do a sort of minimum-if-instaled dependency, so @@ -74,7 +74,7 @@ build-backend = "mesonpy" requires = [ "meson-python>=0.13.1,<0.17.0", "pybind11>=2.13.2,!=2.13.3", - "setuptools_scm>=7", + "setuptools_scm>=7,<10", ] [tool.meson-python.args] diff --git a/requirements/dev/build-requirements.txt b/requirements/dev/build-requirements.txt index 4d2a098c3c4f..372a7d669fb1 100644 --- a/requirements/dev/build-requirements.txt +++ b/requirements/dev/build-requirements.txt @@ -1,3 +1,3 @@ pybind11>=2.13.2,!=2.13.3 meson-python -setuptools-scm +setuptools-scm<10 diff --git a/requirements/testing/mypy.txt b/requirements/testing/mypy.txt index 0cef979a34bf..4421560494d7 100644 --- a/requirements/testing/mypy.txt +++ b/requirements/testing/mypy.txt @@ -22,5 +22,5 @@ packaging>=20.0 pillow>=8 pyparsing>=3 python-dateutil>=2.7 -setuptools_scm>=7 +setuptools_scm>=7,<10 setuptools>=64 From 3c91aec2b20adf547b102c68b37cfdedb03e7d54 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:51:07 +0200 Subject: [PATCH 11/18] Backport PR #31420: Fix outdated Savannah URL for freetype download --- subprojects/freetype-2.6.1.wrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subprojects/freetype-2.6.1.wrap b/subprojects/freetype-2.6.1.wrap index 763362b84df0..270556f0d5d3 100644 --- a/subprojects/freetype-2.6.1.wrap +++ b/subprojects/freetype-2.6.1.wrap @@ -1,5 +1,5 @@ [wrap-file] -source_url = https://download.savannah.gnu.org/releases/freetype/freetype-old/freetype-2.6.1.tar.gz +source_url = https://download.savannah.nongnu.org/releases/freetype/freetype-old/freetype-2.6.1.tar.gz source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.6.1/freetype-2.6.1.tar.gz source_filename = freetype-2.6.1.tar.gz source_hash = 0a3c7dfbda6da1e8fce29232e8e96d987ababbbf71ebc8c75659e4132c367014 From 69e6954018d082dd2c396b312b4ce40922176092 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 2 Apr 2026 15:28:06 -0400 Subject: [PATCH 12/18] Backport PR #31437: mathtext: Fix type inconsistency with fontmaps mathtext: Fix type inconsistency with fontmaps (cherry picked from commit fc6aa040a6e5d4a5bc6557f257f9102edd447b4b) --- ci/mypy-stubtest-allowlist.txt | 1 - lib/matplotlib/_mathtext.py | 73 +++++++++++++++------------------- lib/matplotlib/ft2font.pyi | 4 +- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 46ec06e0a9f1..85d9be15673e 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -28,7 +28,6 @@ matplotlib\.ticker\.LogitLocator\.nonsingular # Stdlib/Enum considered inconsistent (no fault of ours, I don't think) matplotlib\.backend_bases\._Mode\.__new__ -matplotlib\.units\.Number\.__hash__ # 3.6 Pending deprecations matplotlib\.figure\.Figure\.set_constrained_layout diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index cf35dc1de7db..50077c6738fa 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -331,20 +331,15 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag # Per-instance cache. self._get_info = functools.cache(self._get_info) # type: ignore[method-assign] self._fonts = {} - self.fontmap: dict[str | int, str] = {} + self.fontmap: dict[str, str] = {} filename = findfont(self.default_font_prop) default_font = get_font(filename) self._fonts['default'] = default_font self._fonts['regular'] = default_font - def _get_font(self, font: str | int) -> FT2Font: - if font in self.fontmap: - basename = self.fontmap[font] - else: - # NOTE: An int is only passed by subclasses which have placed int keys into - # `self.fontmap`, so we must cast this to confirm it to typing. - basename = T.cast(str, font) + def _get_font(self, font: str) -> FT2Font: + basename = self.fontmap.get(font, font) cached_font = self._fonts.get(basename) if cached_font is None and os.path.exists(basename): cached_font = get_font(basename) @@ -574,12 +569,13 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag # include STIX sized alternatives for glyphs if fallback is STIX if isinstance(self._fallback_font, StixFonts): stixsizedaltfonts = { - 0: 'STIXGeneral', - 1: 'STIXSizeOneSym', - 2: 'STIXSizeTwoSym', - 3: 'STIXSizeThreeSym', - 4: 'STIXSizeFourSym', - 5: 'STIXSizeFiveSym'} + '0': 'STIXGeneral', + '1': 'STIXSizeOneSym', + '2': 'STIXSizeTwoSym', + '3': 'STIXSizeThreeSym', + '4': 'STIXSizeFourSym', + '5': 'STIXSizeFiveSym', + } for size, name in stixsizedaltfonts.items(): fullpath = findfont(name) @@ -637,7 +633,7 @@ def _get_glyph(self, fontname: str, font_class: str, g = self._fallback_font._get_glyph(fontname, font_class, sym) family = g[0].family_name - if family in list(BakomaFonts._fontmap.values()): + if family in BakomaFonts._fontmap.values(): family = "Computer Modern" _log.info("Substituting symbol %s from %s", sym, family) return g @@ -658,13 +654,12 @@ def _get_glyph(self, fontname: str, font_class: str, def get_sized_alternatives_for_symbol(self, fontname: str, sym: str) -> list[tuple[str, str]]: if self._fallback_font: - return self._fallback_font.get_sized_alternatives_for_symbol( - fontname, sym) + return self._fallback_font.get_sized_alternatives_for_symbol(fontname, sym) return [(fontname, sym)] class DejaVuFonts(UnicodeFonts, metaclass=abc.ABCMeta): - _fontmap: dict[str | int, str] = {} + _fontmap: dict[str, str] = {} def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlags): # This must come first so the backend's owner is set correctly @@ -676,11 +671,11 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags) # Include Stix sized alternatives for glyphs self._fontmap.update({ - 1: 'STIXSizeOneSym', - 2: 'STIXSizeTwoSym', - 3: 'STIXSizeThreeSym', - 4: 'STIXSizeFourSym', - 5: 'STIXSizeFiveSym', + '1': 'STIXSizeOneSym', + '2': 'STIXSizeTwoSym', + '3': 'STIXSizeThreeSym', + '4': 'STIXSizeFourSym', + '5': 'STIXSizeFiveSym', }) for key, name in self._fontmap.items(): fullpath = findfont(name) @@ -718,7 +713,7 @@ class DejaVuSerifFonts(DejaVuFonts): 'sf': 'DejaVu Sans', 'tt': 'DejaVu Sans Mono', 'ex': 'DejaVu Serif Display', - 0: 'DejaVu Serif', + '0': 'DejaVu Serif', } @@ -736,7 +731,7 @@ class DejaVuSansFonts(DejaVuFonts): 'sf': 'DejaVu Sans', 'tt': 'DejaVu Sans Mono', 'ex': 'DejaVu Sans Display', - 0: 'DejaVu Sans', + '0': 'DejaVu Sans', } @@ -752,7 +747,7 @@ class StixFonts(UnicodeFonts): - handles sized alternative characters for the STIXSizeX fonts. """ - _fontmap: dict[str | int, str] = { + _fontmap = { 'rm': 'STIXGeneral', 'it': 'STIXGeneral:italic', 'bf': 'STIXGeneral:weight=bold', @@ -760,12 +755,12 @@ class StixFonts(UnicodeFonts): 'nonunirm': 'STIXNonUnicode', 'nonuniit': 'STIXNonUnicode:italic', 'nonunibf': 'STIXNonUnicode:weight=bold', - 0: 'STIXGeneral', - 1: 'STIXSizeOneSym', - 2: 'STIXSizeTwoSym', - 3: 'STIXSizeThreeSym', - 4: 'STIXSizeFourSym', - 5: 'STIXSizeFiveSym', + '0': 'STIXGeneral', + '1': 'STIXSizeOneSym', + '2': 'STIXSizeTwoSym', + '3': 'STIXSizeThreeSym', + '4': 'STIXSizeFourSym', + '5': 'STIXSizeFiveSym', } _fallback_font = None _sans = False @@ -832,10 +827,8 @@ def _map_virtual_font(self, fontname: str, font_class: str, return fontname, uniindex @functools.cache - def get_sized_alternatives_for_symbol( # type: ignore[override] - self, - fontname: str, - sym: str) -> list[tuple[str, str]] | list[tuple[int, str]]: + def get_sized_alternatives_for_symbol(self, fontname: str, + sym: str) -> list[tuple[str, str]]: fixes = { '\\{': '{', '\\}': '}', '\\[': '[', '\\]': ']', '<': '\N{MATHEMATICAL LEFT ANGLE BRACKET}', @@ -846,8 +839,8 @@ def get_sized_alternatives_for_symbol( # type: ignore[override] uniindex = get_unicode_index(sym) except ValueError: return [(fontname, sym)] - alternatives = [(i, chr(uniindex)) for i in range(6) - if self._get_font(i).get_char_index(uniindex) != 0] + alternatives = [(str(i), chr(uniindex)) for i in range(6) + if self._get_font(str(i)).get_char_index(uniindex) != 0] # The largest size of the radical symbol in STIX has incorrect # metrics that cause it to be disconnected from the stem. if sym == r'\__sqrt__': @@ -1542,7 +1535,7 @@ def __init__(self, c: str, height: float, depth: float, state: ParserState, break shift = 0.0 - if state.font != 0 or len(alternatives) == 1: + if state.font != '0' or len(alternatives) == 1: if factor is None: factor = target_total / (char.height + char.depth) state.fontsize *= factor @@ -2530,7 +2523,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Handle regular sub/superscripts constants = _get_font_constant_set(state) lc_height = last_char.height - lc_baseline = 0 + lc_baseline = 0.0 if self.is_dropsub(last_char): lc_baseline = last_char.depth diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 37281afeaafa..3c8b52a73b6b 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -194,7 +194,7 @@ class FT2Font(Buffer): _kerning_factor: int = ... ) -> None: ... if sys.version_info[:2] >= (3, 12): - def __buffer__(self, flags: int) -> memoryview: ... + def __buffer__(self, /, flags: int) -> memoryview: ... def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( @@ -286,7 +286,7 @@ class FT2Image(Buffer): def __init__(self, width: int, height: int) -> None: ... def draw_rect_filled(self, x0: int, y0: int, x1: int, y1: int) -> None: ... if sys.version_info[:2] >= (3, 12): - def __buffer__(self, flags: int) -> memoryview: ... + def __buffer__(self, /, flags: int) -> memoryview: ... @final class Glyph: From cada119f034458b6335bc7b9463618352d3ce89b Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 3 Apr 2026 16:05:56 -0500 Subject: [PATCH 13/18] Bump mimimum Ubuntu Version on Azure because 20.04 is EOL --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index cfd4f65dc775..5dce09e6d36b 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -50,7 +50,7 @@ stages: strategy: matrix: Linux_py310: - vmImage: 'ubuntu-20.04' # keep one job pinned to the oldest image + vmImage: 'ubuntu-22.04' # keep one job pinned to the oldest image python.version: '3.10' Linux_py311: vmImage: 'ubuntu-latest' From 614abdd5e38ad6cde58c5aaf6c2ca229cd29edab Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 16 Apr 2026 16:27:00 -0400 Subject: [PATCH 14/18] Backport PR #31504: Re-order variants to prioritize narrower types --- src/_backend_agg_wrapper.cpp | 4 ++-- src/ft2font_wrapper.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp index 269e2aaa9ee5..80bb14e8cfec 100644 --- a/src/_backend_agg_wrapper.cpp +++ b/src/_backend_agg_wrapper.cpp @@ -58,8 +58,8 @@ PyRendererAgg_draw_path(RendererAgg *self, static void PyRendererAgg_draw_text_image(RendererAgg *self, py::array_t image_obj, - std::variant vx, - std::variant vy, + std::variant vx, + std::variant vy, double angle, GCAgg &gc) { diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 9b54721810d6..3008c0b08b2f 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -14,7 +14,7 @@ namespace py = pybind11; using namespace pybind11::literals; template -using double_or_ = std::variant; +using double_or_ = std::variant; template static T From 2da812c083736d5109b4f27b3b4b524562d66377 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:34:07 +0100 Subject: [PATCH 15/18] Backport PR #31020: DOC: Fix doc builds with Sphinx 9 --- doc/conf.py | 1 + lib/matplotlib/backend_tools.py | 18 ++++++++++++------ lib/matplotlib/backend_tools.pyi | 6 +++--- lib/matplotlib/widgets.py | 4 ++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 292aff3e1983..45fe32f79f88 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -891,3 +891,4 @@ def setup(app): if sphinx.version_info[:2] < (7, 1): app.connect('html-page-context', add_html_cache_busting, priority=1000) generate_ScalarMappable_docs() + app.config.autodoc_use_legacy_class_based = True diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py index 87ed794022a0..784f97c0a1e4 100644 --- a/lib/matplotlib/backend_tools.py +++ b/lib/matplotlib/backend_tools.py @@ -937,6 +937,7 @@ def trigger(self, *args, **kwargs): self.toolmanager.message_event(message, self) +#: The default tools to add to a tool manager. default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward, 'zoom': ToolZoom, 'pan': ToolPan, 'subplots': ConfigureSubplotsBase, @@ -956,12 +957,13 @@ def trigger(self, *args, **kwargs): 'copy': ToolCopyToClipboardBase, } +#: The default tools to add to a container. default_toolbar_tools = [['navigation', ['home', 'back', 'forward']], ['zoompan', ['pan', 'zoom', 'subplots']], ['io', ['save', 'help']]] -def add_tools_to_manager(toolmanager, tools=default_tools): +def add_tools_to_manager(toolmanager, tools=None): """ Add multiple tools to a `.ToolManager`. @@ -971,14 +973,16 @@ def add_tools_to_manager(toolmanager, tools=default_tools): Manager to which the tools are added. tools : {str: class_like}, optional The tools to add in a {name: tool} dict, see - `.backend_managers.ToolManager.add_tool` for more info. + `.backend_managers.ToolManager.add_tool` for more info. If not specified, then + defaults to `.default_tools`. """ - + if tools is None: + tools = default_tools for name, tool in tools.items(): toolmanager.add_tool(name, tool) -def add_tools_to_container(container, tools=default_toolbar_tools): +def add_tools_to_container(container, tools=None): """ Add multiple tools to the container. @@ -990,9 +994,11 @@ def add_tools_to_container(container, tools=default_toolbar_tools): tools : list, optional List in the form ``[[group1, [tool1, tool2 ...]], [group2, [...]]]`` where the tools ``[tool1, tool2, ...]`` will display in group1. - See `.backend_bases.ToolContainerBase.add_tool` for details. + See `.backend_bases.ToolContainerBase.add_tool` for details. If not specified, + then defaults to `.default_toolbar_tools`. """ - + if tools is None: + tools = default_toolbar_tools for group, grouptools in tools: for position, tool in enumerate(grouptools): container.add_tool(tool, group, position) diff --git a/lib/matplotlib/backend_tools.pyi b/lib/matplotlib/backend_tools.pyi index 32fe8c2f5a79..fa89e3d66871 100644 --- a/lib/matplotlib/backend_tools.pyi +++ b/lib/matplotlib/backend_tools.pyi @@ -112,10 +112,10 @@ class ToolHelpBase(ToolBase): class ToolCopyToClipboardBase(ToolBase): ... -default_tools: dict[str, ToolBase] +default_tools: dict[str, type[ToolBase]] default_toolbar_tools: list[list[str | list[str]]] def add_tools_to_manager( - toolmanager: ToolManager, tools: dict[str, type[ToolBase]] = ... + toolmanager: ToolManager, tools: dict[str, type[ToolBase]] | None = ... ) -> None: ... -def add_tools_to_container(container: ToolContainerBase, tools: list[Any] = ...) -> None: ... +def add_tools_to_container(container: ToolContainerBase, tools: list[Any] | None = ...) -> None: ... diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6a3c0d684380..039d7133ded1 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -980,7 +980,7 @@ class CheckButtons(AxesWidget): For the check buttons to remain responsive you must keep a reference to this object. - Connect to the CheckButtons with the `.on_clicked` method. + Connect to the CheckButtons with the `~._Buttons.on_clicked` method. Attributes ---------- @@ -1545,7 +1545,7 @@ class RadioButtons(AxesWidget): For the buttons to remain responsive you must keep a reference to this object. - Connect to the RadioButtons with the `.on_clicked` method. + Connect to the RadioButtons with the `~._Buttons.on_clicked` method. Attributes ---------- From acc60241a70b920eaf04fce41a8cf0a77010fb7d Mon Sep 17 00:00:00 2001 From: Benjamin Root Date: Fri, 13 Mar 2026 09:07:57 -0400 Subject: [PATCH 16/18] Merge pull request #31282 from scottshambaugh/tex_no_shell SEC: Block shell escapes in latex and ps commands (cherry picked from commit 8ff895d0750f3b16c3214b38a91ad78029c82df7) The test that was edited had significant updates on main, so the old test was kept on backport and no similar call exists in the old test. --- lib/matplotlib/backends/backend_pgf.py | 6 +++--- lib/matplotlib/backends/backend_ps.py | 6 ++++-- lib/matplotlib/testing/__init__.py | 3 ++- lib/matplotlib/texmanager.py | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 48b6e8ac152c..2d2e24c3286c 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -281,7 +281,7 @@ def _setup_latex_process(self, *, expect_reply=True): # it. try: self.latex = subprocess.Popen( - [mpl.rcParams["pgf.texsystem"], "-halt-on-error"], + [mpl.rcParams["pgf.texsystem"], "-halt-on-error", "-no-shell-escape"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding="utf-8", cwd=self.tmpdir) except FileNotFoundError as err: @@ -848,7 +848,7 @@ def print_pdf(self, fname_or_fh, *, metadata=None, **kwargs): texcommand = mpl.rcParams["pgf.texsystem"] cbook._check_and_log_subprocess( [texcommand, "-interaction=nonstopmode", "-halt-on-error", - "figure.tex"], _log, cwd=tmpdir) + "-no-shell-escape", "figure.tex"], _log, cwd=tmpdir) with ((tmppath / "figure.pdf").open("rb") as orig, cbook.open_file_cm(fname_or_fh, "wb") as dest): shutil.copyfileobj(orig, dest) # copy file contents to target @@ -965,7 +965,7 @@ def _run_latex(self): tex_source.write_bytes(self._file.getvalue()) cbook._check_and_log_subprocess( [texcommand, "-interaction=nonstopmode", "-halt-on-error", - tex_source], + "-no-shell-escape", tex_source], _log, cwd=tmpdir) shutil.move(tex_source.with_suffix(".pdf"), self._output_name) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index f1f914ae5420..4dfdb2a6a095 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -1257,8 +1257,9 @@ def _convert_psfrags(tmppath, psfrags, paper_width, paper_height, orientation): with TemporaryDirectory() as tmpdir: psfile = os.path.join(tmpdir, "tmp.ps") + # -R1 is a security flag used to prevent shell command execution cbook._check_and_log_subprocess( - ['dvips', '-q', '-R0', '-o', psfile, dvifile], _log) + ['dvips', '-q', '-R1', '-o', psfile, dvifile], _log) shutil.move(psfile, tmppath) # check if the dvips created a ps in landscape paper. Somehow, @@ -1302,7 +1303,7 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): cbook._check_and_log_subprocess( [mpl._get_executable_info("gs").executable, - "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write", + "-dBATCH", "-dNOPAUSE", "-dSAFER", "-r%d" % dpi, "-sDEVICE=ps2write", *paper_option, f"-sOutputFile={psfile}", tmpfile], _log) @@ -1346,6 +1347,7 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): # happy (https://ghostscript.com/doc/9.56.1/Use.htm#MS_Windows). cbook._check_and_log_subprocess( ["ps2pdf", + "-dSAFER", "-dAutoFilterColorImages#false", "-dAutoFilterGrayImages#false", "-sAutoRotatePages#None", diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 19113d399626..73b1645468a6 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -164,7 +164,8 @@ def _check_for_pgf(texsystem): """, encoding="utf-8") try: subprocess.check_call( - [texsystem, "-halt-on-error", str(tex_path)], cwd=tmpdir, + [texsystem, "-halt-on-error", "-no-shell-escape", + str(tex_path)], cwd=tmpdir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except (OSError, subprocess.CalledProcessError): return False diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 0424aede16eb..6856329931ef 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -291,8 +291,8 @@ def make_dvi(cls, tex, fontsize): Path(tmpdir, "file.tex").write_text( cls._get_tex_source(tex, fontsize), encoding='utf-8') cls._run_checked_subprocess( - ["latex", "-interaction=nonstopmode", "--halt-on-error", - "file.tex"], tex, cwd=tmpdir) + ["latex", "-interaction=nonstopmode", "-halt-on-error", + "-no-shell-escape", "file.tex"], tex, cwd=tmpdir) Path(tmpdir, "file.dvi").replace(dvifile) # Also move the tex source to the main cache directory, but # only for backcompat. From b2ed1969191a03ec8927f96573664474662ab4c1 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:09:00 -0600 Subject: [PATCH 17/18] Backport PR #31248: SEC: Remove eval() from validate_cycler --- .../deprecations/31248-SS.rst | 9 ++ .../cycler_rcparam_security.rst | 14 ++++ lib/matplotlib/rcsetup.py | 83 ++++++++++++++----- lib/matplotlib/tests/test_rcparams.py | 24 ++++-- 4 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/31248-SS.rst create mode 100644 doc/users/next_whats_new/cycler_rcparam_security.rst diff --git a/doc/api/next_api_changes/deprecations/31248-SS.rst b/doc/api/next_api_changes/deprecations/31248-SS.rst new file mode 100644 index 000000000000..1d7adbdf0fde --- /dev/null +++ b/doc/api/next_api_changes/deprecations/31248-SS.rst @@ -0,0 +1,9 @@ +Arbitrary code in ``axes.prop_cycle`` rcParam strings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``axes.prop_cycle`` rcParam accepts Python expressions that are evaluated +in a limited context. The evaluation context has been further limited and some +expressions that previously worked (list comprehensions, for example) no longer +will. This change is made without a deprecation period to improve security. +The previously documented cycler operations at +https://matplotlib.org/cycler/ are still supported. diff --git a/doc/users/next_whats_new/cycler_rcparam_security.rst b/doc/users/next_whats_new/cycler_rcparam_security.rst new file mode 100644 index 000000000000..e4e2893aa994 --- /dev/null +++ b/doc/users/next_whats_new/cycler_rcparam_security.rst @@ -0,0 +1,14 @@ +``axes.prop_cycle`` rcParam security improvements +------------------------------------------------- + +The ``axes.prop_cycle`` rcParam is now parsed in a safer and more restricted +manner. Only literals, ``cycler()`` and ``concat()`` calls, the operators +``+`` and ``*``, and slicing are allowed. All previously valid cycler strings +documented at https://matplotlib.org/cycler/ are still supported, for example: + +.. code-block:: none + + axes.prop_cycle : cycler('color', ['r', 'g', 'b']) + cycler('linewidth', [1, 2, 3]) + axes.prop_cycle : 2 * cycler('color', 'rgb') + axes.prop_cycle : concat(cycler('color', 'rgb'), cycler('color', 'cmk')) + axes.prop_cycle : cycler('color', 'rgbcmk')[:3] diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index c23d9f818454..3a4da5f575fb 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -30,7 +30,7 @@ from matplotlib._enums import JoinStyle, CapStyle # Don't let the original cycler collide with our validating cycler -from cycler import Cycler, cycler as ccycler +from cycler import Cycler, concat as cconcat, cycler as ccycler @_api.caching_module_getattr @@ -759,11 +759,62 @@ def cycler(*args, **kwargs): return reduce(operator.add, (ccycler(k, v) for k, v in validated)) -class _DunderChecker(ast.NodeVisitor): - def visit_Attribute(self, node): - if node.attr.startswith("__") and node.attr.endswith("__"): - raise ValueError("cycler strings with dunders are forbidden") - self.generic_visit(node) +def _parse_cycler_string(s): + """ + Parse a string representation of a cycler into a Cycler object safely, + without using eval(). + + Accepts expressions like:: + + cycler('color', ['r', 'g', 'b']) + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3]) + cycler(c='rgb', lw=[1, 2, 3]) + cycler('c', 'rgb') * cycler('linestyle', ['-', '--']) + """ + try: + tree = ast.parse(s, mode='eval') + except SyntaxError as e: + raise ValueError(f"Could not parse {s!r}: {e}") from e + return _eval_cycler_expr(tree.body) + + +def _eval_cycler_expr(node): + """Recursively evaluate an AST node to build a Cycler object.""" + if isinstance(node, ast.BinOp): + left = _eval_cycler_expr(node.left) + right = _eval_cycler_expr(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Mult): + return left * right + raise ValueError(f"Unsupported operator: {type(node.op).__name__}") + if isinstance(node, ast.Call): + if not (isinstance(node.func, ast.Name) + and node.func.id in ('cycler', 'concat')): + raise ValueError( + "only the 'cycler()' and 'concat()' functions are allowed") + func = cycler if node.func.id == 'cycler' else cconcat + args = [_eval_cycler_expr(a) for a in node.args] + kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords} + return func(*args, **kwargs) + if isinstance(node, ast.Subscript): + sl = node.slice + if not isinstance(sl, ast.Slice): + raise ValueError("only slicing is supported, not indexing") + s = slice( + ast.literal_eval(sl.lower) if sl.lower else None, + ast.literal_eval(sl.upper) if sl.upper else None, + ast.literal_eval(sl.step) if sl.step else None, + ) + value = _eval_cycler_expr(node.value) + return value[s] + # Allow literal values (int, strings, lists, tuples) as arguments + # to cycler() and concat(). + try: + return ast.literal_eval(node) + except (ValueError, TypeError): + raise ValueError( + f"Unsupported expression in cycler string: {ast.dump(node)}") # A validator dedicated to the named legend loc @@ -814,25 +865,11 @@ def _validate_legend_loc(loc): def validate_cycler(s): """Return a Cycler object from a string repr or the object itself.""" if isinstance(s, str): - # TODO: We might want to rethink this... - # While I think I have it quite locked down, it is execution of - # arbitrary code without sanitation. - # Combine this with the possibility that rcparams might come from the - # internet (future plans), this could be downright dangerous. - # I locked it down by only having the 'cycler()' function available. - # UPDATE: Partly plugging a security hole. - # I really should have read this: - # https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html - # We should replace this eval with a combo of PyParsing and - # ast.literal_eval() try: - _DunderChecker().visit(ast.parse(s)) - s = eval(s, {'cycler': cycler, '__builtins__': {}}) - except BaseException as e: + s = _parse_cycler_string(s) + except Exception as e: raise ValueError(f"{s!r} is not a valid cycler construction: {e}" ) from e - # Should make sure what comes from the above eval() - # is a Cycler object. if isinstance(s, Cycler): cycler_inst = s else: @@ -1101,7 +1138,7 @@ def _convert_validator_spec(key, conv): "axes.formatter.offset_threshold": validate_int, "axes.unicode_minus": validate_bool, # This entry can be either a cycler object or a string repr of a - # cycler-object, which gets eval()'ed to create the object. + # cycler-object, which is parsed safely via AST. "axes.prop_cycle": validate_cycler, # If "data", axes limits are set close to the data. # If "round_numbers" axes limits are set to the nearest round numbers. diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index bea5e90ea4e5..d800731ac53a 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -272,16 +272,23 @@ def generate_validator_testcases(valid): cycler('linestyle', ['-', '--'])), (cycler(mew=[2, 5]), cycler('markeredgewidth', [2, 5])), + ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')), + ("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')), + ("cycler(c='r' + 'gb', lw=[1, 2, 3])", + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])), + ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2), + ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))", + cycler('color', list('rgbcmk'))), + ("cycler('color', 'rgbcmk')[:3]", cycler('color', list('rgb'))), + ("cycler('color', 'rgb')[::-1]", cycler('color', list('bgr'))), ), - # This is *so* incredibly important: validate_cycler() eval's - # an arbitrary string! I think I have it locked down enough, - # and that is what this is testing. - # TODO: Note that these tests are actually insufficient, as it may - # be that they raised errors, but still did an action prior to - # raising the exception. We should devise some additional tests - # for that... + # validate_cycler() parses an arbitrary string using a safe + # AST-based parser (no eval). These tests verify that only valid + # cycler expressions are accepted. 'fail': ((4, ValueError), # Gotta be a string or Cycler object ('cycler("bleh, [])', ValueError), # syntax error + ("cycler('color', 'rgb') * * cycler('color', 'rgb')", # syntax error + ValueError), ('Cycler("linewidth", [1, 2, 3])', ValueError), # only 'cycler()' function is allowed # do not allow dunder in string literals @@ -295,6 +302,9 @@ def generate_validator_testcases(valid): ValueError), ("cycler('c', [j.__class__(j).lower() for j in ['r', 'b']])", ValueError), + # list comprehensions are arbitrary code, even if "safe" + ("cycler('color', [x for x in ['r', 'g', 'b']])", + ValueError), ('1 + 2', ValueError), # doesn't produce a Cycler object ('os.system("echo Gotcha")', ValueError), # os not available ('import os', ValueError), # should not be able to import From a83faacb0dbe7edd1bae38e1e715b77b6aaebb84 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 23 Apr 2026 15:39:56 -0500 Subject: [PATCH 18/18] Backport PR #31556: FIX: Inverted PyErr_Occurred check in enum type caster (_enums.h) --- src/_enums.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_enums.h b/src/_enums.h index 18f3d9aac9fa..e607b93f50f2 100644 --- a/src/_enums.h +++ b/src/_enums.h @@ -80,7 +80,7 @@ namespace p11x { auto ival = PyLong_AsLong(tmp); \ value = decltype(value)(ival); \ Py_DECREF(tmp); \ - return !(ival == -1 && !PyErr_Occurred()); \ + return !(ival == -1 && PyErr_Occurred()); \ } else { \ return false; \ } \