From 185b4fae5dc157c45a7255d4979a1c4a35c27893 Mon Sep 17 00:00:00 2001 From: Owl Date: Wed, 30 Apr 2025 13:41:20 +0800 Subject: [PATCH 001/228] fix: Fix unstable tkagg small plot size. --- lib/matplotlib/backends/_backend_tk.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 0bbff1379ffa..b1443bf2c882 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -638,6 +638,14 @@ def __init__(self, canvas, window=None, *, pack_toolbar=True): window = canvas.get_tk_widget().master tk.Frame.__init__(self, master=window, borderwidth=2, width=int(canvas.figure.bbox.width), height=50) + # Avoid message_label expanding the toolbar size, and in turn expanding the + # canvas size. + # Without pack_propagate(False), when the user defines a small figure size + # (e.g. 2x2): + # 1. Figure size that is bigger than the user's expectation. + # 2. When message_label is refreshed by mouse enter/leave, the canvas + # size will also be changed. + self.pack_propagate(False) self._buttons = {} for text, tooltip_text, image_file, callback in self.toolitems: From 3b85ba4365c8eaa172d0a92c9d3ecc2d51693c42 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 9 May 2025 12:23:58 -0500 Subject: [PATCH 002/228] Zenodo v3.10.3 --- doc/_static/zenodo_cache/14940554.svg | 35 +++++++++++++++++++++++++++ doc/_static/zenodo_cache/15375714.svg | 35 +++++++++++++++++++++++++++ doc/project/citing.rst | 6 +++++ tools/cache_zenodo_svg.py | 2 ++ 4 files changed, 78 insertions(+) create mode 100644 doc/_static/zenodo_cache/14940554.svg create mode 100644 doc/_static/zenodo_cache/15375714.svg diff --git a/doc/_static/zenodo_cache/14940554.svg b/doc/_static/zenodo_cache/14940554.svg new file mode 100644 index 000000000000..6e7d5c37bf7b --- /dev/null +++ b/doc/_static/zenodo_cache/14940554.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.14940554 + + + 10.5281/zenodo.14940554 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/15375714.svg b/doc/_static/zenodo_cache/15375714.svg new file mode 100644 index 000000000000..d5e403138561 --- /dev/null +++ b/doc/_static/zenodo_cache/15375714.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.15375714 + + + 10.5281/zenodo.15375714 + + + \ No newline at end of file diff --git a/doc/project/citing.rst b/doc/project/citing.rst index 2cd317906bb5..8b4c323229ca 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,12 @@ By version .. START OF AUTOGENERATED +v3.10.3 + .. image:: ../_static/zenodo_cache/15375714.svg + :target: https://doi.org/10.5281/zenodo.15375714 +v3.10.1 + .. image:: ../_static/zenodo_cache/14940554.svg + :target: https://doi.org/10.5281/zenodo.14940554 v3.10.0 .. image:: ../_static/zenodo_cache/14464227.svg :target: https://doi.org/10.5281/zenodo.14464227 diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 3be7d6ca21e4..229e90efeb34 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,8 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.3": "15375714", + "v3.10.1": "14940554", "v3.10.0": "14464227", "v3.9.4": "14436121", "v3.9.3": "14249941", From d3c77afde1e3fd6948d15ae730d0cebfe25fd6fe Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 9 May 2025 22:46:21 +0200 Subject: [PATCH 003/228] Backport PR #30029: Update diagram in subplots_adjust documentation to clarify parameters --- doc/_embedded_plots/figure_subplots_adjust.py | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/doc/_embedded_plots/figure_subplots_adjust.py b/doc/_embedded_plots/figure_subplots_adjust.py index b4b8d7d32a3d..6f99a3febcdc 100644 --- a/doc/_embedded_plots/figure_subplots_adjust.py +++ b/doc/_embedded_plots/figure_subplots_adjust.py @@ -1,28 +1,34 @@ import matplotlib.pyplot as plt -def arrow(p1, p2, **props): - axs[0, 0].annotate( - "", p1, p2, xycoords='figure fraction', - arrowprops=dict(arrowstyle="<->", shrinkA=0, shrinkB=0, **props)) - - fig, axs = plt.subplots(2, 2, figsize=(6.5, 4)) fig.set_facecolor('lightblue') fig.subplots_adjust(0.1, 0.1, 0.9, 0.9, 0.4, 0.4) + +overlay = fig.add_axes([0, 0, 1, 1], zorder=100) +overlay.axis("off") +xycoords='figure fraction' +arrowprops=dict(arrowstyle="<->", shrinkA=0, shrinkB=0) + for ax in axs.flat: ax.set(xticks=[], yticks=[]) -arrow((0, 0.75), (0.1, 0.75)) # left -arrow((0.435, 0.75), (0.565, 0.75)) # wspace -arrow((0.9, 0.75), (1, 0.75)) # right +overlay.annotate("", (0, 0.75), (0.1, 0.75), + xycoords=xycoords, arrowprops=arrowprops) # left +overlay.annotate("", (0.435, 0.25), (0.565, 0.25), + xycoords=xycoords, arrowprops=arrowprops) # wspace +overlay.annotate("", (0, 0.8), (0.9, 0.8), + xycoords=xycoords, arrowprops=arrowprops) # right fig.text(0.05, 0.7, "left", ha="center") -fig.text(0.5, 0.7, "wspace", ha="center") -fig.text(0.95, 0.7, "right", ha="center") +fig.text(0.5, 0.3, "wspace", ha="center") +fig.text(0.05, 0.83, "right", ha="center") -arrow((0.25, 0), (0.25, 0.1)) # bottom -arrow((0.25, 0.435), (0.25, 0.565)) # hspace -arrow((0.25, 0.9), (0.25, 1)) # top -fig.text(0.28, 0.05, "bottom", va="center") +overlay.annotate("", (0.75, 0), (0.75, 0.1), + xycoords=xycoords, arrowprops=arrowprops) # bottom +overlay.annotate("", (0.25, 0.435), (0.25, 0.565), + xycoords=xycoords, arrowprops=arrowprops) # hspace +overlay.annotate("", (0.8, 0), (0.8, 0.9), + xycoords=xycoords, arrowprops=arrowprops) # top +fig.text(0.65, 0.05, "bottom", va="center") fig.text(0.28, 0.5, "hspace", va="center") -fig.text(0.28, 0.95, "top", va="center") +fig.text(0.82, 0.05, "top", va="center") From b81427cabae98716eda5d27134a882682754de26 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 9 May 2025 23:06:22 +0200 Subject: [PATCH 004/228] Update doc/_embedded_plots/figure_subplots_adjust.py Fix flake8 --- doc/_embedded_plots/figure_subplots_adjust.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/_embedded_plots/figure_subplots_adjust.py b/doc/_embedded_plots/figure_subplots_adjust.py index 6f99a3febcdc..d32a029fe05d 100644 --- a/doc/_embedded_plots/figure_subplots_adjust.py +++ b/doc/_embedded_plots/figure_subplots_adjust.py @@ -7,8 +7,8 @@ overlay = fig.add_axes([0, 0, 1, 1], zorder=100) overlay.axis("off") -xycoords='figure fraction' -arrowprops=dict(arrowstyle="<->", shrinkA=0, shrinkB=0) +xycoords = 'figure fraction' +arrowprops = dict(arrowstyle="<->", shrinkA=0, shrinkB=0) for ax in axs.flat: ax.set(xticks=[], yticks=[]) From e188484b9ccacbda63c401fe925e93a10d73eac8 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 4 Jun 2025 16:11:33 -0400 Subject: [PATCH 005/228] Backport PR #30118: CI: Skip jobs on forks --- .github/workflows/cibuildwheel.yml | 38 +++++++++++++++------------ .github/workflows/codeql-analysis.yml | 1 + .github/workflows/conflictcheck.yml | 1 + 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 79d770d0c8f4..3eddbb402e6a 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -24,14 +24,16 @@ permissions: jobs: build_sdist: if: >- - github.event_name == 'push' || - github.event_name == 'pull_request' && ( - ( - github.event.action == 'labeled' && - github.event.label.name == 'CI: Run cibuildwheel' - ) || - contains(github.event.pull_request.labels.*.name, - 'CI: Run cibuildwheel') + github.repository == 'matplotlib/matplotlib' && ( + github.event_name == 'push' || + github.event_name == 'pull_request' && ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'CI: Run cibuildwheel' + ) || + contains(github.event.pull_request.labels.*.name, + 'CI: Run cibuildwheel') + ) ) name: Build sdist runs-on: ubuntu-latest @@ -78,14 +80,16 @@ jobs: build_wheels: if: >- - github.event_name == 'push' || - github.event_name == 'pull_request' && ( - ( - github.event.action == 'labeled' && - github.event.label.name == 'CI: Run cibuildwheel' - ) || - contains(github.event.pull_request.labels.*.name, - 'CI: Run cibuildwheel') + github.repository == 'matplotlib/matplotlib' && ( + github.event_name == 'push' || + github.event_name == 'pull_request' && ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'CI: Run cibuildwheel' + ) || + contains(github.event.pull_request.labels.*.name, + 'CI: Run cibuildwheel') + ) ) needs: build_sdist name: Build wheels on ${{ matrix.os }} for ${{ matrix.cibw_archs }} @@ -188,7 +192,7 @@ jobs: if-no-files-found: error publish: - if: github.event_name == 'push' && github.ref_type == 'tag' + if: github.repository == 'matplotlib/matplotlib' && github.event_name == 'push' && github.ref_type == 'tag' name: Upload release to PyPI needs: [build_sdist, build_wheels] runs-on: ubuntu-latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 089b15700f1b..7a15de609834 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,6 +12,7 @@ on: jobs: analyze: + if: github.repository == 'matplotlib/matplotlib' name: Analyze runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/conflictcheck.yml b/.github/workflows/conflictcheck.yml index b018101f325c..c9aa036004e7 100644 --- a/.github/workflows/conflictcheck.yml +++ b/.github/workflows/conflictcheck.yml @@ -11,6 +11,7 @@ on: jobs: main: + if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest permissions: pull-requests: write From 5bdbb3ad9d982f58174f0ae10323364760ece24e Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 30 May 2025 01:50:50 -0400 Subject: [PATCH 006/228] Backport PR #30119: Add some types to _mathtext.py --- lib/matplotlib/_mathtext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 7085a986414e..9e20ea3da9b7 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2521,10 +2521,10 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: if len(new_children): # remove last kern if (isinstance(new_children[-1], Kern) and - hasattr(new_children[-2], '_metrics')): + isinstance(new_children[-2], Char)): new_children = new_children[:-1] last_char = new_children[-1] - if hasattr(last_char, '_metrics'): + if isinstance(last_char, Char): last_char.width = last_char._metrics.advance # create new Hlist without kerning nucleus = Hlist(new_children, do_kern=False) @@ -2600,7 +2600,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Do we need to add a space after the nucleus? # To find out, check the flag set by operatorname - spaced_nucleus = [nucleus, x] + spaced_nucleus: list[Node] = [nucleus, x] if self._in_subscript_or_superscript: spaced_nucleus += [self._make_space(self._space_widths[r'\,'])] self._in_subscript_or_superscript = False From 3950d996236581bf7d53f29ffd7fc33434687552 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Thu, 19 Jun 2025 15:09:33 -0700 Subject: [PATCH 007/228] Backport PR #30180: DOC: expand polar example --- .../pie_and_polar_charts/polar_demo.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/galleries/examples/pie_and_polar_charts/polar_demo.py b/galleries/examples/pie_and_polar_charts/polar_demo.py index e4967079d19d..909fea094be5 100644 --- a/galleries/examples/pie_and_polar_charts/polar_demo.py +++ b/galleries/examples/pie_and_polar_charts/polar_demo.py @@ -4,6 +4,11 @@ ========== Demo of a line plot on a polar axis. + +The second plot shows the same data, but with the radial axis starting at r=1 +and the angular axis starting at 0 degrees and ending at 225 degrees. Setting +the origin of the radial axis to 0 allows the radial ticks to be placed at the +same location as the first plot. """ import matplotlib.pyplot as plt import numpy as np @@ -11,14 +16,29 @@ r = np.arange(0, 2, 0.01) theta = 2 * np.pi * r -fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) +fig, axs = plt.subplots(2, 1, figsize=(5, 8), subplot_kw={'projection': 'polar'}, + layout='constrained') +ax = axs[0] ax.plot(theta, r) ax.set_rmax(2) -ax.set_rticks([0.5, 1, 1.5, 2]) # Less radial ticks +ax.set_rticks([0.5, 1, 1.5, 2]) # Fewer radial ticks ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line ax.grid(True) ax.set_title("A line plot on a polar axis", va='bottom') + +ax = axs[1] +ax.plot(theta, r) +ax.set_rmax(2) +ax.set_rmin(1) # Change the radial axis to only go from 1 to 2 +ax.set_rorigin(0) # Set the origin of the radial axis to 0 +ax.set_thetamin(0) +ax.set_thetamax(225) +ax.set_rticks([1, 1.5, 2]) # Fewer radial ticks +ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line + +ax.grid(True) +ax.set_title("Same plot, but with reduced axis limits", va='bottom') plt.show() # %% @@ -32,6 +52,8 @@ # - `matplotlib.projections.polar` # - `matplotlib.projections.polar.PolarAxes` # - `matplotlib.projections.polar.PolarAxes.set_rticks` +# - `matplotlib.projections.polar.PolarAxes.set_rmin` +# - `matplotlib.projections.polar.PolarAxes.set_rorigin` # - `matplotlib.projections.polar.PolarAxes.set_rmax` # - `matplotlib.projections.polar.PolarAxes.set_rlabel_position` # From 96512fd4835d61e037974944b355edcc131b5f46 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 25 Jun 2025 09:08:40 +0200 Subject: [PATCH 008/228] Backport PR #30212: [Doc]: fix bug in release notes for matplotlib v3.5.0 and v3.7.0 --- doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst | 2 +- doc/api/prev_api_changes/api_changes_3.7.0/removals.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst index d10da55a97f8..742a18f04072 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst @@ -282,7 +282,7 @@ Miscellaneous deprecations - The *format* parameter of ``dviread.find_tex_file`` is deprecated (with no replacement). - ``FancyArrowPatch.get_path_in_displaycoord`` and - ``ConnectionPath.get_path_in_displaycoord`` are deprecated. The path in + ``ConnectionPatch.get_path_in_displaycoord`` are deprecated. The path in display coordinates can still be obtained, as for other patches, using ``patch.get_transform().transform_path(patch.get_path())``. - The ``font_manager.win32InstalledFonts`` and diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst index 03239be31057..56b3ad5c253e 100644 --- a/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst +++ b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst @@ -323,7 +323,7 @@ Miscellaneous removals - The *format* parameter of ``dviread.find_tex_file`` is removed (with no replacement). - ``FancyArrowPatch.get_path_in_displaycoord`` and - ``ConnectionPath.get_path_in_displaycoord`` are removed. The path in + ``ConnectionPatch.get_path_in_displaycoord`` are removed. The path in display coordinates can still be obtained, as for other patches, using ``patch.get_transform().transform_path(patch.get_path())``. - The ``font_manager.win32InstalledFonts`` and From b1226396211d5280dcecf55929f2df3fd62cc8a0 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Wed, 2 Jul 2025 07:40:25 -0700 Subject: [PATCH 009/228] Backport PR #30244: DOC: Recommend to use bare Figure instances for saving to file --- doc/users/faq.rst | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/doc/users/faq.rst b/doc/users/faq.rst index 592fff551099..87a1af03d96e 100644 --- a/doc/users/faq.rst +++ b/doc/users/faq.rst @@ -281,8 +281,23 @@ locators as desired because the two axes are independent. Generate images without having a window appear ---------------------------------------------- -Simply do not call `~matplotlib.pyplot.show`, and directly save the figure to -the desired format:: +The recommended approach since matplotlib 3.1 is to explicitly create a Figure +instance:: + + from matplotlib.figure import Figure + fig = Figure() + ax = fig.subplots() + ax.plot([1, 2, 3]) + fig.savefig('myfig.png') + +This prevents any interaction with GUI frameworks and the window manager. + +It's alternatively still possible to use the pyplot interface. Instead of +calling `matplotlib.pyplot.show`, call `matplotlib.pyplot.savefig`. + +Additionally, you must ensure to close the figure after saving it. Not +closing the figure is a memory leak, because pyplot keeps references +to all not-yet-shown figures:: import matplotlib.pyplot as plt plt.plot([1, 2, 3]) From 74d8632fa71aadc21037de776284e26aa4c2351d Mon Sep 17 00:00:00 2001 From: hannah Date: Fri, 11 Jul 2025 14:11:06 -0400 Subject: [PATCH 010/228] Backport PR #30289: DOC: Fix build with pybind11 3 --- doc/missing-references.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/missing-references.json b/doc/missing-references.json index 1a816d19f7cd..e3b23db6fbc8 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -261,8 +261,12 @@ "doc/api/_as_gen/mpl_toolkits.axisartist.floating_axes.rst:32::1" ], "numpy.float64": [ + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1", "doc/docstring of matplotlib.ft2font.PyCapsule.set_text:1" ], + "numpy.typing.NDArray": [ + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1" + ], "numpy.uint8": [ ":1" ] From bc3117798d70f72219dfb08a624f33780a309e20 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 1 Apr 2025 23:41:13 +0200 Subject: [PATCH 011/228] ENH: Allow to register standalone figures with pyplot It may be fundamentally nice not to have to create the figure though pyplot to be able to use it in pyplot afterwards. You can now do ``` from matplotlib.figure import Figure import matplotlib.pyplot as plt fig = Figure() fig.subplots().plot([1, 3, 2]) plt.figure(fig) # fig is now tracked in pyplot plt.show() ``` This also opens up the possibility to more dynamically track and untrack figures in pyplot, which opens up the road to optimized figure tracking in pyplot (#29849) --- lib/matplotlib/pyplot.py | 23 +++++++++++++++++++---- lib/matplotlib/tests/test_figure.py | 2 -- lib/matplotlib/tests/test_pyplot.py | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index e916d57f8871..e6c609c2b84a 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -933,6 +933,10 @@ def figure( window title is set to this value. If num is a ``SubFigure``, its parent ``Figure`` is activated. + If *num* is a Figure instance that is already tracked in pyplot, it is + activated. If *num* is a Figure instance that is not tracked in pyplot, + it is added to the tracked figures and activated. + figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize` The figure dimensions. This can be @@ -1019,21 +1023,32 @@ def figure( in the matplotlibrc file. """ allnums = get_fignums() + next_num = max(allnums) + 1 if allnums else 1 if isinstance(num, FigureBase): # type narrowed to `Figure | SubFigure` by combination of input and isinstance + has_figure_property_parameters = ( + any(param is not None for param in [figsize, dpi, facecolor, edgecolor]) + or not frameon or kwargs + ) + root_fig = num.get_figure(root=True) if root_fig.canvas.manager is None: - raise ValueError("The passed figure is not managed by pyplot") - elif (any(param is not None for param in [figsize, dpi, facecolor, edgecolor]) - or not frameon or kwargs) and root_fig.canvas.manager.num in allnums: + if has_figure_property_parameters: + raise ValueError( + "You cannot pass figure properties when calling figure() with " + "an existing Figure instance") + backend = _get_backend_mod() + manager_ = backend.new_figure_manager_given_figure(next_num, root_fig) + _pylab_helpers.Gcf._set_new_active_manager(manager_) + return manager_.canvas.figure + elif has_figure_property_parameters and root_fig.canvas.manager.num in allnums: _api.warn_external( "Ignoring specified arguments in this call because figure " f"with num: {root_fig.canvas.manager.num} already exists") _pylab_helpers.Gcf.set_active(root_fig.canvas.manager) return root_fig - next_num = max(allnums) + 1 if allnums else 1 fig_label = '' if num is None: num = next_num diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c5890a2963b3..5f0e68648966 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -147,8 +147,6 @@ def test_figure_label(): assert plt.get_figlabels() == ['', 'today'] plt.figure(fig_today) assert plt.gcf() == fig_today - with pytest.raises(ValueError): - plt.figure(Figure()) def test_figure_label_replaced(): diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 55f7c33cb52e..44555a333a8c 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -471,6 +471,30 @@ def test_multiple_same_figure_calls(): assert fig is fig3 +def test_register_existing_figure_with_pyplot(): + from matplotlib.figure import Figure + # start with a standalone figure + fig = Figure() + assert fig.canvas.manager is None + with pytest.raises(AttributeError): + # Heads-up: This will change to returning None in the future + # See docstring for the Figure.number property + fig.number + # register the Figure with pyplot + plt.figure(fig) + assert fig.number == 1 + # the figure can now be used in pyplot + plt.suptitle("my title") + assert fig.get_suptitle() == "my title" + # it also has a manager that is properly wired up in the pyplot state + assert plt._pylab_helpers.Gcf.get_fig_manager(fig.number) is fig.canvas.manager + # and we can regularly switch the pyplot state + fig2 = plt.figure() + assert fig2.number == 2 + assert plt.figure(1) is fig + assert plt.gcf() is fig + + def test_close_all_warning(): fig1 = plt.figure() From c718eae4dd7761051ba309d5c962077797f1e954 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:30:52 +0200 Subject: [PATCH 012/228] Clarifying comment --- lib/matplotlib/_pylab_helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/_pylab_helpers.py b/lib/matplotlib/_pylab_helpers.py index e3c3d98cb156..05f6d8aa02b3 100644 --- a/lib/matplotlib/_pylab_helpers.py +++ b/lib/matplotlib/_pylab_helpers.py @@ -53,6 +53,8 @@ def destroy(cls, num): two managers share the same number. """ if all(hasattr(num, attr) for attr in ["num", "destroy"]): + # num is a manager-like instance (not necessarily a + # FigureManagerBase subclass) manager = num if cls.figs.get(manager.num) is manager: cls.figs.pop(manager.num) From a38f9bb624f59cc01320840a5a53b6ab32e72867 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:32:44 +0200 Subject: [PATCH 013/228] Fix: properly decouple from pyplot and specific backends when destroying When destroying a manager, replace the figure's canvas by a figure canvas base. --- lib/matplotlib/backend_bases.py | 4 +++- lib/matplotlib/backends/_backend_gtk.py | 1 + lib/matplotlib/backends/_backend_tk.py | 1 + lib/matplotlib/backends/backend_gtk3.py | 1 + lib/matplotlib/backends/backend_gtk4.py | 1 + lib/matplotlib/backends/backend_nbagg.py | 1 + lib/matplotlib/backends/backend_qt.py | 1 + lib/matplotlib/backends/backend_wx.py | 1 + lib/matplotlib/figure.py | 11 ++++++++++- lib/matplotlib/tests/test_backends_interactive.py | 9 ++++++--- 10 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 4adaecb7f8c0..51db8dc054e5 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2723,7 +2723,9 @@ def show(self): f"shown") def destroy(self): - pass + # managers may have swapped the canvas to a GUI-framework specific one. + # restore the base canvas when the manager is destroyed. + self.canvas.figure._set_base_canvas() def full_screen_toggle(self): pass diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index ac443730e28a..ce6982a72526 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -195,6 +195,7 @@ def destroy(self, *args): self._destroying = True self.window.destroy() self.canvas.destroy() + super().destroy() @classmethod def start_main_loop(cls): diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..3cd349cb9e17 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -606,6 +606,7 @@ def delayed_destroy(): else: self.window.update() delayed_destroy() + super().destroy() def get_window_title(self): return self.window.wm_title() diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 888f5a770f5d..68ba3a329b5e 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -89,6 +89,7 @@ def __init__(self, figure=None): def destroy(self): CloseEvent("close_event", self)._process() + super().destroy() def set_cursor(self, cursor): # docstring inherited diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index cd38968779ed..8c9045e8cca3 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -90,6 +90,7 @@ def __init__(self, figure=None): def destroy(self): CloseEvent("close_event", self)._process() + super().destroy() def set_cursor(self, cursor): # docstring inherited diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 4d18e1e9fb88..543454ab25fd 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -137,6 +137,7 @@ def _create_comm(self): return comm def destroy(self): + super().destroy() self._send_event('close') # need to copy comms as callbacks will modify this list for comm in list(self.web_sockets): diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 401ce0b0b754..68d89e1990bb 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -664,6 +664,7 @@ def show(self): self.window.raise_() def destroy(self, *args): + super().destroy() # check for qApp first, as PySide deletes it in its atexit handler if QtWidgets.QApplication.instance() is None: return diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..5219042e7971 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -1007,6 +1007,7 @@ def show(self): def destroy(self, *args): # docstring inherited _log.debug("%s - destroy()", type(self)) + super().destroy() frame = self.frame if frame: # Else, may have been already deleted, e.g. when closing. # As this can be called from non-GUI thread from plt.close use diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 03549dd53bc1..00a21343eef6 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2642,7 +2642,7 @@ def __init__(self, self._set_artist_props(self.patch) self.patch.set_antialiased(False) - FigureCanvasBase(self) # Set self.canvas. + self._set_base_canvas() if subplotpars is None: subplotpars = SubplotParams() @@ -2996,6 +2996,15 @@ def get_constrained_layout_pads(self, relative=False): return w_pad, h_pad, wspace, hspace + def _set_base_canvas(self): + """ + Initialize self.canvas with a FigureCanvasBase instance. + + This is used upon initialization of the Figure, but also + to reset the canvas when decoupling from pyplot. + """ + FigureCanvasBase(self) # Set self.canvas as a side-effect + def set_canvas(self, canvas): """ Set the canvas that contains the figure diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 9f8522a9df4a..5a6740d6e7ac 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -227,12 +227,15 @@ def check_alt_backend(alt_backend): # Ensure that the window is really closed. plt.pause(0.5) - # Test that saving works after interactive window is closed, but the figure - # is not deleted. + # When the figure is closed, it's manager is removed and the canvas is reset to + # FigureCanvasBase. Saving should still be possible. result_after = io.BytesIO() fig.savefig(result_after, format='png') - assert result.getvalue() == result_after.getvalue() + if backend.endswith("agg"): + # agg-based interactive backends should save the same image as a non-interactive + # figure + assert result.getvalue() == result_after.getvalue() @pytest.mark.parametrize("env", _get_testable_interactive_backends()) From eab4a8934249af016cfde396a1de55f897e3690a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 30 Jul 2025 10:34:00 -0400 Subject: [PATCH 014/228] TST: do not use non-baseclass methods to get renderer in tests There in now machinery for all of the public API that takes a renderer is input to get it from the current canvas on the root figure. Use that machinery instead. --- lib/matplotlib/tests/test_axes.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index c96173e340f7..e0b651095cb5 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8223,10 +8223,9 @@ def color_boxes(fig, ax): """ fig.canvas.draw() - renderer = fig.canvas.get_renderer() bbaxis = [] for nn, axx in enumerate([ax.xaxis, ax.yaxis]): - bb = axx.get_tightbbox(renderer) + bb = axx.get_tightbbox() if bb: axisr = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, @@ -8237,7 +8236,7 @@ def color_boxes(fig, ax): bbspines = [] for nn, a in enumerate(['bottom', 'top', 'left', 'right']): - bb = ax.spines[a].get_window_extent(renderer) + bb = ax.spines[a].get_window_extent() spiner = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, linewidth=0.7, edgecolor="green", facecolor="none", transform=None, @@ -8253,7 +8252,7 @@ def color_boxes(fig, ax): fig.add_artist(rect2) bbax = bb - bb2 = ax.get_tightbbox(renderer) + bb2 = ax.get_tightbbox() rect2 = mpatches.Rectangle( (bb2.x0, bb2.y0), width=bb2.width, height=bb2.height, linewidth=3, edgecolor="red", facecolor="none", transform=None, From 2396e9011c0b7da4e230ebc09b8e204b51672e99 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 30 Jul 2025 11:05:52 -0400 Subject: [PATCH 015/228] TST: fix wx window closing test Flush the wx canvas, not the base canvas that is installed on close. --- lib/matplotlib/tests/test_backends_interactive.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 5a6740d6e7ac..0a1c7f703cad 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -288,10 +288,13 @@ def _test_thread_impl(): future = ThreadPoolExecutor().submit(fig.canvas.draw) plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176) future.result() # Joins the thread; rethrows any exception. + # stash the current canvas as closing the figure will reset the canvas on + # the figure + canvas = fig.canvas plt.close() # backend is responsible for flushing any events here if plt.rcParams["backend"].lower().startswith("wx"): # TODO: debug why WX needs this only on py >= 3.8 - fig.canvas.flush_events() + canvas.flush_events() _thread_safe_backends = _get_testable_interactive_backends() From b7a5cfd99289d7a257966044479cd4f68bb7c438 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 30 Jul 2025 11:10:08 -0400 Subject: [PATCH 016/228] TST: try forcing the dpi when testing saving after closing window --- lib/matplotlib/tests/test_backends_interactive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 0a1c7f703cad..671ad8466aee 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -220,7 +220,7 @@ def check_alt_backend(alt_backend): fig.canvas.mpl_connect("close_event", print) result = io.BytesIO() - fig.savefig(result, format='png') + fig.savefig(result, format='png', dpi=100) plt.show() @@ -230,7 +230,7 @@ def check_alt_backend(alt_backend): # When the figure is closed, it's manager is removed and the canvas is reset to # FigureCanvasBase. Saving should still be possible. result_after = io.BytesIO() - fig.savefig(result_after, format='png') + fig.savefig(result_after, format='png', dpi=100) if backend.endswith("agg"): # agg-based interactive backends should save the same image as a non-interactive From c578ec6bdf1528efe60802eedf30a6b0523ff6cc Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 30 Jul 2025 11:38:04 -0400 Subject: [PATCH 017/228] FIX: do not add super().destroy to Canvas destroy method This goes up the chain to the GUI classes that do not have this method. --- lib/matplotlib/backends/backend_gtk4.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 8c9045e8cca3..cd38968779ed 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -90,7 +90,6 @@ def __init__(self, figure=None): def destroy(self): CloseEvent("close_event", self)._process() - super().destroy() def set_cursor(self, cursor): # docstring inherited From 06b97a8ff5b74ca4dc6ef7038fb66e28c1884cbd Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:24:29 +0200 Subject: [PATCH 018/228] Add what's new note --- .../next_whats_new/pyplot-register-figure.rst | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 doc/users/next_whats_new/pyplot-register-figure.rst diff --git a/doc/users/next_whats_new/pyplot-register-figure.rst b/doc/users/next_whats_new/pyplot-register-figure.rst new file mode 100644 index 000000000000..9132e961e540 --- /dev/null +++ b/doc/users/next_whats_new/pyplot-register-figure.rst @@ -0,0 +1,54 @@ +Figures can be attached to and removed from pyplot +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Figures can now be attached to and removed from management through pyplot, which in +the background also means a less strict coupling to backends. + +In particular, standalone figures (created with the `.Figure` constructor) can now be +registered with the `.pyplot` module by calling ``plt.figure(fig)``. This allows to +show them with ``plt.show()`` as you would do with any figure created with pyplot +factory methods such as ``plt.figure()`` or ``plt.subplots()``. + +When closing a shown figure window, the related figure is reset to the standalone +state, i.e. it's not visible to pyplot anymore, but if you still hold a reference +to it, you can continue to work with it (e.g. do ``fig.savefig()``, or re-add it +to pyplot with ``plt.figure(fig)`` and then show it again). + +The following is now possible - though the example is exaggerated to show what's +possible. In practice, you'll stick with much simpler versions for better +consistency :: + + import matplotlib.pyplot as plt + from matplotlib.figure import Figure + + # Create a standalone figure + fig = Figure() + ax = fig.add_subplot() + ax.plot([1, 2, 3], [4, 5, 6]) + + # Register it with pyplot + plt.figure(fig) + + # Modify the figure through pyplot + plt.xlabel("x label") + + # Show the figure + plt.show() + + # Close the figure window through the GUI + + # Continue to work on the figure + fig.savefig("my_figure.png") + ax.set_ylabel("y label") + + # Re-register the figure and show it again + plt.figure(fig) + plt.show() + +Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is +replaced by a backend-dependent subclass when registering with pyplot, and is +reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses +the current canvas to save the figure (if possible). Since `.FigureCanvasBase` +is Agg-based any Agg-based backend will create the same file output. There may +be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as +interactive backend, ``fig.savefig("file.png")`` may create a slightly different +image depending on whether the figure is registered with pyplot or not. From b17b0623880de7b44d4b9625b34b7af33582846c Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 31 Jul 2025 14:05:00 -0500 Subject: [PATCH 019/228] Zenodo v3.10.5 --- doc/_static/zenodo_cache/16644850.svg | 35 +++++++++++++++++++++++++++ doc/project/citing.rst | 3 +++ tools/cache_zenodo_svg.py | 1 + 3 files changed, 39 insertions(+) create mode 100644 doc/_static/zenodo_cache/16644850.svg diff --git a/doc/_static/zenodo_cache/16644850.svg b/doc/_static/zenodo_cache/16644850.svg new file mode 100644 index 000000000000..89910032da4e --- /dev/null +++ b/doc/_static/zenodo_cache/16644850.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.16644850 + + + 10.5281/zenodo.16644850 + + + \ No newline at end of file diff --git a/doc/project/citing.rst b/doc/project/citing.rst index 8b4c323229ca..249f568625db 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,9 @@ By version .. START OF AUTOGENERATED +v3.10.5 + .. image:: ../_static/zenodo_cache/16644850.svg + :target: https://doi.org/10.5281/zenodo.16644850 v3.10.3 .. image:: ../_static/zenodo_cache/15375714.svg :target: https://doi.org/10.5281/zenodo.15375714 diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 229e90efeb34..59d6fce55162 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,7 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.5": "16644850", "v3.10.3": "15375714", "v3.10.1": "14940554", "v3.10.0": "14464227", From db30cbe4a1c45c05d295e57d8aac287fede7d75b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 31 Jul 2025 16:34:09 -0400 Subject: [PATCH 020/228] MNT: restore the old DPI when resetting the canvas This prevent the figures from growing on hi-dpi screens. --- lib/matplotlib/figure.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 00a21343eef6..330019f0e07c 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3003,7 +3003,12 @@ def _set_base_canvas(self): This is used upon initialization of the Figure, but also to reset the canvas when decoupling from pyplot. """ + # check if we have changed the API due to hi-dpi screens + orig_dpi = getattr(self, '_original_dpi', self._dpi) FigureCanvasBase(self) # Set self.canvas as a side-effect + # put it back to what it was + if orig_dpi != self._dpi: + self.dpi = orig_dpi def set_canvas(self, canvas): """ From 1ea856d77450d61a2498228ef896212f7faeccb4 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 31 Jul 2025 16:56:59 -0400 Subject: [PATCH 021/228] TST: verify that dpi is restored on close --- lib/matplotlib/tests/test_backend_qt.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 6e147fd14380..5bb81e5c1e2d 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -215,6 +215,10 @@ def set_device_pixel_ratio(ratio): assert qt_canvas.get_width_height() == (600, 240) assert (fig.get_size_inches() == (5, 2)).all() + # check that closing the figure restores the original dpi + plt.close(fig) + assert fig.dpi == 120 + @pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_subplottool(): From 0e078a7af0a08c3d9a0ca381b33816e69e21aa00 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 1 Aug 2025 12:26:31 +0200 Subject: [PATCH 022/228] Update lib/matplotlib/figure.py Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> --- lib/matplotlib/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 330019f0e07c..eba873cdc221 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3003,7 +3003,7 @@ def _set_base_canvas(self): This is used upon initialization of the Figure, but also to reset the canvas when decoupling from pyplot. """ - # check if we have changed the API due to hi-dpi screens + # check if we have changed the DPI due to hi-dpi screens orig_dpi = getattr(self, '_original_dpi', self._dpi) FigureCanvasBase(self) # Set self.canvas as a side-effect # put it back to what it was From 1784c73aba335c8eb57f9656803af0ba49957717 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sat, 2 Aug 2025 03:30:16 +0200 Subject: [PATCH 023/228] Add docs to state that you should not keep a reference to the canvas --- doc/users/next_whats_new/pyplot-register-figure.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/users/next_whats_new/pyplot-register-figure.rst b/doc/users/next_whats_new/pyplot-register-figure.rst index 9132e961e540..1acc0d0bf767 100644 --- a/doc/users/next_whats_new/pyplot-register-figure.rst +++ b/doc/users/next_whats_new/pyplot-register-figure.rst @@ -51,4 +51,8 @@ the current canvas to save the figure (if possible). Since `.FigureCanvasBase` is Agg-based any Agg-based backend will create the same file output. There may be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as interactive backend, ``fig.savefig("file.png")`` may create a slightly different -image depending on whether the figure is registered with pyplot or not. +image depending on whether the figure is registered with pyplot or not. In +general, you should not store a reference to the canvas, but rather always +obtain it from the figure with ``fig.canvas``. This will return the current +canvas, which is either the original `.FigureCanvasBase` or a backend-dependent +subclass, depending on whether the figure is registered with pyplot or not. From 6574ed51247ce252281788de7e848bbd2d50e0b9 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:19:23 +0200 Subject: [PATCH 024/228] MNT: Refactor default violin KDE estimator Move the default KDE estimator from a private definition in `violinplot()` into `violin_stats()`. This makes it easier to test and debug violin_stats() as we don't have to explicitly provide a KDE method. It also becomes logically simpler, because `violinplot()` is now only `violin_stats()` + `violin()`. --- lib/matplotlib/axes/_axes.py | 14 ++----------- lib/matplotlib/cbook.py | 38 ++++++++++++++++++++++++++++++------ lib/matplotlib/cbook.pyi | 5 ++++- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index b71f26b76d38..eacd0515ebbf 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -8878,18 +8878,8 @@ def violinplot(self, dataset, positions=None, vert=None, .Axes.violin : Draw a violin from pre-computed statistics. boxplot : Draw a box and whisker plot. """ - - def _kde_method(X, coords): - # Unpack in case of e.g. Pandas or xarray object - X = cbook._unpack_to_numpy(X) - # fallback gracefully if the vector contains only one value - if np.all(X[0] == X): - return (X[0] == coords).astype(float) - kde = mlab.GaussianKDE(X, bw_method) - return kde.evaluate(coords) - - vpstats = cbook.violin_stats(dataset, _kde_method, points=points, - quantiles=quantiles) + vpstats = cbook.violin_stats(dataset, ("GaussianKDE", bw_method), + points=points, quantiles=quantiles) return self.violin(vpstats, positions=positions, vert=vert, orientation=orientation, widths=widths, showmeans=showmeans, showextrema=showextrema, diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index a09780965b0c..8dc05d6ccc5a 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -29,7 +29,7 @@ from numpy import VisibleDeprecationWarning import matplotlib -from matplotlib import _api, _c_internal_utils +from matplotlib import _api, _c_internal_utils, mlab class _ExceptionInfo: @@ -1430,7 +1430,7 @@ def _reshape_2D(X, name): return result -def violin_stats(X, method, points=100, quantiles=None): +def violin_stats(X, method=("GaussianKDE", "scott"), points=100, quantiles=None): """ Return a list of dictionaries of data which can be used to draw a series of violin plots. @@ -1449,11 +1449,23 @@ def violin_stats(X, method, points=100, quantiles=None): Sample data that will be used to produce the gaussian kernel density estimates. Must have 2 or fewer dimensions. - method : callable + method : (name, bw_method) or callable, The method used to calculate the kernel density estimate for each - column of data. When called via ``method(v, coords)``, it should - return a vector of the values of the KDE evaluated at the values - specified in coords. + column of data. Valid values: + + - a tuple of the form ``(name, bw_method)`` where *name* currently must + always be ``"GaussianKDE"`` and *bw_method* is the method used to + calculate the estimator bandwidth. Supported values are 'scott', + 'silverman' or a float or a callable. If a float, this will be used + directly as `!kde.factor`. If a callable, it should take a + `matplotlib.mlab.GaussianKDE` instance as its only parameter and + return a float. + + - a callable with the signature :: + + def method(data: ndarray, coords: ndarray) -> ndarray + + It should return the KDE of *data* evaluated at *coords*. points : int, default: 100 Defines the number of points to evaluate each of the gaussian kernel @@ -1481,6 +1493,20 @@ def violin_stats(X, method, points=100, quantiles=None): - max: The maximum value for this column of data. - quantiles: The quantile values for this column of data. """ + if isinstance(method, tuple): + name, bw_method = method + if name != "GaussianKDE": + raise ValueError(f"Unknown KDE method name {name!r}. The only supported " + 'named method is "GaussianKDE"') + + def _kde_method(x, coords): + # fallback gracefully if the vector contains only one value + if np.all(x[0] == x): + return (x[0] == coords).astype(float) + kde = mlab.GaussianKDE(x, bw_method) + return kde.evaluate(coords) + + method = _kde_method # List of dictionaries describing each of the violins. vpstats = [] diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index ad14841463e8..f7959a6fd0bb 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -133,7 +133,10 @@ ls_mapper_r: dict[str, str] def contiguous_regions(mask: ArrayLike) -> list[np.ndarray]: ... def is_math_text(s: str) -> bool: ... def violin_stats( - X: ArrayLike, method: Callable, points: int = ..., quantiles: ArrayLike | None = ... + X: ArrayLike, + method: tuple[Literal["GaussianKDE"], Literal["scott", "silverman"] | float | Callable] | Callable = ..., + points: int = ..., + quantiles: ArrayLike | None = ... ) -> list[dict[str, Any]]: ... def pts_to_prestep(x: ArrayLike, *args: ArrayLike) -> np.ndarray: ... def pts_to_poststep(x: ArrayLike, *args: ArrayLike) -> np.ndarray: ... From 64d0b0cef9a1b874f7799a79487d145c488be557 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:47:30 +0200 Subject: [PATCH 025/228] ENH: Gracefully handle python-build-standalone ImportError with Tk. Closes #30390. --- lib/matplotlib/backends/_backend_tk.py | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..d8cd3e94d104 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -22,8 +22,34 @@ TimerBase, ToolContainerBase, cursors, _Mode, MouseButton, CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib._pylab_helpers import Gcf -from . import _tkagg -from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET + +try: + from . import _tkagg + from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET +except ImportError as e: + # catch incompatibility of python-build-standalone with Tk + cause1 = getattr(e, '__cause__', None) + cause2 = getattr(cause1, '__cause__', None) + if (isinstance(cause1, ImportError) and + isinstance(cause2, AttributeError) and + "'_tkinter' has no attribute '__file__'" in str(cause2)): + + is_uv_python = "/uv/python" in (os.path.realpath(sys.executable)) + if is_uv_python: + raise ImportError( + "Failed to import tkagg backend. You are using a uv-installed python " + "executable, which is not compatible with Tk. " + "Please use another Python interpreter or select another backend." + ) from e + else: + raise ImportError( + "Failed to import tkagg backend. This is likely caused by using a " + "Python executable based on python-build-standalone, which is not " + "compatible with Tk. " + "Please use another Python interpreter or select another backend." + ) from e + else: + raise _log = logging.getLogger(__name__) From bc2ed8750bc93cd490bad91d0adaf04241809d15 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 21 Aug 2025 15:58:22 -0400 Subject: [PATCH 026/228] DOC: add canonical uv update command in error message --- lib/matplotlib/backends/_backend_tk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index d8cd3e94d104..5b22ec6b5d0e 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -39,7 +39,8 @@ raise ImportError( "Failed to import tkagg backend. You are using a uv-installed python " "executable, which is not compatible with Tk. " - "Please use another Python interpreter or select another backend." + "Please update your python via: " + "`uv self update && uv python upgrade --reinstall`" ) from e else: raise ImportError( From 782750e606b81c5aa8007f08767a67a20659dc49 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:01:49 +0200 Subject: [PATCH 027/228] Reword what's new message Co-authored-by: Thomas A Caswell --- doc/users/next_whats_new/pyplot-register-figure.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/users/next_whats_new/pyplot-register-figure.rst b/doc/users/next_whats_new/pyplot-register-figure.rst index 1acc0d0bf767..dad2a0496a81 100644 --- a/doc/users/next_whats_new/pyplot-register-figure.rst +++ b/doc/users/next_whats_new/pyplot-register-figure.rst @@ -48,8 +48,8 @@ Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is replaced by a backend-dependent subclass when registering with pyplot, and is reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses the current canvas to save the figure (if possible). Since `.FigureCanvasBase` -is Agg-based any Agg-based backend will create the same file output. There may -be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as +can not render the figure, when using save fig it will fallback to `.FigureCanvasAgg` which is Agg-based. Any Agg-based backend will create the same file output, however +There may be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as interactive backend, ``fig.savefig("file.png")`` may create a slightly different image depending on whether the figure is registered with pyplot or not. In general, you should not store a reference to the canvas, but rather always From 8b77fffb9a7c1b8d694a07e27edfb761dca57f3f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 22 Aug 2025 09:37:34 -0400 Subject: [PATCH 028/228] DOC: move whats new to new location --- .../next_whats_new/pyplot-register-figure.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename doc/{users => release}/next_whats_new/pyplot-register-figure.rst (93%) diff --git a/doc/users/next_whats_new/pyplot-register-figure.rst b/doc/release/next_whats_new/pyplot-register-figure.rst similarity index 93% rename from doc/users/next_whats_new/pyplot-register-figure.rst rename to doc/release/next_whats_new/pyplot-register-figure.rst index dad2a0496a81..0c186fe0ecb9 100644 --- a/doc/users/next_whats_new/pyplot-register-figure.rst +++ b/doc/release/next_whats_new/pyplot-register-figure.rst @@ -48,7 +48,8 @@ Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is replaced by a backend-dependent subclass when registering with pyplot, and is reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses the current canvas to save the figure (if possible). Since `.FigureCanvasBase` -can not render the figure, when using save fig it will fallback to `.FigureCanvasAgg` which is Agg-based. Any Agg-based backend will create the same file output, however +can not render the figure, when using savefig it will fallback to`.FigureCanvasAgg` +which is Agg-based. Any Agg-based backend will create the same file output, however There may be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as interactive backend, ``fig.savefig("file.png")`` may create a slightly different image depending on whether the figure is registered with pyplot or not. In From 59b29f5ecf314879bd227b4e1c0012cfa6047922 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 22 Aug 2025 13:44:12 -0400 Subject: [PATCH 029/228] DOC: tweak suggestion for upgrading uv managed Python Co-authored-by: Zanie Blue --- lib/matplotlib/backends/_backend_tk.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 5b22ec6b5d0e..5f49add4fe55 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -37,10 +37,10 @@ is_uv_python = "/uv/python" in (os.path.realpath(sys.executable)) if is_uv_python: raise ImportError( - "Failed to import tkagg backend. You are using a uv-installed python " - "executable, which is not compatible with Tk. " - "Please update your python via: " - "`uv self update && uv python upgrade --reinstall`" + "Failed to import tkagg backend. You appear to be using an outdated " + "version of uv's managed Python distribution which is not compatible with Tk. " + "Please upgrade to the latest uv version, then update Python with:" + "`uv python upgrade --reinstall`" ) from e else: raise ImportError( From c332377bcc14b3826e4500a2da730d3c3552ac09 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 03:22:47 +0200 Subject: [PATCH 030/228] Fix line length --- lib/matplotlib/backends/_backend_tk.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 5f49add4fe55..b1d5521577d4 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -38,9 +38,9 @@ if is_uv_python: raise ImportError( "Failed to import tkagg backend. You appear to be using an outdated " - "version of uv's managed Python distribution which is not compatible with Tk. " - "Please upgrade to the latest uv version, then update Python with:" - "`uv python upgrade --reinstall`" + "version of uv's managed Python distribution which is not compatible " + " with Tk. Please upgrade to the latest uv version, then update " + " Python with: `uv python upgrade --reinstall`" ) from e else: raise ImportError( From ed12aa97ba67b673693994ca31458f6715fe1734 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 03:23:35 +0200 Subject: [PATCH 031/228] Mentioned fix in recent python-build-standalone --- lib/matplotlib/backends/_backend_tk.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index b1d5521577d4..bec93c2b0ec6 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -46,8 +46,9 @@ raise ImportError( "Failed to import tkagg backend. This is likely caused by using a " "Python executable based on python-build-standalone, which is not " - "compatible with Tk. " - "Please use another Python interpreter or select another backend." + "compatible with Tk. Recent versions of python-build-standalone " + "should be compatible with Tk. Please update your python version " + "or select another backend." ) from e else: raise From 6d1f6d3548dbe82aa3b63007a76ec59ed63ac4de Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 03:24:28 +0200 Subject: [PATCH 032/228] whitespace fix --- lib/matplotlib/backends/_backend_tk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index bec93c2b0ec6..e547b88329f3 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -39,8 +39,8 @@ raise ImportError( "Failed to import tkagg backend. You appear to be using an outdated " "version of uv's managed Python distribution which is not compatible " - " with Tk. Please upgrade to the latest uv version, then update " - " Python with: `uv python upgrade --reinstall`" + "with Tk. Please upgrade to the latest uv version, then update " + "Python with: `uv python upgrade --reinstall`" ) from e else: raise ImportError( From e44495f9f940adc5003795386c70675595908262 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 08:26:54 +0200 Subject: [PATCH 033/228] Backport PR #30451: doc: factor out quick install tab for reuse --- doc/index.rst | 55 +++-------------------------- doc/install/index.rst | 7 +++- doc/install/quick_install.inc.rst | 54 ++++++++++++++++++++++++++++ doc/users/getting_started/index.rst | 21 +---------- 4 files changed, 65 insertions(+), 72 deletions(-) create mode 100644 doc/install/quick_install.inc.rst diff --git a/doc/index.rst b/doc/index.rst index 74a183d6cd7b..c5b25b6d37aa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,59 +14,12 @@ and interactive visualizations. Install ======= -.. tab-set:: - :class: sd-width-content-min +.. include:: install/quick_install.inc.rst - .. tab-item:: pip +.. toctree:: + :hidden: - .. code-block:: bash - - pip install matplotlib - - .. tab-item:: conda - - .. code-block:: bash - - conda install -c conda-forge matplotlib - - .. tab-item:: pixi - - .. code-block:: bash - - pixi add matplotlib - - .. tab-item:: uv - - .. code-block:: bash - - uv add matplotlib - - .. warning:: - - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. - - .. code-block:: bash - - uv add matplotlib pyside6 - - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - - .. tab-item:: other - - .. rst-class:: section-toc - .. toctree:: - :maxdepth: 2 - - install/index - -For more detailed instructions, see the -:doc:`installation guide `. + install/index Learn ===== diff --git a/doc/install/index.rst b/doc/install/index.rst index 2d9e724e6a73..eb5e02f98868 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -5,6 +5,9 @@ Installation ************ +.. include:: quick_install.inc.rst + +.. _install-official: Install an official release =========================== @@ -35,6 +38,7 @@ precompiled wheel for your OS and Python. animations and a larger selection of file formats, you can install :ref:`optional dependencies `. +.. _install-third-party: Third-party distributions ========================= @@ -81,7 +85,7 @@ you can install Matplotlib via your package manager, e.g.: .. redirect-from:: /users/installing/installing_source -.. _install_from_source: +.. _install-nightly-build: Install a nightly build ======================= @@ -101,6 +105,7 @@ scientific-python-nightly-wheels as the package index to query: --extra-index-url https://pypi.org/simple \ matplotlib +.. _install-source: Install from source =================== diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst new file mode 100644 index 000000000000..2e75b332f6ed --- /dev/null +++ b/doc/install/quick_install.inc.rst @@ -0,0 +1,54 @@ +.. set of quick install commands for reuse across docs + +.. tab-set:: + :class: sd-width-content-min + + .. tab-item:: pip + + .. code-block:: bash + + pip install matplotlib + + .. tab-item:: conda + + .. code-block:: bash + + conda install -c conda-forge matplotlib + + .. tab-item:: pixi + + .. code-block:: bash + + pixi add matplotlib + + .. tab-item:: uv + + .. code-block:: bash + + uv add matplotlib + + .. warning:: + + If you install Python with ``uv`` then the ``tkagg`` backend + will not be available because python-build-standalone (used by uv + to distribute Python) does not contain tk bindings that are usable by + Matplotlib (see `this issue`_ for details). If you want Matplotlib + to be able to display plots in a window, you should install one of + the other :ref:`supported GUI frameworks `, + e.g. + + .. code-block:: bash + + uv add matplotlib pyside6 + + .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 + + .. tab-item:: other + + :ref:`install-official` + + :ref:`install-third-party` + + :ref:`install-nightly-build` + + :ref:`install-source` diff --git a/doc/users/getting_started/index.rst b/doc/users/getting_started/index.rst index ac896687979d..dfbbd615b5cd 100644 --- a/doc/users/getting_started/index.rst +++ b/doc/users/getting_started/index.rst @@ -4,26 +4,7 @@ Getting started Installation quick-start ------------------------ -.. grid:: 1 1 2 2 - - .. grid-item:: - - Install using `pip `__: - - .. code-block:: bash - - pip install matplotlib - - .. grid-item:: - - Install using `conda `__: - - .. code-block:: bash - - conda install -c conda-forge matplotlib - -Further details are available in the :doc:`Installation Guide `. - +.. include:: /install/quick_install.inc.rst Draw a first plot ----------------- From 3305482033e63ac1d7cce4418407d11164aea612 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 08:26:54 +0200 Subject: [PATCH 034/228] Backport PR #30451: doc: factor out quick install tab for reuse --- doc/index.rst | 55 +++-------------------------- doc/install/index.rst | 7 +++- doc/install/quick_install.inc.rst | 54 ++++++++++++++++++++++++++++ doc/users/getting_started/index.rst | 21 +---------- 4 files changed, 65 insertions(+), 72 deletions(-) create mode 100644 doc/install/quick_install.inc.rst diff --git a/doc/index.rst b/doc/index.rst index 74a183d6cd7b..c5b25b6d37aa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,59 +14,12 @@ and interactive visualizations. Install ======= -.. tab-set:: - :class: sd-width-content-min +.. include:: install/quick_install.inc.rst - .. tab-item:: pip +.. toctree:: + :hidden: - .. code-block:: bash - - pip install matplotlib - - .. tab-item:: conda - - .. code-block:: bash - - conda install -c conda-forge matplotlib - - .. tab-item:: pixi - - .. code-block:: bash - - pixi add matplotlib - - .. tab-item:: uv - - .. code-block:: bash - - uv add matplotlib - - .. warning:: - - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. - - .. code-block:: bash - - uv add matplotlib pyside6 - - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - - .. tab-item:: other - - .. rst-class:: section-toc - .. toctree:: - :maxdepth: 2 - - install/index - -For more detailed instructions, see the -:doc:`installation guide `. + install/index Learn ===== diff --git a/doc/install/index.rst b/doc/install/index.rst index 2d9e724e6a73..eb5e02f98868 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -5,6 +5,9 @@ Installation ************ +.. include:: quick_install.inc.rst + +.. _install-official: Install an official release =========================== @@ -35,6 +38,7 @@ precompiled wheel for your OS and Python. animations and a larger selection of file formats, you can install :ref:`optional dependencies `. +.. _install-third-party: Third-party distributions ========================= @@ -81,7 +85,7 @@ you can install Matplotlib via your package manager, e.g.: .. redirect-from:: /users/installing/installing_source -.. _install_from_source: +.. _install-nightly-build: Install a nightly build ======================= @@ -101,6 +105,7 @@ scientific-python-nightly-wheels as the package index to query: --extra-index-url https://pypi.org/simple \ matplotlib +.. _install-source: Install from source =================== diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst new file mode 100644 index 000000000000..2e75b332f6ed --- /dev/null +++ b/doc/install/quick_install.inc.rst @@ -0,0 +1,54 @@ +.. set of quick install commands for reuse across docs + +.. tab-set:: + :class: sd-width-content-min + + .. tab-item:: pip + + .. code-block:: bash + + pip install matplotlib + + .. tab-item:: conda + + .. code-block:: bash + + conda install -c conda-forge matplotlib + + .. tab-item:: pixi + + .. code-block:: bash + + pixi add matplotlib + + .. tab-item:: uv + + .. code-block:: bash + + uv add matplotlib + + .. warning:: + + If you install Python with ``uv`` then the ``tkagg`` backend + will not be available because python-build-standalone (used by uv + to distribute Python) does not contain tk bindings that are usable by + Matplotlib (see `this issue`_ for details). If you want Matplotlib + to be able to display plots in a window, you should install one of + the other :ref:`supported GUI frameworks `, + e.g. + + .. code-block:: bash + + uv add matplotlib pyside6 + + .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 + + .. tab-item:: other + + :ref:`install-official` + + :ref:`install-third-party` + + :ref:`install-nightly-build` + + :ref:`install-source` diff --git a/doc/users/getting_started/index.rst b/doc/users/getting_started/index.rst index ac896687979d..dfbbd615b5cd 100644 --- a/doc/users/getting_started/index.rst +++ b/doc/users/getting_started/index.rst @@ -4,26 +4,7 @@ Getting started Installation quick-start ------------------------ -.. grid:: 1 1 2 2 - - .. grid-item:: - - Install using `pip `__: - - .. code-block:: bash - - pip install matplotlib - - .. grid-item:: - - Install using `conda `__: - - .. code-block:: bash - - conda install -c conda-forge matplotlib - -Further details are available in the :doc:`Installation Guide `. - +.. include:: /install/quick_install.inc.rst Draw a first plot ----------------- From 36e0f1accdb62e0b2b5ea5bfa03ea35bb5d2530a Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:36:14 +0200 Subject: [PATCH 035/228] FIX: Mark shared Axes as stale when propagating adjustable --- lib/matplotlib/axes/_base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index fa628b3f34e0..fd4c26d14a36 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1736,6 +1736,8 @@ def get_adjustable(self): Return whether the Axes will adjust its physical dimension ('box') or its data limits ('datalim') to achieve the desired aspect ratio. + Newly created Axes default to 'box'. + See Also -------- matplotlib.axes.Axes.set_adjustable @@ -1761,6 +1763,8 @@ def set_adjustable(self, adjustable, share=False): See Also -------- + matplotlib.axes.Axes.get_adjustable + Return the current value of *adjustable*. matplotlib.axes.Axes.set_aspect For a description of aspect handling. @@ -1792,7 +1796,7 @@ def set_adjustable(self, adjustable, share=False): "Axes which override 'get_data_ratio'") for ax in axs: ax._adjustable = adjustable - self.stale = True + ax.stale = True def get_box_aspect(self): """ From 1f2ca75ed9481793cda8227173e3622618c5a3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Mon, 25 Aug 2025 16:59:06 +0200 Subject: [PATCH 036/228] removed test_image_cursor_formatting() This test does not test im.format_cursor_data() because it test it with 1d arrays, whereas the artist.format_cursor_data() should forward to colrizingArtist._format_cursor_data_override() but this only happens with 0d data. The correct behavior with nans is tested in the existing test test_format_cursor_data(), so the test removed here, even if working as intended, is superfluous. --- lib/matplotlib/tests/test_image.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 00c223c59362..330a2fab503d 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -1168,21 +1168,6 @@ def test_respects_bbox(): assert buf_before.getvalue() != buf_after.getvalue() # Not all white. -def test_image_cursor_formatting(): - fig, ax = plt.subplots() - # Create a dummy image to be able to call format_cursor_data - im = ax.imshow(np.zeros((4, 4))) - - data = np.ma.masked_array([0], mask=[True]) - assert im.format_cursor_data(data) == '[]' - - data = np.ma.masked_array([0], mask=[False]) - assert im.format_cursor_data(data) == '[0]' - - data = np.nan - assert im.format_cursor_data(data) == '[nan]' - - @check_figures_equal(extensions=['png', 'pdf', 'svg']) def test_image_array_alpha(fig_test, fig_ref): """Per-pixel alpha channel test.""" From 64ecd118ec2029da602bee9ee80e22d40d8317dd Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 25 Aug 2025 22:35:14 +0200 Subject: [PATCH 037/228] Let ticklabels respect set_in_layout(False). This is useful for manual finetuning of layout of static plots. --- lib/matplotlib/axis.py | 6 ++++-- lib/matplotlib/tests/test_axis.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index d80e7b4dafb9..ba17c634ac5c 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1333,9 +1333,11 @@ def _update_ticks(self): def _get_ticklabel_bboxes(self, ticks, renderer): """Return lists of bboxes for ticks' label1's and label2's.""" return ([tick.label1.get_window_extent(renderer) - for tick in ticks if tick.label1.get_visible()], + for tick in ticks + if tick.label1.get_visible() and tick.label1.get_in_layout()], [tick.label2.get_window_extent(renderer) - for tick in ticks if tick.label2.get_visible()]) + for tick in ticks + if tick.label2.get_visible() and tick.label2.get_in_layout()]) def get_tightbbox(self, renderer=None, *, for_layout_only=False): """ diff --git a/lib/matplotlib/tests/test_axis.py b/lib/matplotlib/tests/test_axis.py index 97884a33208f..67d9ed5bde62 100644 --- a/lib/matplotlib/tests/test_axis.py +++ b/lib/matplotlib/tests/test_axis.py @@ -2,6 +2,7 @@ import matplotlib.pyplot as plt from matplotlib.axis import XTick +from matplotlib.testing.decorators import check_figures_equal def test_tick_labelcolor_array(): @@ -31,6 +32,21 @@ def test_axis_not_in_layout(): assert ax1_right.get_position().bounds == ax2_right.get_position().bounds +@check_figures_equal() +def test_tick_not_in_layout(fig_test, fig_ref): + # Check that the "very long" ticklabel is ignored from layouting after + # set_in_layout(False); i.e. the layout is as if the ticklabel was empty. + # Ticklabels are set to white so that the actual string doesn't matter. + fig_test.set_layout_engine("constrained") + ax = fig_test.add_subplot(xticks=[0, 1], xticklabels=["short", "very long"]) + ax.tick_params(labelcolor="w") + fig_test.draw_without_rendering() # Ensure ticks are correct. + ax.xaxis.majorTicks[-1].label1.set_in_layout(False) + fig_ref.set_layout_engine("constrained") + ax = fig_ref.add_subplot(xticks=[0, 1], xticklabels=["short", ""]) + ax.tick_params(labelcolor="w") + + def test_translate_tick_params_reverse(): fig, ax = plt.subplots() kw = {'label1On': 'a', 'label2On': 'b', 'tick1On': 'c', 'tick2On': 'd'} From f25ceed326766d8824d2474d6125f2b6537ed18a Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 27 Aug 2025 08:57:57 +0100 Subject: [PATCH 038/228] Backport PR #30476: ci: Remove cibuildwheel override for win_arm64/Py3.14 --- .github/workflows/cibuildwheel.yml | 4 ---- pyproject.toml | 8 -------- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 4284f7037e1a..c72ab67fd43c 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -151,10 +151,6 @@ jobs: CIBW_ENABLE: "cpython-freethreading cpython-prerelease" CIBW_ARCHS: ${{ matrix.cibw_archs }} CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - CIBW_BEFORE_TEST: >- - python -m pip install - --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple - --upgrade --pre --only-binary=:all: contourpy numpy pillow - name: Build wheels for CPython 3.13 uses: pypa/cibuildwheel@95d2f3a92fbf80abe066b09418bbf128a8923df2 # v3.0.1 diff --git a/pyproject.toml b/pyproject.toml index 5666016f0508..e6d1abaf530b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,14 +85,6 @@ local_scheme = "node-and-date" parentdir_prefix_version = "matplotlib-" fallback_version = "0.0+UNKNOWN" -# FIXME: Remove this override once dependencies are available on PyPI. -[[tool.cibuildwheel.overrides]] -select = "*-win_arm64" -before-test = """\ - pip install --pre \ - --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ - contourpy numpy""" - [tool.isort] known_pydata = "numpy, matplotlib.pyplot" known_firstparty = "matplotlib,mpl_toolkits" From 84436f081f56e63c482c67d41c278f0d624d45f4 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 28 Aug 2025 11:50:33 +0200 Subject: [PATCH 039/228] Clarify inset_locator.inset_axes demo. ... by clearly stating that this is the docs about `inset_locator.inset_axes`, which is different from `Axes.inset_axes`. Otherwise the last sentence ("Users should consider using `Axes.inset_axes` instead") gets quite confusing. --- .../demo_colorbar_with_inset_locator.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py b/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py index f62a0f58e3bc..352c8527910e 100644 --- a/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py +++ b/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py @@ -1,17 +1,17 @@ """ .. _demo-colorbar-with-inset-locator: -=========================================================== -Control the position and size of a colorbar with Inset Axes -=========================================================== +========================================================================= +Control the position and size of a colorbar with inset_locator.inset_axes +========================================================================= This example shows how to control the position, height, and width of colorbars -using `~mpl_toolkits.axes_grid1.inset_locator.inset_axes`. +using `.inset_locator.inset_axes`. -Inset Axes placement is controlled as for legends: either by providing a *loc* -option ("upper right", "best", ...), or by providing a locator with respect to -the parent bbox. Parameters such as *bbox_to_anchor* and *borderpad* likewise -work in the same way, and are also demonstrated here. +`.inset_locator.inset_axes` placement is controlled as for legends: either +by providing a *loc* option ("upper right", "best", ...), or by providing a +locator with respect to the parent bbox. Parameters such as *bbox_to_anchor* +and *borderpad* likewise work in the same way, and are also demonstrated here. Users should consider using `.Axes.inset_axes` instead (see :ref:`colorbar_placement`). @@ -21,12 +21,12 @@ import matplotlib.pyplot as plt -from mpl_toolkits.axes_grid1.inset_locator import inset_axes +from mpl_toolkits.axes_grid1 import inset_locator fig, (ax1, ax2) = plt.subplots(1, 2, figsize=[6, 3]) im1 = ax1.imshow([[1, 2], [2, 3]]) -axins1 = inset_axes( +axins1 = inset_locator.inset_axes( ax1, width="50%", # width: 50% of parent_bbox width height="5%", # height: 5% @@ -36,7 +36,7 @@ fig.colorbar(im1, cax=axins1, orientation="horizontal", ticks=[1, 2, 3]) im = ax2.imshow([[1, 2], [2, 3]]) -axins = inset_axes( +axins = inset_locator.inset_axes( ax2, width="5%", # width: 5% of parent_bbox width height="50%", # height: 50% From e1e13605fd5dc7fea6589a19b9884db48498ceef Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 28 Aug 2025 13:30:11 -0500 Subject: [PATCH 040/228] Backport PR #30394: ENH: Gracefully handle python-build-standalone ImportError with Tk --- lib/matplotlib/backends/_backend_tk.py | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..813e0c60620f 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -22,8 +22,36 @@ TimerBase, ToolContainerBase, cursors, _Mode, MouseButton, CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib._pylab_helpers import Gcf -from . import _tkagg -from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET + +try: + from . import _tkagg + from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET +except ImportError as e: + # catch incompatibility of python-build-standalone with Tk + cause1 = getattr(e, '__cause__', None) + cause2 = getattr(cause1, '__cause__', None) + if (isinstance(cause1, ImportError) and + isinstance(cause2, AttributeError) and + "'_tkinter' has no attribute '__file__'" in str(cause2)): + + is_uv_python = "/uv/python" in (os.path.realpath(sys.executable)) + if is_uv_python: + raise ImportError( + "Failed to import tkagg backend. You appear to be using an outdated " + "version of uv's managed Python distribution which is not compatible " + "with Tk. Please upgrade to the latest uv version, then update " + "Python with: `uv python upgrade --reinstall`" + ) from e + else: + raise ImportError( + "Failed to import tkagg backend. This is likely caused by using a " + "Python executable based on python-build-standalone, which is not " + "compatible with Tk. Recent versions of python-build-standalone " + "should be compatible with Tk. Please update your python version " + "or select another backend." + ) from e + else: + raise _log = logging.getLogger(__name__) From a6267f787bd3a927de7ce56456675abb3eb140e9 Mon Sep 17 00:00:00 2001 From: Brian Christian Date: Tue, 26 Aug 2025 15:40:09 -0700 Subject: [PATCH 041/228] Fix spelling error in `contains_branch_seperately` method name Fixes #30474. This PR fixes a long-standing spelling error in the `contains_branch_seperately` method name, which should be `contains_branch_separately`. In order to maintain backwards compatibility, aliases maintain the old spelling for legacy use. Type hints and internal references are updated to the new spelling accordingly. --- lib/matplotlib/axes/_axes.py | 6 +++--- lib/matplotlib/axes/_base.py | 10 +++++----- lib/matplotlib/collections.py | 2 +- lib/matplotlib/contour.py | 2 +- lib/matplotlib/tests/test_transforms.py | 4 ++-- lib/matplotlib/transforms.py | 19 ++++++++++++------- lib/matplotlib/transforms.pyi | 5 +++-- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index f0bc139bdc11..6da0e925ab4f 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1183,7 +1183,7 @@ def hlines(self, y, xmin, xmax, colors=None, linestyles='solid', if self.name == "rectilinear": datalim = lines.get_datalim(self.transData) t = lines.get_transform() - updatex, updatey = t.contains_branch_seperately(self.transData) + updatex, updatey = t.contains_branch_separately(self.transData) minx = np.nanmin(datalim.xmin) maxx = np.nanmax(datalim.xmax) miny = np.nanmin(datalim.ymin) @@ -1275,7 +1275,7 @@ def vlines(self, x, ymin, ymax, colors=None, linestyles='solid', if self.name == "rectilinear": datalim = lines.get_datalim(self.transData) t = lines.get_transform() - updatex, updatey = t.contains_branch_seperately(self.transData) + updatex, updatey = t.contains_branch_separately(self.transData) minx = np.nanmin(datalim.xmin) maxx = np.nanmax(datalim.xmax) miny = np.nanmin(datalim.ymin) @@ -6809,7 +6809,7 @@ def _update_pcolor_lims(self, collection, coords): hasattr(t, '_as_mpl_transform')): t = t._as_mpl_transform(self.axes) - if t and any(t.contains_branch_seperately(self.transData)): + if t and any(t.contains_branch_separately(self.transData)): trans_to_data = t - self.transData coords = trans_to_data.transform(coords) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index fa628b3f34e0..42f6e51786bf 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2380,9 +2380,9 @@ def add_collection(self, collection, autolim=True): # only update the dataLim for x/y if the collection uses transData # in this direction. x_is_data, y_is_data = (collection.get_transform() - .contains_branch_seperately(self.transData)) + .contains_branch_separately(self.transData)) ox_is_data, oy_is_data = (collection.get_offset_transform() - .contains_branch_seperately(self.transData)) + .contains_branch_separately(self.transData)) self.update_datalim( points, updatex=x_is_data or ox_is_data, @@ -2451,7 +2451,7 @@ def _update_line_limits(self, line): if line_trf == self.transData: data_path = path - elif any(line_trf.contains_branch_seperately(self.transData)): + elif any(line_trf.contains_branch_separately(self.transData)): # Compute the transform from line coordinates to data coordinates. trf_to_data = line_trf - self.transData # If transData is affine we can use the cached non-affine component @@ -2474,7 +2474,7 @@ def _update_line_limits(self, line): if not data_path.vertices.size: return - updatex, updatey = line_trf.contains_branch_seperately(self.transData) + updatex, updatey = line_trf.contains_branch_separately(self.transData) if self.name != "rectilinear": # This block is mostly intended to handle axvline in polar plots, # for which updatey would otherwise be True. @@ -2527,7 +2527,7 @@ def _update_patch_limits(self, patch): vertices = np.vstack(vertices) patch_trf = patch.get_transform() - updatex, updatey = patch_trf.contains_branch_seperately(self.transData) + updatex, updatey = patch_trf.contains_branch_separately(self.transData) if not (updatex or updatey): return if self.name != "rectilinear": diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index ec6d40805da0..684e15cdf854 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -283,7 +283,7 @@ def get_datalim(self, transData): offsets = self.get_offsets() - if any(transform.contains_branch_seperately(transData)): + if any(transform.contains_branch_separately(transData)): # collections that are just in data units (like quiver) # can properly have the axes limits set by their shape + # offset. LineCollections that have no offsets can diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 643bfce4273a..1125b9c5fbc6 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -1333,7 +1333,7 @@ def _process_args(self, *args, corner_mask=None, algorithm=None, **kwargs): # if the transform is not trans data, and some part of it # contains transData, transform the xs and ys to data coordinates if (t != self.axes.transData and - any(t.contains_branch_seperately(self.axes.transData))): + any(t.contains_branch_separately(self.axes.transData))): trans_to_data = t - self.axes.transData pts = np.vstack([x.flat, y.flat]).T transformed_pts = trans_to_data.transform(pts) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index b4db34db5a91..dbff0e1ba39f 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -694,9 +694,9 @@ def test_contains_branch(self): assert not self.stack1.contains_branch(self.tn1 + self.ta2) blend = mtransforms.BlendedGenericTransform(self.tn2, self.stack2) - x, y = blend.contains_branch_seperately(self.stack2_subset) + x, y = blend.contains_branch_separately(self.stack2_subset) stack_blend = self.tn3 + blend - sx, sy = stack_blend.contains_branch_seperately(self.stack2_subset) + sx, sy = stack_blend.contains_branch_separately(self.stack2_subset) assert x is sx is False assert y is sy is True diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 350113c56170..a22a2f7db070 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -1418,7 +1418,7 @@ def contains_branch(self, other): return True return False - def contains_branch_seperately(self, other_transform): + def contains_branch_separately(self, other_transform): """ Return whether the given branch is a sub-tree of this transform on each separate dimension. @@ -1426,16 +1426,21 @@ def contains_branch_seperately(self, other_transform): A common use for this method is to identify if a transform is a blended transform containing an Axes' data transform. e.g.:: - x_isdata, y_isdata = trans.contains_branch_seperately(ax.transData) + x_isdata, y_isdata = trans.contains_branch_separately(ax.transData) """ if self.output_dims != 2: - raise ValueError('contains_branch_seperately only supports ' + raise ValueError('contains_branch_separately only supports ' 'transforms with 2 output dimensions') # for a non-blended transform each separate dimension is the same, so # just return the appropriate shape. return (self.contains_branch(other_transform), ) * 2 + # Permanent alias for backwards compatibility (historical typo) + def contains_branch_seperately(self, other_transform): + """:meta private:""" + return self.contains_branch_separately(other_transform) + def __sub__(self, other): """ Compose *self* with the inverse of *other*, cancelling identical terms @@ -2185,7 +2190,7 @@ def __eq__(self, other): else: return NotImplemented - def contains_branch_seperately(self, transform): + def contains_branch_separately(self, transform): return (self._x.contains_branch(transform), self._y.contains_branch(transform)) @@ -2411,14 +2416,14 @@ def _iter_break_from_left_to_right(self): for left, right in self._b._iter_break_from_left_to_right(): yield self._a + left, right - def contains_branch_seperately(self, other_transform): + def contains_branch_separately(self, other_transform): # docstring inherited if self.output_dims != 2: - raise ValueError('contains_branch_seperately only supports ' + raise ValueError('contains_branch_separately only supports ' 'transforms with 2 output dimensions') if self == other_transform: return (True, True) - return self._b.contains_branch_seperately(other_transform) + return self._b.contains_branch_separately(other_transform) depth = property(lambda self: self._a.depth + self._b.depth) is_affine = property(lambda self: self._a.is_affine and self._b.is_affine) diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 07d299be297c..4e582904d1d8 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -189,9 +189,10 @@ class Transform(TransformNode): @property def depth(self) -> int: ... def contains_branch(self, other: Transform) -> bool: ... - def contains_branch_seperately( + def contains_branch_separately( self, other_transform: Transform ) -> Sequence[bool]: ... + contains_branch_seperately = contains_branch_separately # Alias (historical typo) def __sub__(self, other: Transform) -> Transform: ... def __array__(self, *args, **kwargs) -> np.ndarray: ... def transform(self, values: ArrayLike) -> np.ndarray: ... @@ -252,7 +253,7 @@ class IdentityTransform(Affine2DBase): ... class _BlendedMixin: def __eq__(self, other: object) -> bool: ... - def contains_branch_seperately(self, transform: Transform) -> Sequence[bool]: ... + def contains_branch_separately(self, transform: Transform) -> Sequence[bool]: ... class BlendedGenericTransform(_BlendedMixin, Transform): input_dims: Literal[2] From 4b0a26a9d18b8a39ecbbb23d12c8ff35a5cfff41 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 28 Aug 2025 23:21:30 -0400 Subject: [PATCH 042/228] FIX: be more cautious about checking widget size This can fail if the c++ side of the object has already been cleaned up but the Python side is still lingering (due to refs in callbacks). closes #29618 --- lib/matplotlib/backends/backend_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index dd614e516de5..d0aded5fff63 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -517,7 +517,7 @@ def _draw_idle(self): if not self._draw_pending: return self._draw_pending = False - if self.height() <= 0 or self.width() <= 0: + if _isdeleted(self) or self.height() <= 0 or self.width() <= 0: return try: self.draw() From d2b3e9596991387d46e72f021cd1701d21f51187 Mon Sep 17 00:00:00 2001 From: Geoffrey Thomas Date: Fri, 29 Aug 2025 13:39:45 -0400 Subject: [PATCH 043/228] doc: Update warnings about python-build-standalone (fixes #30409) --- doc/install/index.rst | 19 ++++++++++++++----- doc/install/quick_install.inc.rst | 17 ++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/doc/install/index.rst b/doc/install/index.rst index 21804aa81622..4058b0549738 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -28,11 +28,20 @@ precompiled wheel for your OS and Python. .. note:: - The following backends work out of the box: Agg, ps, pdf, svg - - Python is typically shipped with tk bindings which are used by - TkAgg. Notably, python-build-standalone – used by ``uv`` – does - not include tk bindings that are usable by Matplotlib. + The following non-interactive backends work out of the box: Agg, + ps, pdf, svg + + The TkAgg interactive backend also typically works out of the box. + It requires Tk bindings, which are usually provided via the Python + standard library's ``tkinter`` module. On some OSes, you may need + to install a separate package like ``python3-tk`` to add this + component of the standard library. + + Some tools like ``uv`` make use of Python builds from the + python-build-standalone project, which only gained usable Tk + bindings recently (August 2025). If you are having trouble with the + TkAgg backend, ensure you have an up-to-date build, e.g. ``uv self + update && uv python upgrade --reinstall``. For support of other GUI frameworks, LaTeX rendering, saving animations and a larger selection of file formats, you can diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst index 2e75b332f6ed..0604a3c8fe75 100644 --- a/doc/install/quick_install.inc.rst +++ b/doc/install/quick_install.inc.rst @@ -29,20 +29,19 @@ .. warning:: - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. + uv usually installs its own versions of Python from the + python-build-standalone project, and only recent versions of those + Python builds (August 2025) work properly with the ``tkagg`` backend + for displaying plots in a window. Please make sure you are using uv + 0.8.7 or newer (update with e.g. ``uv self update``) and that your + bundled Python installs are up to date (with ``uv python upgrade + --reinstall``). Alternatively, you can use one of the other + :ref:`supported GUI frameworks `, e.g. .. code-block:: bash uv add matplotlib pyside6 - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - .. tab-item:: other :ref:`install-official` From 8693a2018bac679e951c3aa70ca208cb38905b70 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 29 Aug 2025 14:12:31 -0400 Subject: [PATCH 044/228] CI: try skipping just released version of coverage --- requirements/testing/all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index e386924a9b67..7351c2470f9f 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -2,7 +2,7 @@ black<24 certifi -coverage!=6.3 +coverage!=6.3,!=7.10.6 psutil pytest!=4.6.0,!=5.4.0,!=8.1.0 pytest-cov From 63842c09648577d870b5ed19df383ac977b91cd8 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 29 Aug 2025 14:30:04 -0400 Subject: [PATCH 045/228] CI: try pinning back pytest-rerunfailures instead --- requirements/testing/all.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index 7351c2470f9f..a41073bdf47e 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -2,11 +2,11 @@ black<24 certifi -coverage!=6.3,!=7.10.6 +coverage!=6.3 psutil pytest!=4.6.0,!=5.4.0,!=8.1.0 pytest-cov -pytest-rerunfailures +pytest-rerunfailures!=16.0 pytest-timeout pytest-xdist pytest-xvfb From a55b817f019c0b2a6550fa8d255a821605651ed7 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 15:20:22 -0500 Subject: [PATCH 046/228] Backport PR #30484: FIX: be more cautious about checking widget size --- lib/matplotlib/backends/backend_qt.py | 2 +- requirements/testing/all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index dd614e516de5..d0aded5fff63 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -517,7 +517,7 @@ def _draw_idle(self): if not self._draw_pending: return self._draw_pending = False - if self.height() <= 0 or self.width() <= 0: + if _isdeleted(self) or self.height() <= 0 or self.width() <= 0: return try: self.draw() diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index e386924a9b67..a41073bdf47e 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -6,7 +6,7 @@ coverage!=6.3 psutil pytest!=4.6.0,!=5.4.0,!=8.1.0 pytest-cov -pytest-rerunfailures +pytest-rerunfailures!=16.0 pytest-timeout pytest-xdist pytest-xvfb From 4b794954750f7a1218c9d0c032ec30ba7a6088e6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 29 Aug 2025 17:30:11 -0400 Subject: [PATCH 047/228] Backport PR #30486: doc: Update warnings about python-build-standalone --- doc/install/index.rst | 19 ++++++++++++++----- doc/install/quick_install.inc.rst | 17 ++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/doc/install/index.rst b/doc/install/index.rst index eb5e02f98868..429e4bdaf60d 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -28,11 +28,20 @@ precompiled wheel for your OS and Python. .. note:: - The following backends work out of the box: Agg, ps, pdf, svg - - Python is typically shipped with tk bindings which are used by - TkAgg. Notably, python-build-standalone – used by ``uv`` – does - not include tk bindings that are usable by Matplotlib. + The following non-interactive backends work out of the box: Agg, + ps, pdf, svg + + The TkAgg interactive backend also typically works out of the box. + It requires Tk bindings, which are usually provided via the Python + standard library's ``tkinter`` module. On some OSes, you may need + to install a separate package like ``python3-tk`` to add this + component of the standard library. + + Some tools like ``uv`` make use of Python builds from the + python-build-standalone project, which only gained usable Tk + bindings recently (August 2025). If you are having trouble with the + TkAgg backend, ensure you have an up-to-date build, e.g. ``uv self + update && uv python upgrade --reinstall``. For support of other GUI frameworks, LaTeX rendering, saving animations and a larger selection of file formats, you can diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst index 2e75b332f6ed..0604a3c8fe75 100644 --- a/doc/install/quick_install.inc.rst +++ b/doc/install/quick_install.inc.rst @@ -29,20 +29,19 @@ .. warning:: - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. + uv usually installs its own versions of Python from the + python-build-standalone project, and only recent versions of those + Python builds (August 2025) work properly with the ``tkagg`` backend + for displaying plots in a window. Please make sure you are using uv + 0.8.7 or newer (update with e.g. ``uv self update``) and that your + bundled Python installs are up to date (with ``uv python upgrade + --reinstall``). Alternatively, you can use one of the other + :ref:`supported GUI frameworks `, e.g. .. code-block:: bash uv add matplotlib pyside6 - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - .. tab-item:: other :ref:`install-official` From 25a97fd721edeee7ef2916cdbb79e050187f3d11 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 29 Aug 2025 17:30:11 -0400 Subject: [PATCH 048/228] Backport PR #30486: doc: Update warnings about python-build-standalone --- doc/install/index.rst | 19 ++++++++++++++----- doc/install/quick_install.inc.rst | 17 ++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/doc/install/index.rst b/doc/install/index.rst index eb5e02f98868..429e4bdaf60d 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -28,11 +28,20 @@ precompiled wheel for your OS and Python. .. note:: - The following backends work out of the box: Agg, ps, pdf, svg - - Python is typically shipped with tk bindings which are used by - TkAgg. Notably, python-build-standalone – used by ``uv`` – does - not include tk bindings that are usable by Matplotlib. + The following non-interactive backends work out of the box: Agg, + ps, pdf, svg + + The TkAgg interactive backend also typically works out of the box. + It requires Tk bindings, which are usually provided via the Python + standard library's ``tkinter`` module. On some OSes, you may need + to install a separate package like ``python3-tk`` to add this + component of the standard library. + + Some tools like ``uv`` make use of Python builds from the + python-build-standalone project, which only gained usable Tk + bindings recently (August 2025). If you are having trouble with the + TkAgg backend, ensure you have an up-to-date build, e.g. ``uv self + update && uv python upgrade --reinstall``. For support of other GUI frameworks, LaTeX rendering, saving animations and a larger selection of file formats, you can diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst index 2e75b332f6ed..0604a3c8fe75 100644 --- a/doc/install/quick_install.inc.rst +++ b/doc/install/quick_install.inc.rst @@ -29,20 +29,19 @@ .. warning:: - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. + uv usually installs its own versions of Python from the + python-build-standalone project, and only recent versions of those + Python builds (August 2025) work properly with the ``tkagg`` backend + for displaying plots in a window. Please make sure you are using uv + 0.8.7 or newer (update with e.g. ``uv self update``) and that your + bundled Python installs are up to date (with ``uv python upgrade + --reinstall``). Alternatively, you can use one of the other + :ref:`supported GUI frameworks `, e.g. .. code-block:: bash uv add matplotlib pyside6 - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - .. tab-item:: other :ref:`install-official` From d3365af7e461b3c4bebb0351a06809ad503a3db9 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:05:48 -0500 Subject: [PATCH 049/228] Github stats v3.10.6 --- doc/users/github_stats.rst | 130 ++++------------ .../prev_whats_new/github_stats_3.10.5.rst | 142 ++++++++++++++++++ 2 files changed, 174 insertions(+), 98 deletions(-) create mode 100644 doc/users/prev_whats_new/github_stats_3.10.5.rst diff --git a/doc/users/github_stats.rst b/doc/users/github_stats.rst index e28b39088994..84c2cc5867fd 100644 --- a/doc/users/github_stats.rst +++ b/doc/users/github_stats.rst @@ -1,33 +1,29 @@ .. _github-stats: -GitHub statistics for 3.10.5 (Jul 31, 2025) +GitHub statistics for 3.10.6 (Aug 29, 2025) =========================================== -GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/07/31 +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/08/29 These lists are automatically generated, and may be incomplete or contain duplicates. -We closed 18 issues and merged 67 pull requests. -The full list can be seen `on GitHub `__ +We closed 4 issues and merged 19 pull requests. +The full list can be seen `on GitHub `__ -The following 36 authors contributed 371 commits. +The following 31 authors contributed 380 commits. +* Alan Burlot * Antony Lee -* Brian Christian -* chrisjbillington * Christine P. Chai -* Clément Robert * David Stansby * dependabot[bot] +* Doron Behar * Elliott Sales de Andrade * G.D. McBain * Greg Lucas * hannah * hu-xiaonan * Ian Thomas -* ianlv -* IdiotCoffee -* Ines Cachola * Inês Cachola * Jody Klymak * Jouni K. Seppänen @@ -41,7 +37,6 @@ The following 36 authors contributed 371 commits. * Qian Zhang * Raphael Erik Hviding * Roman -* Roman A * Ruth Comer * saikarna913 * Scott Shambaugh @@ -51,95 +46,34 @@ The following 36 authors contributed 371 commits. GitHub issues and pull requests: -Pull Requests (67): +Pull Requests (19): -* :ghpull:`30357`: CIBW updates: fix pypy sections, update cibw version -* :ghpull:`30356`: Manual Backport PR #30195 on branch v3.10.x (ci: Enable wheel builds on Python 3.14) -* :ghpull:`30352`: Backport PR #28554 on branch v3.10.x (BLD: Enable wheels on Windows-on-ARM) -* :ghpull:`30353`: Backport PR #30345 on branch v3.10.x (qt: Use better devicePixelRatio event to refresh scaling) -* :ghpull:`30350`: Backport PR #30344 on branch v3.10.x (Support fractional HiDPI in GTK4 backend) -* :ghpull:`30277`: Backport PR #30271 on branch v3.10.x (Reduce pause time in interactive timer test) -* :ghpull:`30351`: Backport PR #30327 on branch v3.10.x (FIX Update Axes limits from Axes.add_collection(... autolim=True)) -* :ghpull:`30345`: qt: Use better devicePixelRatio event to refresh scaling -* :ghpull:`28554`: BLD: Enable wheels on Windows-on-ARM -* :ghpull:`30292`: Backport PR #30237: Add explicit ``**options: Any`` for ``add_subplot`` m… -* :ghpull:`29935`: Backport PR #29908 on branch v3.10.x (TST: Use text placeholders for empty legends) -* :ghpull:`30327`: FIX Update Axes limits from Axes.add_collection(... autolim=True) -* :ghpull:`30344`: Support fractional HiDPI in GTK4 backend -* :ghpull:`30326`: Backport PR #30321 on branch v3.10.x (Fix type annotation for Axes.get_legend() to include None) -* :ghpull:`30321`: Fix type annotation for Axes.get_legend() to include None -* :ghpull:`30287`: Backport PR #30286 on branch v3.10.x (Fix whitespace in _axes.py error message) -* :ghpull:`30288`: Backport PR #30283 on branch v3.10.x (changed the FAQ link to point to the correct path) -* :ghpull:`30293`: Backport PR #30289 on branch v3.10.x (DOC: Fix build with pybind11 3) -* :ghpull:`30283`: changed the FAQ link to point to the correct path -* :ghpull:`30286`: Fix whitespace in _axes.py error message -* :ghpull:`30271`: Reduce pause time in interactive timer test -* :ghpull:`30269`: Backport PR #30186 on branch v3.10.x (Fix figure legend when drawing stackplots) -* :ghpull:`30186`: Fix figure legend when drawing stackplots -* :ghpull:`30268`: Backport PR #30233 on branch v3.10.x (Check that stem input is 1D) -* :ghpull:`30233`: Check that stem input is 1D -* :ghpull:`30259`: Backport PR #30256 on branch v3.10.x (Time out in _get_executable_info) -* :ghpull:`30256`: Time out in _get_executable_info -* :ghpull:`30237`: Add explicit ``**options: Any`` for ``add_subplot`` method -* :ghpull:`30253`: Backport PR #30243 on branch v3.10.x (Fix FancyArrow rendering for zero-length arrows) -* :ghpull:`30243`: Fix FancyArrow rendering for zero-length arrows -* :ghpull:`30250`: Backport PR #30244 on branch v3.10.x (DOC: Recommend to use bare Figure instances for saving to file) -* :ghpull:`30247`: Backport PR #30246 on branch v3.10.x (chore: remove redundant words in comment) -* :ghpull:`30246`: chore: remove redundant words in comment -* :ghpull:`30240`: Backport PR #30236 on branch v3.10.x (Copy-edit the docstring of AuxTransformBox.) -* :ghpull:`30236`: Copy-edit the docstring of AuxTransformBox. -* :ghpull:`30234`: Backport PR #30209 on branch v3.10.x (Clean up Qt socket notifier to avoid spurious interrupt handler calls) -* :ghpull:`30209`: Clean up Qt socket notifier to avoid spurious interrupt handler calls -* :ghpull:`30195`: ci: Enable wheel builds on Python 3.14 -* :ghpull:`30229`: Backport PR #30221 on branch v3.10.x (BUG: fix future incompatibility with Pillow 13) -* :ghpull:`30221`: BUG: fix future incompatibility with Pillow 13 -* :ghpull:`30228`: Backport PR #30098 on branch v3.10.x (Fix label_outer in the presence of colorbars.) -* :ghpull:`30227`: Backport PR #30223 on branch v3.10.x (Polar log scale: fix inner patch boundary and spine location) -* :ghpull:`30098`: Fix label_outer in the presence of colorbars. -* :ghpull:`30223`: Polar log scale: fix inner patch boundary and spine location -* :ghpull:`30217`: Backport PR #30198 on branch v3.10.x (Implement Path.__deepcopy__ avoiding infinite recursion) -* :ghpull:`30198`: Implement Path.__deepcopy__ avoiding infinite recursion -* :ghpull:`30213`: Backport PR #30212 on branch v3.10.x ([Doc]: fix bug in release notes for matplotlib v3.5.0 and v3.7.0) -* :ghpull:`30189`: Backport PR #30180 on branch v3.10.x (DOC: expand polar example) -* :ghpull:`30167`: Backport PR #30162 on branch v3.10.x (TST: Fix runtime error checking NaN input to format_cursor_data) -* :ghpull:`30162`: TST: Fix runtime error checking NaN input to format_cursor_data -* :ghpull:`30146`: Backport PR #30144 on branch v3.10.x (js: Fix externally-controlled format strings) -* :ghpull:`30144`: js: Fix externally-controlled format strings -* :ghpull:`30140`: Backport PR #30118 on branch v3.10.x (CI: Skip jobs on forks) -* :ghpull:`30120`: Backport PR #30114 on branch v3.10.x (Fix _is_tensorflow_array.) -* :ghpull:`30122`: Backport PR #30119 on branch v3.10.x (Add some types to _mathtext.py) -* :ghpull:`30119`: Add some types to _mathtext.py -* :ghpull:`30114`: Fix _is_tensorflow_array. -* :ghpull:`30106`: Backport PR #30089 on branch v3.10.x (FIX: fix submerged margins algorithm being applied twice) -* :ghpull:`30089`: FIX: fix submerged margins algorithm being applied twice -* :ghpull:`30101`: Backport PR #30096 on branch v3.10.x (Fix OffsetBox custom picker) -* :ghpull:`30096`: Fix OffsetBox custom picker -* :ghpull:`30081`: Backport PR #30079 on branch v3.10.x (FIX: cast legend handles to list) -* :ghpull:`30079`: FIX: cast legend handles to list -* :ghpull:`30057`: Backport PR #29895 on branch v3.10.x (The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps)" -* :ghpull:`29895`: The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps -* :ghpull:`30033`: Backport PR #30029 on branch v3.10.x (Update diagram in subplots_adjust documentation to clarify parameters) +* :ghpull:`30487`: Backport PR #30484 on branch v3.10.x (FIX: be more cautious about checking widget size) +* :ghpull:`30484`: FIX: be more cautious about checking widget size +* :ghpull:`30481`: Backport PR #30394 on branch v3.10.x (ENH: Gracefully handle python-build-standalone ImportError with Tk) +* :ghpull:`30477`: Backport PR #30476 on branch v3.10.x (ci: Remove cibuildwheel override for win_arm64/Py3.14) +* :ghpull:`30394`: ENH: Gracefully handle python-build-standalone ImportError with Tk +* :ghpull:`30476`: ci: Remove cibuildwheel override for win_arm64/Py3.14 +* :ghpull:`30461`: Backport PR #30451 on branch v3.10.x (doc: factor out quick install tab for reuse) +* :ghpull:`30448`: Backport PR #30412 on branch v3.10.x ({Check,Radio}Buttons: Improve docs of label_props) +* :ghpull:`30412`: {Check,Radio}Buttons: Improve docs of label_props +* :ghpull:`30445`: Backport PR #30444 on branch v3.10.x (Small correction of a typo in the galleries: axis instead of axes) +* :ghpull:`30444`: Small correction of a typo in the galleries: axis instead of axes +* :ghpull:`30430`: Backport PR #30426 on branch v3.10.x (Fix a race condition in TexManager.make_dvi.) +* :ghpull:`30434`: Backport PR #30426: Fix a race condition in TexManager.make_dvi & make_png. +* :ghpull:`30431`: Use pathlib in texmanager. +* :ghpull:`30428`: Backport PR #30399 on branch v3.10.x (Qt: Fix HiDPI handling on X11/Windows) +* :ghpull:`30426`: Fix a race condition in TexManager.make_dvi. +* :ghpull:`30399`: Qt: Fix HiDPI handling on X11/Windows +* :ghpull:`30415`: Backport PR #30414 on branch v3.10.x (DOC: update Cartopy url) +* :ghpull:`30414`: DOC: update Cartopy url -Issues (18): +Issues (4): -* :ghissue:`30370`: [Bug]: matplotlib simple example fails in Python 3.14 -* :ghissue:`30218`: [Bug]: Rendering on Wayland with fractional scaling looks bad -* :ghissue:`30318`: [Bug]: type annotation of ``Axes.get_legend()`` misses ``None`` -* :ghissue:`30169`: [Doc]: Incorrect FAQ Link on Tutorials Page -* :ghissue:`30285`: [Bug]: Missing whitespace in _axes.py error message -* :ghissue:`30280`: [Bug]: Pillow 11.3 raises a deprecation warning when using TkAgg -* :ghissue:`30158`: [Bug]: Stackplot in SubFigure raises when drawing Legend -* :ghissue:`30216`: [Bug]: stem complaining about PyTorch's Tensor -* :ghissue:`30242`: [Bug]: Cannot create empty FancyArrow (expired numpy deprecation) -* :ghissue:`30249`: [Bug]: DeprecationWarning from Pillow 11.3.0 about 'mode' parameter of PIL.Image.fromarray() -* :ghissue:`29688`: [Bug]: "Bad file descriptor" raised repeatedly when plt.pause() interrupted in IPython -* :ghissue:`27305`: [Bug]: Axes.label_outer() does not work when there is a colorbar -* :ghissue:`30179`: [Bug]: Inner border is not rendered correctly when using log-scale and polar projection. -* :ghissue:`29157`: FUTURE BUG: reconsider how we deep-copy path objects -* :ghissue:`30152`: [Bug]: Test pipeline failure on windows -* :ghissue:`30076`: [Bug]: Layout Managers are confused by complex arrangement of sub-figures and gridspec's -* :ghissue:`30078`: [Bug]: legend no longer works with itertools.chain -* :ghissue:`29780`: [Bug]: Setting 'lines.markeredgecolor' affects color of errorbar caps. +* :ghissue:`29618`: [Bug]: FigureCanvasQT is seemingly prematurely freed under certain conditions +* :ghissue:`30390`: [ENH]: Gracefully handle python-build-standalone ImportError +* :ghissue:`30420`: [ENH]: Support parallel plotting +* :ghissue:`30386`: BUG: Qt hi-dpi regression on windows and X11 with mpl 3.10.5 Previous GitHub statistics diff --git a/doc/users/prev_whats_new/github_stats_3.10.5.rst b/doc/users/prev_whats_new/github_stats_3.10.5.rst new file mode 100644 index 000000000000..319086baebe5 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.10.5.rst @@ -0,0 +1,142 @@ +.. _github-stats-3_10_5: + +GitHub statistics for 3.10.5 (Jul 31, 2025) +=========================================== + +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/07/31 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 18 issues and merged 67 pull requests. +The full list can be seen `on GitHub `__ + +The following 36 authors contributed 371 commits. + +* Antony Lee +* Brian Christian +* chrisjbillington +* Christine P. Chai +* Clément Robert +* David Stansby +* dependabot[bot] +* Elliott Sales de Andrade +* G.D. McBain +* Greg Lucas +* hannah +* hu-xiaonan +* Ian Thomas +* ianlv +* IdiotCoffee +* Ines Cachola +* Inês Cachola +* Jody Klymak +* Jouni K. Seppänen +* Khushi_29 +* Kyle Sunden +* Lumberbot (aka Jack) +* N R Navaneet +* Nathan G. Wiseman +* Oscar Gustafsson +* Praful Gulani +* Qian Zhang +* Raphael Erik Hviding +* Roman +* Roman A +* Ruth Comer +* saikarna913 +* Scott Shambaugh +* Thomas A Caswell +* Tim Hoffmann +* Trygve Magnus Ræder + +GitHub issues and pull requests: + +Pull Requests (67): + +* :ghpull:`30357`: CIBW updates: fix pypy sections, update cibw version +* :ghpull:`30356`: Manual Backport PR #30195 on branch v3.10.x (ci: Enable wheel builds on Python 3.14) +* :ghpull:`30352`: Backport PR #28554 on branch v3.10.x (BLD: Enable wheels on Windows-on-ARM) +* :ghpull:`30353`: Backport PR #30345 on branch v3.10.x (qt: Use better devicePixelRatio event to refresh scaling) +* :ghpull:`30350`: Backport PR #30344 on branch v3.10.x (Support fractional HiDPI in GTK4 backend) +* :ghpull:`30277`: Backport PR #30271 on branch v3.10.x (Reduce pause time in interactive timer test) +* :ghpull:`30351`: Backport PR #30327 on branch v3.10.x (FIX Update Axes limits from Axes.add_collection(... autolim=True)) +* :ghpull:`30345`: qt: Use better devicePixelRatio event to refresh scaling +* :ghpull:`28554`: BLD: Enable wheels on Windows-on-ARM +* :ghpull:`30292`: Backport PR #30237: Add explicit ``**options: Any`` for ``add_subplot`` m… +* :ghpull:`29935`: Backport PR #29908 on branch v3.10.x (TST: Use text placeholders for empty legends) +* :ghpull:`30327`: FIX Update Axes limits from Axes.add_collection(... autolim=True) +* :ghpull:`30344`: Support fractional HiDPI in GTK4 backend +* :ghpull:`30326`: Backport PR #30321 on branch v3.10.x (Fix type annotation for Axes.get_legend() to include None) +* :ghpull:`30321`: Fix type annotation for Axes.get_legend() to include None +* :ghpull:`30287`: Backport PR #30286 on branch v3.10.x (Fix whitespace in _axes.py error message) +* :ghpull:`30288`: Backport PR #30283 on branch v3.10.x (changed the FAQ link to point to the correct path) +* :ghpull:`30293`: Backport PR #30289 on branch v3.10.x (DOC: Fix build with pybind11 3) +* :ghpull:`30283`: changed the FAQ link to point to the correct path +* :ghpull:`30286`: Fix whitespace in _axes.py error message +* :ghpull:`30271`: Reduce pause time in interactive timer test +* :ghpull:`30269`: Backport PR #30186 on branch v3.10.x (Fix figure legend when drawing stackplots) +* :ghpull:`30186`: Fix figure legend when drawing stackplots +* :ghpull:`30268`: Backport PR #30233 on branch v3.10.x (Check that stem input is 1D) +* :ghpull:`30233`: Check that stem input is 1D +* :ghpull:`30259`: Backport PR #30256 on branch v3.10.x (Time out in _get_executable_info) +* :ghpull:`30256`: Time out in _get_executable_info +* :ghpull:`30237`: Add explicit ``**options: Any`` for ``add_subplot`` method +* :ghpull:`30253`: Backport PR #30243 on branch v3.10.x (Fix FancyArrow rendering for zero-length arrows) +* :ghpull:`30243`: Fix FancyArrow rendering for zero-length arrows +* :ghpull:`30250`: Backport PR #30244 on branch v3.10.x (DOC: Recommend to use bare Figure instances for saving to file) +* :ghpull:`30247`: Backport PR #30246 on branch v3.10.x (chore: remove redundant words in comment) +* :ghpull:`30246`: chore: remove redundant words in comment +* :ghpull:`30240`: Backport PR #30236 on branch v3.10.x (Copy-edit the docstring of AuxTransformBox.) +* :ghpull:`30236`: Copy-edit the docstring of AuxTransformBox. +* :ghpull:`30234`: Backport PR #30209 on branch v3.10.x (Clean up Qt socket notifier to avoid spurious interrupt handler calls) +* :ghpull:`30209`: Clean up Qt socket notifier to avoid spurious interrupt handler calls +* :ghpull:`30195`: ci: Enable wheel builds on Python 3.14 +* :ghpull:`30229`: Backport PR #30221 on branch v3.10.x (BUG: fix future incompatibility with Pillow 13) +* :ghpull:`30221`: BUG: fix future incompatibility with Pillow 13 +* :ghpull:`30228`: Backport PR #30098 on branch v3.10.x (Fix label_outer in the presence of colorbars.) +* :ghpull:`30227`: Backport PR #30223 on branch v3.10.x (Polar log scale: fix inner patch boundary and spine location) +* :ghpull:`30098`: Fix label_outer in the presence of colorbars. +* :ghpull:`30223`: Polar log scale: fix inner patch boundary and spine location +* :ghpull:`30217`: Backport PR #30198 on branch v3.10.x (Implement Path.__deepcopy__ avoiding infinite recursion) +* :ghpull:`30198`: Implement Path.__deepcopy__ avoiding infinite recursion +* :ghpull:`30213`: Backport PR #30212 on branch v3.10.x ([Doc]: fix bug in release notes for matplotlib v3.5.0 and v3.7.0) +* :ghpull:`30189`: Backport PR #30180 on branch v3.10.x (DOC: expand polar example) +* :ghpull:`30167`: Backport PR #30162 on branch v3.10.x (TST: Fix runtime error checking NaN input to format_cursor_data) +* :ghpull:`30162`: TST: Fix runtime error checking NaN input to format_cursor_data +* :ghpull:`30146`: Backport PR #30144 on branch v3.10.x (js: Fix externally-controlled format strings) +* :ghpull:`30144`: js: Fix externally-controlled format strings +* :ghpull:`30140`: Backport PR #30118 on branch v3.10.x (CI: Skip jobs on forks) +* :ghpull:`30120`: Backport PR #30114 on branch v3.10.x (Fix _is_tensorflow_array.) +* :ghpull:`30122`: Backport PR #30119 on branch v3.10.x (Add some types to _mathtext.py) +* :ghpull:`30119`: Add some types to _mathtext.py +* :ghpull:`30114`: Fix _is_tensorflow_array. +* :ghpull:`30106`: Backport PR #30089 on branch v3.10.x (FIX: fix submerged margins algorithm being applied twice) +* :ghpull:`30089`: FIX: fix submerged margins algorithm being applied twice +* :ghpull:`30101`: Backport PR #30096 on branch v3.10.x (Fix OffsetBox custom picker) +* :ghpull:`30096`: Fix OffsetBox custom picker +* :ghpull:`30081`: Backport PR #30079 on branch v3.10.x (FIX: cast legend handles to list) +* :ghpull:`30079`: FIX: cast legend handles to list +* :ghpull:`30057`: Backport PR #29895 on branch v3.10.x (The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps)" +* :ghpull:`29895`: The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps +* :ghpull:`30033`: Backport PR #30029 on branch v3.10.x (Update diagram in subplots_adjust documentation to clarify parameters) + +Issues (18): + +* :ghissue:`30370`: [Bug]: matplotlib simple example fails in Python 3.14 +* :ghissue:`30218`: [Bug]: Rendering on Wayland with fractional scaling looks bad +* :ghissue:`30318`: [Bug]: type annotation of ``Axes.get_legend()`` misses ``None`` +* :ghissue:`30169`: [Doc]: Incorrect FAQ Link on Tutorials Page +* :ghissue:`30285`: [Bug]: Missing whitespace in _axes.py error message +* :ghissue:`30280`: [Bug]: Pillow 11.3 raises a deprecation warning when using TkAgg +* :ghissue:`30158`: [Bug]: Stackplot in SubFigure raises when drawing Legend +* :ghissue:`30216`: [Bug]: stem complaining about PyTorch's Tensor +* :ghissue:`30242`: [Bug]: Cannot create empty FancyArrow (expired numpy deprecation) +* :ghissue:`30249`: [Bug]: DeprecationWarning from Pillow 11.3.0 about 'mode' parameter of PIL.Image.fromarray() +* :ghissue:`29688`: [Bug]: "Bad file descriptor" raised repeatedly when plt.pause() interrupted in IPython +* :ghissue:`27305`: [Bug]: Axes.label_outer() does not work when there is a colorbar +* :ghissue:`30179`: [Bug]: Inner border is not rendered correctly when using log-scale and polar projection. +* :ghissue:`29157`: FUTURE BUG: reconsider how we deep-copy path objects +* :ghissue:`30152`: [Bug]: Test pipeline failure on windows +* :ghissue:`30076`: [Bug]: Layout Managers are confused by complex arrangement of sub-figures and gridspec's +* :ghissue:`30078`: [Bug]: legend no longer works with itertools.chain +* :ghissue:`29780`: [Bug]: Setting 'lines.markeredgecolor' affects color of errorbar caps. From b2358e7a537205b29ab0810b4bd94f036b3086dc Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:13:43 -0500 Subject: [PATCH 050/228] Release prep v3.10.6 --- doc/_static/switcher.json | 4 ++-- doc/users/release_notes.rst | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json index 7f4913137693..2d02ec01ae56 100644 --- a/doc/_static/switcher.json +++ b/doc/_static/switcher.json @@ -1,7 +1,7 @@ [ { "name": "3.10 (stable)", - "version": "3.10.5", + "version": "3.10.6", "url": "https://matplotlib.org/stable/", "preferred": true }, @@ -12,7 +12,7 @@ }, { "name": "3.9", - "version": "3.9.3", + "version": "3.9.4", "url": "https://matplotlib.org/3.9.4/" }, { diff --git a/doc/users/release_notes.rst b/doc/users/release_notes.rst index 4888e35948c7..1ca483ab15a2 100644 --- a/doc/users/release_notes.rst +++ b/doc/users/release_notes.rst @@ -21,6 +21,7 @@ Version 3.10 ../api/prev_api_changes/api_changes_3.10.1.rst ../api/prev_api_changes/api_changes_3.10.0.rst github_stats.rst + prev_whats_new/github_stats_3.10.5.rst prev_whats_new/github_stats_3.10.3.rst prev_whats_new/github_stats_3.10.1.rst prev_whats_new/github_stats_3.10.0.rst From 5cd38c3edcdf0792d0e6aded280a9b7a7de6146f Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:21:02 -0500 Subject: [PATCH 051/228] REL: v3.10.6 This is a bugfix release in the 3.10.x series. Highlights from this release include: - Fix regression of hi-dpi support for Qt - Fix race condition in TexManager.make_dvi & make_png - Various documentation and other bugfixes From 1d52624ff79f0bd9c57fedd1ed334dad47ee4a03 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:26:35 -0500 Subject: [PATCH 052/228] REL: bump from v3.10.6 From 22c649b6a4362f09a1080e0d46519639c022a590 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:35:39 -0500 Subject: [PATCH 053/228] Zenodo v3.10.6 --- doc/_static/zenodo_cache/16999430.svg | 35 +++++++++++++++++++++++++++ doc/project/citing.rst | 3 +++ tools/cache_zenodo_svg.py | 1 + 3 files changed, 39 insertions(+) create mode 100644 doc/_static/zenodo_cache/16999430.svg diff --git a/doc/_static/zenodo_cache/16999430.svg b/doc/_static/zenodo_cache/16999430.svg new file mode 100644 index 000000000000..44c448643e91 --- /dev/null +++ b/doc/_static/zenodo_cache/16999430.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.16999430 + + + 10.5281/zenodo.16999430 + + + \ No newline at end of file diff --git a/doc/project/citing.rst b/doc/project/citing.rst index 249f568625db..ae2061e7349c 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,9 @@ By version .. START OF AUTOGENERATED +v3.10.6 + .. image:: ../_static/zenodo_cache/16999430.svg + :target: https://doi.org/10.5281/zenodo.16999430 v3.10.5 .. image:: ../_static/zenodo_cache/16644850.svg :target: https://doi.org/10.5281/zenodo.16644850 diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 59d6fce55162..c6783dd9f19d 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,7 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.6": "16999430", "v3.10.5": "16644850", "v3.10.3": "15375714", "v3.10.1": "14940554", From 66bc1ddaf5172f9b782ef4406af3274be82d60cb Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Fri, 29 Aug 2025 20:09:39 -0700 Subject: [PATCH 054/228] DOC: pytz link should be from PyPI --- doc/install/dependencies.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 4b006d9016e2..1fb463ab18e9 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -377,7 +377,7 @@ them will be skipped by pytest. .. _pandas: https://pypi.org/project/pandas/ .. _pikepdf: https://pypi.org/project/pikepdf/ .. _psutil: https://pypi.org/project/psutil/ -.. _pytz: https://fonts.google.com/noto/use#faq +.. _pytz: https://pypi.org/project/pytz/ .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ .. _pytest-timeout: https://pypi.org/project/pytest-timeout/ .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ From 0cfa6bf98d169882175ad356812f30c1f4722fcc Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 30 Aug 2025 00:42:30 -0400 Subject: [PATCH 055/228] Backport PR #30492: DOC: pytz link should be from PyPI --- doc/install/dependencies.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 3034a64a3361..c97dcdd64444 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -370,7 +370,7 @@ them will be skipped by pytest. .. _pandas: https://pypi.org/project/pandas/ .. _pikepdf: https://pypi.org/project/pikepdf/ .. _psutil: https://pypi.org/project/psutil/ -.. _pytz: https://fonts.google.com/noto/use#faq +.. _pytz: https://pypi.org/project/pytz/ .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ .. _pytest-flake8: https://pypi.org/project/pytest-flake8/ .. _pytest-timeout: https://pypi.org/project/pytest-timeout/ From b23f18d572aa68f278292381032e73ea729c551c Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 30 Aug 2025 00:42:30 -0400 Subject: [PATCH 056/228] Backport PR #30492: DOC: pytz link should be from PyPI --- doc/install/dependencies.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 3034a64a3361..c97dcdd64444 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -370,7 +370,7 @@ them will be skipped by pytest. .. _pandas: https://pypi.org/project/pandas/ .. _pikepdf: https://pypi.org/project/pikepdf/ .. _psutil: https://pypi.org/project/psutil/ -.. _pytz: https://fonts.google.com/noto/use#faq +.. _pytz: https://pypi.org/project/pytz/ .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ .. _pytest-flake8: https://pypi.org/project/pytest-flake8/ .. _pytest-timeout: https://pypi.org/project/pytest-timeout/ From 5906a4f0fb92144538f07c54ad4be8a9091cc8ed Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:40:42 +0200 Subject: [PATCH 057/228] Update what's new according to suggestions --- doc/release/next_whats_new/pyplot-register-figure.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/release/next_whats_new/pyplot-register-figure.rst b/doc/release/next_whats_new/pyplot-register-figure.rst index 0c186fe0ecb9..6df2676e0ffa 100644 --- a/doc/release/next_whats_new/pyplot-register-figure.rst +++ b/doc/release/next_whats_new/pyplot-register-figure.rst @@ -48,8 +48,9 @@ Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is replaced by a backend-dependent subclass when registering with pyplot, and is reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses the current canvas to save the figure (if possible). Since `.FigureCanvasBase` -can not render the figure, when using savefig it will fallback to`.FigureCanvasAgg` -which is Agg-based. Any Agg-based backend will create the same file output, however +can not render the figure, when saving the figure, it will fallback to a suitable +canvas subclass, e.g. `.FigureCanvasAgg` for raster outputs such as png. +Any Agg-based backend will create the same file output, however There may be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as interactive backend, ``fig.savefig("file.png")`` may create a slightly different image depending on whether the figure is registered with pyplot or not. In From 545892a5c1c6b873a65d9fb87c6cf721382b083f Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:45:02 +0200 Subject: [PATCH 058/228] Move all super().destroy() calls to the end --- lib/matplotlib/backends/backend_nbagg.py | 2 +- lib/matplotlib/backends/backend_qt.py | 2 +- lib/matplotlib/backends/backend_wx.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 543454ab25fd..3ffec0910d79 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -137,12 +137,12 @@ def _create_comm(self): return comm def destroy(self): - super().destroy() self._send_event('close') # need to copy comms as callbacks will modify this list for comm in list(self.web_sockets): comm.on_close() self.clearup_closed() + super().destroy() def clearup_closed(self): """Clear up any closed Comms.""" diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 68d89e1990bb..77ecf4b861c0 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -664,7 +664,6 @@ def show(self): self.window.raise_() def destroy(self, *args): - super().destroy() # check for qApp first, as PySide deletes it in its atexit handler if QtWidgets.QApplication.instance() is None: return @@ -674,6 +673,7 @@ def destroy(self, *args): if self.toolbar: self.toolbar.destroy() self.window.close() + super().destroy() def get_window_title(self): return self.window.windowTitle() diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 5219042e7971..6645077defc9 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -1007,12 +1007,12 @@ def show(self): def destroy(self, *args): # docstring inherited _log.debug("%s - destroy()", type(self)) - super().destroy() frame = self.frame if frame: # Else, may have been already deleted, e.g. when closing. # As this can be called from non-GUI thread from plt.close use # wx.CallAfter to ensure thread safety. wx.CallAfter(frame.Close) + super().destroy() def full_screen_toggle(self): # docstring inherited From 63e03292f990b55973bb07722809cc6fb80084f9 Mon Sep 17 00:00:00 2001 From: MQY <3463526515@qq.com> Date: Sun, 31 Aug 2025 16:01:08 +0800 Subject: [PATCH 059/228] Fix Line3DCollection with autolim=True for lines of different lengths (#30423) * Fix Line3DCollection with autolim=True for lines of different lengths When using Line3DCollection with autolim=True, the code was trying to convert ragged arrays (lines with different numbers of points) directly to a numpy array, which would fail. This fix extracts coordinates separately to avoid the issue. Fixes #30418 * fix lint * fix lint * Add test for Line3DCollection with autolim=True and ragged arrays * move test * Update lib/mpl_toolkits/mplot3d/tests/test_axes3d.py Co-authored-by: Elliott Sales de Andrade * Update lib/mpl_toolkits/mplot3d/tests/test_axes3d.py Co-authored-by: Elliott Sales de Andrade --------- Co-authored-by: Elliott Sales de Andrade --- lib/mpl_toolkits/mplot3d/axes3d.py | 6 +++-- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index c56e4c6b7039..3122b64bde73 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2890,8 +2890,10 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, if autolim: if isinstance(col, art3d.Line3DCollection): - self.auto_scale_xyz(*np.array(col._segments3d).transpose(), - had_data=had_data) + # Handle ragged arrays by extracting coordinates separately + all_points = np.concatenate(col._segments3d) + self.auto_scale_xyz(all_points[:, 0], all_points[:, 1], + all_points[:, 2], had_data=had_data) elif isinstance(col, art3d.Poly3DCollection): self.auto_scale_xyz(col._faces[..., 0], col._faces[..., 1], diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index e6d11f793b46..370fd506530e 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2689,3 +2689,25 @@ def test_ndarray_color_kwargs_value_error(): ax = fig.add_subplot(111, projection='3d') ax.scatter(1, 0, 0, color=np.array([0, 0, 0, 1])) fig.canvas.draw() + + +def test_line3dcollection_autolim_ragged(): + """Test Line3DCollection with autolim=True and lines of different lengths.""" + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Create lines with different numbers of points (ragged arrays) + edges = [ + [(0, 0, 0), (1, 1, 1), (2, 2, 2)], # 3 points + [(0, 1, 0), (1, 2, 1)], # 2 points + [(1, 0, 1), (2, 1, 2), (3, 2, 3), (4, 3, 4)] # 4 points + ] + + # This should not raise an exception. + collections = ax.add_collection3d(art3d.Line3DCollection(edges), autolim=True) + + # Check that limits were computed correctly with margins + # The limits should include all points with default margins + assert np.allclose(ax.get_xlim3d(), (-0.08333333333333333, 4.083333333333333)) + assert np.allclose(ax.get_ylim3d(), (-0.0625, 3.0625)) + assert np.allclose(ax.get_zlim3d(), (-0.08333333333333333, 4.083333333333333)) From 87079119e89a7134f2beaf2246f7d4dab46a7440 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sun, 31 Aug 2025 09:12:27 +0100 Subject: [PATCH 060/228] Improve cursor icons with RectangleSelector --- lib/matplotlib/widgets.py | 56 +++++++++++++++++++++++++++++--------- lib/matplotlib/widgets.pyi | 3 ++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 034a9b4db7a0..53c1c9b117b8 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -150,6 +150,10 @@ def ignore(self, event): # docstring inherited return super().ignore(event) or self.canvas is None + def _set_cursor(self, cursor): + """Update the canvas cursor.""" + self.ax.get_figure(root=True).canvas.set_cursor(cursor) + class Button(AxesWidget): """ @@ -2643,7 +2647,7 @@ def _handles_artists(self): else: return () - def _set_cursor(self, enabled): + def _set_span_cursor(self, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2652,7 +2656,7 @@ def _set_cursor(self, enabled): else: cursor = backend_tools.Cursors.POINTER - self.ax.get_figure(root=True).canvas.set_cursor(cursor) + self._set_cursor(cursor) def connect_default_events(self): # docstring inherited @@ -2662,7 +2666,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_cursor(True) + self._set_span_cursor(True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2712,7 +2716,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_cursor(False) + self._set_span_cursor(False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2754,7 +2758,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_cursor(e_dist <= self.grab_range) + self._set_span_cursor(e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" @@ -3278,10 +3282,24 @@ def _press(self, event): self._rotation_on_press = self._rotation self._set_aspect_ratio_correction() + match self._get_action(): + case "rotate": + # TODO: set to a rotate cursor if possible? + pass + case "move": + self._set_cursor(backend_tools.cursors.MOVE) + case "resize": + # TODO: set to a resize cursor if possible? + pass + case "create": + self._set_cursor(backend_tools.cursors.SELECT_REGION) + + return False def _release(self, event): """Button release event handler.""" + self._set_cursor(backend_tools.Cursors.POINTER) if not self._interactive: self._selection_artist.set_visible(False) @@ -3325,9 +3343,23 @@ def _release(self, event): self.update() self._active_handle = None self._extents_on_press = None - return False + def _get_action(self): + """ + Return one of "rotate", "move", "resize", "create" + """ + state = self._state + if 'rotate' in state and self._active_handle in self._corner_order: + return 'rotate' + elif self._active_handle == 'C': + return 'move' + elif self._active_handle: + return 'resize' + + return 'create' + + def _onmove(self, event): """ Motion notify event handler. @@ -3342,12 +3374,10 @@ def _onmove(self, event): # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move state = self._state - rotate = 'rotate' in state and self._active_handle in self._corner_order - move = self._active_handle == 'C' - resize = self._active_handle and not move + action = self._get_action() xdata, ydata = self._get_data_coords(event) - if resize: + if action == "resize": inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3367,7 +3397,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if rotate: + if action == "rotate": # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3376,7 +3406,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif resize: + elif action == "resize": size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3427,7 +3457,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif move: + elif action == "move": x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index e143d0b2c96e..cd26ab84c49c 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -6,6 +6,7 @@ from .figure import Figure from .lines import Line2D from .patches import Polygon, Rectangle from .text import Text +from .backend_tools import Cursors import PIL.Image @@ -38,6 +39,7 @@ class AxesWidget(Widget): def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... def disconnect_events(self) -> None: ... + def _set_cursor(self, cursor: Cursors) -> None: ... class Button(AxesWidget): label: Text @@ -398,6 +400,7 @@ class RectangleSelector(_SelectorWidget): minspany: float spancoords: Literal["data", "pixels"] grab_range: float + _active_handle: None | Literal["C", "N", "NE", "E", "SE", "S", "SW", "W", "NW"] def __init__( self, ax: Axes, From ca02c4654532b4ff56eb82c528d24a5092e387d9 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:02:55 +0100 Subject: [PATCH 061/228] Use an enum for RectangleSelector state --- lib/matplotlib/widgets.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 53c1c9b117b8..bd9a62508616 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,6 +11,7 @@ from contextlib import ExitStack import copy +import enum import itertools from numbers import Integral, Number @@ -3149,6 +3150,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ +class _RectangleSelectorState(enum.Enum): + ROTATE = enum.auto() + MOVE = enum.auto() + RESIZE = enum.auto() + CREATE = enum.auto() + + @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): @@ -3283,18 +3291,17 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case "rotate": + case _RectangleSelectorState.ROTATE: # TODO: set to a rotate cursor if possible? pass - case "move": + case _RectangleSelectorState.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case "resize": + case _RectangleSelectorState.RESIZE: # TODO: set to a resize cursor if possible? pass - case "create": + case _RectangleSelectorState.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) - return False def _release(self, event): @@ -3346,18 +3353,15 @@ def _release(self, event): return False def _get_action(self): - """ - Return one of "rotate", "move", "resize", "create" - """ state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return 'rotate' + return _RectangleSelectorState.ROTATE elif self._active_handle == 'C': - return 'move' + return _RectangleSelectorState.MOVE elif self._active_handle: - return 'resize' + return _RectangleSelectorState.RESIZE - return 'create' + return _RectangleSelectorState.CREATE def _onmove(self, event): @@ -3377,7 +3381,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == "resize": + if action == _RectangleSelectorState.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3397,7 +3401,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == "rotate": + if action == _RectangleSelectorState.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3406,7 +3410,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == "resize": + elif action == _RectangleSelectorState.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3457,7 +3461,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == "move": + elif action == _RectangleSelectorState.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata From 4d2e657f7b585ba43a8e908937b16346b8d3c49d Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:05:46 +0100 Subject: [PATCH 062/228] Improve signature of set_span_cursor --- lib/matplotlib/widgets.py | 8 ++++---- lib/matplotlib/widgets.pyi | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index bd9a62508616..f03e1c1c030d 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2648,7 +2648,7 @@ def _handles_artists(self): else: return () - def _set_span_cursor(self, enabled): + def _set_span_cursor(self, *, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2667,7 +2667,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_span_cursor(True) + self._set_span_cursor(enabled=True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2717,7 +2717,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_span_cursor(False) + self._set_span_cursor(enabled=False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2759,7 +2759,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_span_cursor(e_dist <= self.grab_range) + self._set_span_cursor(enabled=e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index cd26ab84c49c..f74b9c7f32bf 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -337,6 +337,7 @@ class SpanSelector(_SelectorWidget): _props: dict[str, Any] | None = ..., _init: bool = ..., ) -> None: ... + def _set_span_cursor(self, *, enabled: bool) -> None: ... def connect_default_events(self) -> None: ... @property def direction(self) -> Literal["horizontal", "vertical"]: ... From a3fafa63f9d01e9f9ed7e3bdca3032cf52b22a43 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:07:58 +0100 Subject: [PATCH 063/228] Improve variable naming --- lib/matplotlib/widgets.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index f03e1c1c030d..d10de28c106a 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3150,7 +3150,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ -class _RectangleSelectorState(enum.Enum): +class _RectangleSelectorAction(enum.Enum): ROTATE = enum.auto() MOVE = enum.auto() RESIZE = enum.auto() @@ -3291,15 +3291,15 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case _RectangleSelectorState.ROTATE: + case _RectangleSelectorAction.ROTATE: # TODO: set to a rotate cursor if possible? pass - case _RectangleSelectorState.MOVE: + case _RectangleSelectorAction.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case _RectangleSelectorState.RESIZE: + case _RectangleSelectorAction.RESIZE: # TODO: set to a resize cursor if possible? pass - case _RectangleSelectorState.CREATE: + case _RectangleSelectorAction.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) return False @@ -3355,13 +3355,13 @@ def _release(self, event): def _get_action(self): state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return _RectangleSelectorState.ROTATE + return _RectangleSelectorAction.ROTATE elif self._active_handle == 'C': - return _RectangleSelectorState.MOVE + return _RectangleSelectorAction.MOVE elif self._active_handle: - return _RectangleSelectorState.RESIZE + return _RectangleSelectorAction.RESIZE - return _RectangleSelectorState.CREATE + return _RectangleSelectorAction.CREATE def _onmove(self, event): @@ -3381,7 +3381,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == _RectangleSelectorState.RESIZE: + if action == _RectangleSelectorAction.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3401,7 +3401,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == _RectangleSelectorState.ROTATE: + if action == _RectangleSelectorAction.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3410,7 +3410,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == _RectangleSelectorState.RESIZE: + elif action == _RectangleSelectorAction.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3461,7 +3461,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == _RectangleSelectorState.MOVE: + elif action == _RectangleSelectorAction.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata From d65c5621757833a62574cc65e9acbb975ccf1909 Mon Sep 17 00:00:00 2001 From: Jonathan Reimer <41432658+jonathimer@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:58:05 +0200 Subject: [PATCH 064/228] Add Linux Foundation Health Score badge to README Congrats! We have onboarded matplotlib to LFX Insights, the Linux Foundation's platform for monitoring the world's most critical open-source projects. https://insights.linuxfoundation.org/project/matplotlib --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7b9c99597c0d..e7dce2a5f472 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Conda](https://img.shields.io/conda/vn/conda-forge/matplotlib)](https://anaconda.org/conda-forge/matplotlib) [![Downloads](https://img.shields.io/pypi/dm/matplotlib)](https://pypi.org/project/matplotlib) [![NUMFocus](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](https://numfocus.org) +[![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=matplotlib)](https://insights.linuxfoundation.org/project/matplotlib) [![Discourse help forum](https://img.shields.io/badge/help_forum-discourse-blue.svg)](https://discourse.matplotlib.org) [![Gitter](https://badges.gitter.im/matplotlib/matplotlib.svg)](https://gitter.im/matplotlib/matplotlib) From 0056a47621dd17870bfd84e52a475899c50df63b Mon Sep 17 00:00:00 2001 From: Aasma Gupta Date: Tue, 2 Sep 2025 22:13:29 +0530 Subject: [PATCH 065/228] Fix SVG rendering error in def update_background (#30490) Check if saving before updating blitting background --------- Co-authored-by: AASMA GUPTA Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 034a9b4db7a0..f4adcd60f6cb 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2172,6 +2172,8 @@ def update_background(self, event): # `release` can call a draw event even when `ignore` is True. if not self.useblit: return + if self.canvas.is_saving(): + return # saving does not use blitting # Make sure that widget artists don't get accidentally included in the # background, by re-rendering the background if needed (and then # re-re-rendering the canvas with the visible widget artists). From e8eac49c6c2bd8dcc4dd8aec9cd88130b7492236 Mon Sep 17 00:00:00 2001 From: Aasma Gupta Date: Tue, 2 Sep 2025 22:13:29 +0530 Subject: [PATCH 066/228] Backport PR #30490: Fix SVG rendering error in def update_background --- lib/matplotlib/widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 631ada38027a..6a3c0d684380 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2157,6 +2157,8 @@ def update_background(self, event): # `release` can call a draw event even when `ignore` is True. if not self.useblit: return + if self.canvas.is_saving(): + return # saving does not use blitting # Make sure that widget artists don't get accidentally included in the # background, by re-rendering the background if needed (and then # re-re-rendering the canvas with the visible widget artists). From 83b8e42bf848d9e532dc8173d52bf96514724cec Mon Sep 17 00:00:00 2001 From: Aaratrika-Shelly Date: Fri, 8 Aug 2025 19:18:31 +0000 Subject: [PATCH 067/228] MNT/DOC: Deprecate anchor in Axes3D.set_aspect --- .../deprecations/30364-AS.rst | 4 ++ lib/mpl_toolkits/mplot3d/axes3d.py | 38 ++++++++----------- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 29 ++++++++++++++ 3 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30364-AS.rst diff --git a/doc/api/next_api_changes/deprecations/30364-AS.rst b/doc/api/next_api_changes/deprecations/30364-AS.rst new file mode 100644 index 000000000000..4f5493b8b706 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30364-AS.rst @@ -0,0 +1,4 @@ +Parameters ``Axes3D.set_aspect(..., anchor=..., share=...)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The parameters *anchor* and *share* of `.Axes3D.set_aspect` are deprecated. +They had no effect on 3D axes and will be removed in a future version. diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 3122b64bde73..32da8dfde7aa 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -244,6 +244,8 @@ def _transformed_cube(self, vals): (minx, maxy, maxz)] return proj3d._proj_points(xyzs, self.M) + @_api.delete_parameter("3.11", "share") + @_api.delete_parameter("3.11", "anchor") def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): """ Set the aspect ratios. @@ -263,39 +265,31 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): 'equalyz' adapt the y and z axes to have equal aspect ratios. ========= ================================================== - adjustable : None or {'box', 'datalim'}, optional - If not *None*, this defines which parameter will be adjusted to - meet the required aspect. See `.set_adjustable` for further - details. + adjustable : {'box', 'datalim'}, default: 'box' + Defines which parameter to adjust to meet the aspect ratio. + + - 'box': Change the physical dimensions of the axes bounding box. + - 'datalim': Change the x, y, or z data limits. anchor : None or str or 2-tuple of float, optional - If not *None*, this defines where the Axes will be drawn if there - is extra space due to aspect constraints. The most common way to - specify the anchor are abbreviations of cardinal directions: - - ===== ===================== - value description - ===== ===================== - 'C' centered - 'SW' lower left corner - 'S' middle of bottom edge - 'SE' lower right corner - etc. - ===== ===================== - - See `~.Axes.set_anchor` for further details. + .. deprecated:: 3.11 + This parameter has no effect. share : bool, default: False - If ``True``, apply the settings to all shared Axes. + .. deprecated:: 3.11 + This parameter has no effect. See Also -------- mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect """ + if adjustable is None: + adjustable = 'box' + _api.check_in_list(['box', 'datalim'], adjustable=adjustable) _api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'), aspect=aspect) - super().set_aspect( - aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + + self.set_adjustable(adjustable) self._aspect = aspect if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 370fd506530e..e38df4f80ba4 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -17,6 +17,7 @@ from matplotlib.patches import Circle, PathPatch from matplotlib.path import Path from matplotlib.text import Text +from matplotlib import _api import matplotlib.pyplot as plt import numpy as np @@ -2711,3 +2712,31 @@ def test_line3dcollection_autolim_ragged(): assert np.allclose(ax.get_xlim3d(), (-0.08333333333333333, 4.083333333333333)) assert np.allclose(ax.get_ylim3d(), (-0.0625, 3.0625)) assert np.allclose(ax.get_zlim3d(), (-0.08333333333333333, 4.083333333333333)) + + +def test_axes3d_set_aspect_deperecated_params(): + """ + Test that using the deprecated 'anchor' and 'share' kwargs in + set_aspect raises the correct warning. + """ + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Test that providing the `anchor` parameter raises a deprecation warning. + with pytest.warns(_api.MatplotlibDeprecationWarning, match="'anchor' parameter"): + ax.set_aspect('equal', anchor='C') + + # Test that using the 'share' parameter is now deprecated. + with pytest.warns(_api.MatplotlibDeprecationWarning, match="'share' parameter"): + ax.set_aspect('equal', share=True) + + # Test that the `adjustable` parameter is correctly processed to satisfy + # code coverage. + ax.set_aspect('equal', adjustable='box') + assert ax.get_adjustable() == 'box' + + ax.set_aspect('equal', adjustable='datalim') + assert ax.get_adjustable() == 'datalim' + + with pytest.raises(ValueError, match="adjustable"): + ax.set_aspect('equal', adjustable='invalid_value') From 4383c87ac5006d3d5ab6f6b99e30334cb0ba93e5 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 2 Sep 2025 16:00:33 -0500 Subject: [PATCH 068/228] Revert 3.9 link --- doc/_static/switcher.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json index 2d02ec01ae56..c71f2c8c4859 100644 --- a/doc/_static/switcher.json +++ b/doc/_static/switcher.json @@ -12,8 +12,8 @@ }, { "name": "3.9", - "version": "3.9.4", - "url": "https://matplotlib.org/3.9.4/" + "version": "3.9.3", + "url": "https://matplotlib.org/3.9.3/" }, { "name": "3.8", From a6356715f9952078f73e490bd2987417c3c2b970 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 17 Apr 2025 13:09:43 +0200 Subject: [PATCH 069/228] Parse {lua,xe}tex-generated dvi in dviread. --- lib/matplotlib/backends/backend_pdf.py | 7 +- lib/matplotlib/cbook.py | 10 +- lib/matplotlib/dviread.py | 308 +++++++++++++----- lib/matplotlib/dviread.pyi | 20 +- lib/matplotlib/testing/__init__.py | 4 +- lib/matplotlib/testing/__init__.pyi | 3 + .../baseline_images/dviread/lualatex.json | 1 + .../baseline_images/dviread/pdflatex.json | 1 + .../tests/baseline_images/dviread/test.dvi | Bin 856 -> 0 bytes .../tests/baseline_images/dviread/test.json | 94 ------ .../tests/baseline_images/dviread/test.tex | 16 +- .../baseline_images/dviread/xelatex.json | 1 + lib/matplotlib/tests/test_dviread.py | 56 +++- lib/matplotlib/textpath.py | 4 +- 14 files changed, 313 insertions(+), 212 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/dviread/lualatex.json create mode 100644 lib/matplotlib/tests/baseline_images/dviread/pdflatex.json delete mode 100644 lib/matplotlib/tests/baseline_images/dviread/test.dvi delete mode 100644 lib/matplotlib/tests/baseline_images/dviread/test.json create mode 100644 lib/matplotlib/tests/baseline_images/dviread/xelatex.json diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index a75a8a86eb92..d63808eb3925 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1035,11 +1035,10 @@ def _embedTeXFont(self, dvifont): fontdict['Encoding'] = self._generate_encoding(encoding) fc = fontdict['FirstChar'] = min(encoding.keys(), default=0) lc = fontdict['LastChar'] = max(encoding.keys(), default=255) - # Convert glyph widths from TeX 12.20 fixed point to 1/1000 text space units - tfm = dvifont._tfm - widths = [(1000 * metrics.tex_width) >> 20 - if (metrics := tfm.get_metrics(char)) else 0 + font_metrics = dvifont._metrics + widths = [(1000 * glyph_metrics.tex_width) >> 20 + if (glyph_metrics := font_metrics.get_metrics(char)) else 0 for char in range(fc, lc + 1)] fontdict['Widths'] = widthsObject = self.reserveObject('glyph widths') self.writeObject(widthsObject, widths) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index a09780965b0c..8b438e7d79b5 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -43,16 +43,20 @@ class _ExceptionInfo: users and result in incorrect tracebacks. """ - def __init__(self, cls, *args): + def __init__(self, cls, *args, notes=None): self._cls = cls self._args = args + self._notes = notes if notes is not None else [] @classmethod def from_exception(cls, exc): - return cls(type(exc), *exc.args) + return cls(type(exc), *exc.args, notes=getattr(exc, "__notes__", [])) def to_exception(self): - return self._cls(*self._args) + exc = self._cls(*self._args) + for note in self._notes: + exc.add_note(note) + return exc def _get_running_interactive_framework(): diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 9e8b6a5facf5..1a0f9219a498 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -26,12 +26,14 @@ import subprocess import sys from collections import namedtuple -from functools import cache, lru_cache, partial, wraps +from functools import cache, cached_property, lru_cache, partial, wraps from pathlib import Path +import fontTools.agl import numpy as np from matplotlib import _api, cbook, font_manager +from matplotlib.ft2font import LoadFlags _log = logging.getLogger(__name__) @@ -67,42 +69,15 @@ class Text(namedtuple('Text', 'x y font glyph width')): """ A glyph in the dvi file. - The *x* and *y* attributes directly position the glyph. The *font*, - *glyph*, and *width* attributes are kept public for back-compatibility, - but users wanting to draw the glyph themselves are encouraged to instead - load the font specified by `font_path` at `font_size`, warp it with the - effects specified by `font_effects`, and load the glyph at the FreeType - glyph `index`. - """ - - def _get_pdftexmap_entry(self): - return PsfontsMap(find_tex_file("pdftex.map"))[self.font.texname] - - @property - def font_path(self): - """The `~pathlib.Path` to the font for this glyph.""" - psfont = self._get_pdftexmap_entry() - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - return Path(psfont.filename) + In order to render the glyph, load the glyph at index ``text.index`` + from the font at ``text.font.resolve_path()`` with size ``text.font.size``, + warped with ``text.font.effects``, then draw it at position + ``(text.x, text.y)``. - @property - def font_size(self): - """The font size.""" - return self.font.size - - @property - def font_effects(self): - """ - The "font effects" dict for this glyph. - - This dict contains the values for this glyph of SlantFont and - ExtendFont (if any), read off :file:`pdftex.map`. - """ - return self._get_pdftexmap_entry().effects + ``text.glyph`` is the glyph number actually stored in the dvi file (whose + interpretation depends on the font). ``text.width`` is the glyph width in + dvi units. + """ @property def index(self): @@ -112,25 +87,51 @@ def index(self): # See DviFont._index_dvi_to_freetype for details on the index mapping. return self.font._index_dvi_to_freetype(self.glyph) - @property # To be deprecated together with font_size, font_effects. + font_path = property(lambda self: self.font.resolve_path()) + font_size = property(lambda self: self.font.size) + font_effects = property(lambda self: self.font.effects) + + @property # To be deprecated together with font_path, font_size, font_effects. def glyph_name_or_index(self): """ - Either the glyph name or the native charmap glyph index. - - If :file:`pdftex.map` specifies an encoding for this glyph's font, that - is a mapping of glyph indices to Adobe glyph names; use it to convert - dvi indices to glyph names. Callers can then convert glyph names to - glyph indices (with FT_Get_Name_Index/get_name_index), and load the - glyph using FT_Load_Glyph/load_glyph. - - If :file:`pdftex.map` specifies no encoding, the indices directly map - to the font's "native" charmap; glyphs should directly load using - FT_Load_Char/load_char after selecting the native charmap. + The glyph name, the native charmap glyph index, or the raw glyph index. + + If the font is a TrueType file (which can currently only happen for + DVI files generated by xetex or luatex), then this number is the raw + index of the glyph, which can be passed to FT_Load_Glyph/load_glyph. + + Otherwise, the font is a PostScript font. For such fonts, if + :file:`pdftex.map` specifies an encoding for this glyph's font, + that is a mapping of glyph indices to Adobe glyph names; which + is used by this property to convert dvi numbers to glyph names. + Callers can then convert glyph names to glyph indices (with + FT_Get_Name_Index/get_name_index), and load the glyph using + FT_Load_Glyph/load_glyph. + + If :file:`pdftex.map` specifies no encoding for a PostScript font, + this number is an index to the font's "native" charmap; glyphs should + directly load using FT_Load_Char/load_char after selecting the native + charmap. """ + # The last section is only true on luatex since luaotfload 3.23; this + # must be checked by the code generated by texmanager. (luaotfload's + # docs states "No one should rely on the mapping between DVI character + # codes and font glyphs [prior to v3.15] unless they tightly + # control all involved versions and are deeply familiar with the + # implementation", but a further mapping bug was fixed in luaotfload + # commit 8f2dca4, first included in v3.23). entry = self._get_pdftexmap_entry() return (_parse_enc(entry.encoding)[self.glyph] if entry.encoding is not None else self.glyph) + def _as_unicode_or_name(self): + if self.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") + face = font_manager.get_font(self.font.resolve_path()) + glyph_name = face.get_glyph_name(self.index) + glyph_str = fontTools.agl.toUnicode(glyph_name) + return glyph_str or glyph_name + # Opcode argument parsing # @@ -408,7 +409,7 @@ def _put_char_real(self, char): scale = font._scale for x, y, f, g, w in font._vf[char].text: newf = DviFont(scale=_mul1220(scale, f._scale), - tfm=f._tfm, texname=f.texname, vf=f._vf) + metrics=f._metrics, texname=f.texname, vf=f._vf) self.text.append(Text(self.h + _mul1220(x, scale), self.v + _mul1220(y, scale), newf, g, newf._width_of(g))) @@ -504,10 +505,21 @@ def _fnt_def(self, k, c, s, d, a, l): def _fnt_def_real(self, k, c, s, d, a, l): n = self.file.read(a + l) - fontname = n[-l:].decode('ascii') + fontname = n[-l:] + if fontname.startswith(b"[") and c == 0x4c756146: # c == "LuaF" + # See https://chat.stackexchange.com/rooms/106428 (and also + # https://tug.org/pipermail/dvipdfmx/2021-January/000168.html). + # AFAICT luatex's dvi drops info re: OpenType variation-axis values. + self.fonts[k] = DviFont.from_luatex(s, n) + return + fontname = fontname.decode("ascii") try: tfm = _tfmfile(fontname) except FileNotFoundError as exc: + if fontname.startswith("[") and fontname.endswith(";") and c == 0: + exc.add_note( + "This dvi file was likely generated with a too-old " + "version of luaotfload; luaotfload 3.23 is required.") # Explicitly allow defining missing fonts for Vf support; we only # register an error when trying to load a glyph from a missing font # and throw that error in Dvi._read. For Vf, _finalize_packet @@ -521,12 +533,12 @@ def _fnt_def_real(self, k, c, s, d, a, l): vf = _vffile(fontname) except FileNotFoundError: vf = None - self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf) + self.fonts[k] = DviFont(scale=s, metrics=tfm, texname=n, vf=vf) @_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1')) def _pre(self, i, num, den, mag, k): self.file.read(k) # comment in the dvi file - if i != 2: + if i not in [2, 7]: # 2: pdftex, luatex; 7: xetex raise ValueError(f"Unknown dvi format {i}") if num != 25400000 or den != 7227 * 2**16: raise ValueError("Nonstandard units in dvi file") @@ -547,13 +559,66 @@ def _post(self, _): # TODO: actually read the postamble and finale? # currently post_post just triggers closing the file - @_dispatch(249) - def _post_post(self, _): + @_dispatch(249, args=()) + def _post_post(self): + raise NotImplementedError + + @_dispatch(250, args=()) + def _begin_reflect(self): + raise NotImplementedError + + @_dispatch(251, args=()) + def _end_reflect(self): raise NotImplementedError - @_dispatch(min=250, max=255) - def _malformed(self, offset): - raise ValueError(f"unknown command: byte {250 + offset}") + @_dispatch(252, args=()) + def _define_native_font(self): + k = self._read_arg(4, signed=False) + s = self._read_arg(4, signed=False) + flags = self._read_arg(2, signed=False) + l = self._read_arg(1, signed=False) + n = self.file.read(l) + i = self._read_arg(4, signed=False) + effects = {} + if flags & 0x0200: + effects["rgba"] = [self._read_arg(1, signed=False) for _ in range(4)] + if flags & 0x1000: + effects["extend"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x2000: + effects["slant"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x4000: + effects["embolden"] = self._read_arg(4, signed=True) / 65536 + self.fonts[k] = DviFont.from_xetex(s, n, i, effects) + + @_dispatch(253, args=()) + def _set_glyphs(self): + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(254, args=()) + def _set_text_and_glyphs(self): + l = self._read_arg(2, signed=False) + t = self.file.read(2 * l) # utf16 + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(255) + def _malformed(self, raw): + raise ValueError("unknown command: byte 255") class DviFont: @@ -571,10 +636,10 @@ class DviFont: ---------- scale : float Factor by which the font is scaled from its natural size. - tfm : Tfm + metrics : Tfm | TtfMetrics TeX font metrics for this font texname : bytes - Name of the font as used internally by TeX and friends, as an ASCII + Name of the font as used internally in the DVI file, as an ASCII bytestring. This is usually very different from any external font names; `PsfontsMap` can be used to find the external name of the font. vf : Vf @@ -590,17 +655,54 @@ class DviFont: Size of the font in Adobe points, converted from the slightly smaller TeX points. """ - __slots__ = ('texname', 'size', '_scale', '_vf', '_tfm', '_encoding') - def __init__(self, scale, tfm, texname, vf): + def __init__(self, scale, metrics, texname, vf): _api.check_isinstance(bytes, texname=texname) self._scale = scale - self._tfm = tfm + self._metrics = metrics self.texname = texname self._vf = vf - self.size = scale * (72.0 / (72.27 * 2**16)) + self._path = None self._encoding = None + @classmethod + def from_luatex(cls, scale, texname): + path_b, sep, rest = texname[1:].rpartition(b"]") + if not (texname.startswith(b"[") and sep and rest[:1] in [b"", b":"]): + raise ValueError(f"Invalid modern font name: {texname}") + # utf8 on Windows, not utf16! + path = path_b.decode("utf8") if os.name == "nt" else os.fsdecode(path_b) + subfont = 0 + effects = {} + if rest[1:]: + for kv in rest[1:].decode("ascii").split(";"): + key, val = kv.split("=", 1) + if key == "index": + subfont = val + elif key in ["embolden", "slant", "extend"]: + effects[key] = int(val) / 65536 + else: + _log.warning("Ignoring invalid key-value pair: %r", kv) + metrics = TtfMetrics(path) + font = cls(scale, metrics, texname, vf=None) + font._path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + @classmethod + def from_xetex(cls, scale, texname, subfont, effects): + # utf8 on Windows, not utf16! + path = texname.decode("utf8") if os.name == "nt" else os.fsdecode(texname) + metrics = TtfMetrics(path) + font = cls(scale, metrics, b"[" + texname + b"]", vf=None) + font._path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + size = property(lambda self: self._scale * (72.0 / (72.27 * 2**16))) + widths = _api.deprecated("3.11")(property(lambda self: [ (1000 * self._tfm.width.get(char, 0)) >> 20 for char in range(max(self._tfm.width, default=-1) + 1)])) @@ -629,7 +731,7 @@ def __repr__(self): def _width_of(self, char): """Width of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No width for char %d in font %s.', char, self.texname) return 0 @@ -637,7 +739,7 @@ def _width_of(self, char): def _height_depth_of(self, char): """Height and depth of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No metrics for char %d in font %s', char, self.texname) return [0, 0] @@ -654,26 +756,42 @@ def _height_depth_of(self, char): hd[-1] = 0 return hd + def resolve_path(self): + if self._path is None: + psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] + if psfont.filename is None: + raise ValueError("No usable font file found for {} ({}); " + "the font may lack a Type-1 version" + .format(psfont.psname.decode("ascii"), + psfont.texname.decode("ascii"))) + self._path = Path(psfont.filename) + return self._path + + @cached_property + def subfont(self): + return 0 + + @cached_property + def effects(self): + return PsfontsMap(find_tex_file("pdftex.map"))[self.texname].effects + def _index_dvi_to_freetype(self, idx): """Convert dvi glyph indices to FreeType ones.""" # Glyphs indices stored in the dvi file map to FreeType glyph indices # (i.e., which can be passed to FT_Load_Glyph) in various ways: + # - for xetex & luatex "native fonts", dvi indices are directly equal + # to FreeType indices. # - if pdftex.map specifies an ".enc" file for the font, that file maps # dvi indices to Adobe glyph names, which can then be converted to # FreeType glyph indices with FT_Get_Name_Index. # - if no ".enc" file is specified, then the font must be a Type 1 # font, and dvi indices directly index into the font's CharStrings # vector. - # - (xetex & luatex, currently unsupported, can also declare "native - # fonts", for which dvi indices are equal to FreeType indices.) + if self.texname.startswith(b"["): + return idx if self._encoding is None: + face = font_manager.get_font(self.resolve_path()) psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - face = font_manager.get_font(psfont.filename) if psfont.encoding: self._encoding = [face.get_name_index(name) for name in _parse_enc(psfont.encoding)] @@ -882,6 +1000,27 @@ def get_metrics(self, idx): property(lambda self: {c: m.tex_depth for c, m in self._glyph_metrics})) +class TtfMetrics: + def __init__(self, filename): + self._face = font_manager.get_font(filename, hinting_factor=1) + + def get_metrics(self, idx): + # _mul1220 uses a truncating bitshift for compatibility with dvitype. + # When upem is 2048 the conversion to 12.20 is exact, but when + # upem is 1000 (e.g. lmroman10-regular.otf) the metrics themselves + # are not exactly representable as 12.20 fp. Manual testing via + # \sbox0{x}\count0=\wd0\typeout{\the\count0} suggests that metrics + # are rounded (not truncated) after conversion to 12.20 and before + # multiplication by the scale. + upem = self._face.units_per_EM # Usually 2048 or 1000. + g = self._face.load_glyph(idx, LoadFlags.NO_SCALE) + return TexMetrics( + tex_width=round(g.horiAdvance / upem * 2**20), + tex_height=round(g.horiBearingY / upem * 2**20), + tex_depth=round((g.height - g.horiBearingY) / upem * 2**20), + ) + + PsFont = namedtuple('PsFont', 'texname psname effects encoding filename') @@ -1179,10 +1318,6 @@ def _fontfile(cls, suffix, texname): import itertools from argparse import ArgumentParser - import fontTools.agl - - from matplotlib.ft2font import FT2Font - parser = ArgumentParser() parser.add_argument("filename") parser.add_argument("dpi", nargs="?", type=float, default=None) @@ -1197,17 +1332,18 @@ def _print_fields(*args): print(f"=== NEW PAGE === " f"(w: {page.width}, h: {page.height}, d: {page.descent})") print("--- GLYPHS ---") - for font, group in itertools.groupby( - page.text, lambda text: text.font): - psfont = fontmap[font.texname] - fontpath = psfont.filename - print(f"font: {font.texname.decode('latin-1')} " - f"(scale: {font._scale / 2 ** 20}) at {fontpath}") - face = FT2Font(fontpath) + for font, group in itertools.groupby(page.text, lambda text: text.font): + font_name = (font.texname.decode("utf8") if os.name == "nt" + else os.fsdecode(font.texname)) + if isinstance(font._metrics, Tfm): + print(f"font: {font_name} at {font.resolve_path()}") + else: + print(f"font: {font_name}") + print(f"scale: {font._scale / 2 ** 20}") _print_fields("x", "y", "glyph", "chr", "w") for text in group: - glyph_str = fontTools.agl.toUnicode(face.get_glyph_name(text.index)) - _print_fields(text.x, text.y, text.glyph, glyph_str, text.width) + _print_fields(text.x, text.y, text.glyph, + text._as_unicode_or_name(), text.width) if page.boxes: print("--- BOXES ---") _print_fields("x", "y", "h", "w") diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 82c0238d39d1..1c24ff1c28a9 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -58,16 +58,28 @@ class Dvi: class DviFont: texname: bytes - size: float def __init__( - self, scale: float, tfm: Tfm, texname: bytes, vf: Vf | None + self, scale: float, metrics: Tfm | TtfMetrics, texname: bytes, vf: Vf | None ) -> None: ... + @classmethod + def from_luatex(cls, scale: float, texname: bytes) -> DviFont: ... + @classmethod + def from_xetex( + cls, scale: float, texname: bytes, subfont: int, effects: dict[str, float] + ) -> DviFont: ... def __eq__(self, other: object) -> bool: ... def __ne__(self, other: object) -> bool: ... @property + def size(self) -> float: ... + @property def widths(self) -> list[int]: ... @property def fname(self) -> str: ... + def resolve_path(self) -> Path: ... + @property + def subfont(self) -> int: ... + @property + def effects(self) -> dict[str, float]: ... class Vf(Dvi): def __init__(self, filename: str | os.PathLike) -> None: ... @@ -93,6 +105,10 @@ class Tfm: @property def depth(self) -> dict[int, int]: ... +class TtfMetrics: + def __init__(self, filename: str | os.PathLike) -> None: ... + def get_metrics(self, idx: int) -> TexMetrics: ... + class PsFont(NamedTuple): texname: bytes psname: bytes diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 904ee5d73db4..eff079efe887 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -54,7 +54,7 @@ def setup(): def subprocess_run_for_testing(command, env=None, timeout=60, stdout=None, stderr=None, check=False, text=True, - capture_output=False): + capture_output=False, **kwargs): """ Create and run a subprocess. @@ -97,7 +97,7 @@ def subprocess_run_for_testing(command, env=None, timeout=60, stdout=None, command, env=env, timeout=timeout, check=check, stdout=stdout, stderr=stderr, - text=text + text=text, **kwargs ) except BlockingIOError: if sys.platform == "cygwin": diff --git a/lib/matplotlib/testing/__init__.pyi b/lib/matplotlib/testing/__init__.pyi index 7763cb6a9769..accf973615fa 100644 --- a/lib/matplotlib/testing/__init__.pyi +++ b/lib/matplotlib/testing/__init__.pyi @@ -16,6 +16,7 @@ def subprocess_run_for_testing( *, text: Literal[True], capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[str]: ... @overload def subprocess_run_for_testing( @@ -27,6 +28,7 @@ def subprocess_run_for_testing( check: bool = ..., text: Literal[False] = ..., capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[bytes]: ... @overload def subprocess_run_for_testing( @@ -38,6 +40,7 @@ def subprocess_run_for_testing( check: bool = ..., text: bool = ..., capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[bytes] | subprocess.CompletedProcess[str]: ... def subprocess_run_helper( func: Callable[[], None], diff --git a/lib/matplotlib/tests/baseline_images/dviread/lualatex.json b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json new file mode 100644 index 000000000000..8f2d95017ec7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8072180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8473140, 4128768, "c", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8833460, 4128768, ".", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738721, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920911, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json new file mode 100644 index 000000000000..4754b722aa58 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "cmr10.pfb", 9.96, {}], [5756246, 4128768, "L", "cmr10.pfb", 9.96, {}], [5929917, 3994421, "A", "cmr7.pfb", 6.97, {}], [6218464, 4128768, "T", "cmr10.pfb", 9.96, {}], [6582530, 4269852, "E", "cmr10.pfb", 9.96, {}], [6946620, 4128768, "X", "cmr10.pfb", 9.96, {}], [7656594, 4128768, "d", "cmr10.pfb", 9.96, {}], [8020684, 4128768, "o", "cmr10.pfb", 9.96, {}], [8366570, 4128768, "c", "cmr10.pfb", 9.96, {}], [8657841, 4128768, ".", "cmr10.pfb", 9.96, {}]], "boxes": []}, {"text": [[13686591, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13717140, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355327, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406754, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010688, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937658, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480727, 6804696, "s", "cmr10.pfb", 9.96, {}], [14739230, 6804696, "i", "cmr10.pfb", 9.96, {}], [14921275, 6804696, "n", "cmr10.pfb", 9.96, {}], [15394589, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847788, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239184, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642411, 6355152, "d", "cmr10.pfb", 9.96, {}], [17006501, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686591, 5130818, 26213, 284106], [14480727, 6204418, 26213, 1288418]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.dvi b/lib/matplotlib/tests/baseline_images/dviread/test.dvi deleted file mode 100644 index 93751ffdcba0df980ecca01aec8ba561a4e4f485..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 856 zcmey)#MnIPfQ&T*5HP=xRtQOrP{=PWDJU&bFfuSS)iX5EGcd6-G%&U32C85LDI)~_ z1Hl5ON(P4B1%DSaFf3rQ2Q!}l$%(!U44>J(KPFT%Z~=`0Vb z8P~pv%#SM~CYPiZmrPE{pWrT=T$-DjH(|%)lD;K8Ae-$N7}D~KKvsa%Wagz$&P^;S z$jL9s$xKo&o}5yaS(KWX(zmb|th5E>RmMmLhQ7Y}ia##&_RfEI&jCpO4dCgW#Bkxd z{bvq;-diyYtRNpie36@Jx>>RhEIFT1S*;UjUn&Dj=&AY4Ul;XGC=dP2+QvLP3a$g> z6VnYweLxEY6%X_RO+9_me*UNA_RkEzHkJy(!-p+7H?_jhV09EwA>YYAKy_y(DQz_9 z>+1#Sxq8xJ{+DM7s|A4O00rj%v{3}xYpadsW>yQZzAu`f^^rJ!(~FM0CMdye3O)a1;>9L2uA9#B$Y0%Br-7M`TCDKRgE diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.json b/lib/matplotlib/tests/baseline_images/dviread/test.json deleted file mode 100644 index 0809cb9531f1..000000000000 --- a/lib/matplotlib/tests/baseline_images/dviread/test.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { - "text": [ - [5046272, 4128768, "T", "cmr10", 9.96], - [5519588, 4128768, "h", "cmr10", 9.96], - [5883678, 4128768, "i", "cmr10", 9.96], - [6065723, 4128768, "s", "cmr10", 9.96], - [6542679, 4128768, "i", "cmr10", 9.96], - [6724724, 4128768, "s", "cmr10", 9.96], - [7201680, 4128768, "a", "cmr10", 9.96], - [7747814, 4128768, "L", "cmr10", 9.96], - [7921485, 3994421, "A", "cmr7", 6.97], - [8210032, 4128768, "T", "cmr10", 9.96], - [8574098, 4269852, "E", "cmr10", 9.96], - [8938188, 4128768, "X", "cmr10", 9.96], - [9648162, 4128768, "t", "cmr10", 9.96], - [9903025, 4128768, "e", "cmr10", 9.96], - [10194296, 4128768, "s", "cmr10", 9.96], - [10452799, 4128768, "t", "cmr10", 9.96], - [10926115, 4128768, "d", "cmr10", 9.96], - [11290205, 4128768, "o", "cmr10", 9.96], - [11636091, 4128768, "c", "cmr10", 9.96], - [11927362, 4128768, "u", "cmr10", 9.96], - [12291452, 4128768, "m", "cmr10", 9.96], - [12837587, 4128768, "e", "cmr10", 9.96], - [13128858, 4128768, "n", "cmr10", 9.96], - [13474743, 4128768, "t", "cmr10", 9.96], - [4063232, 4915200, "f", "cmr10", 9.96], - [4263482, 4915200, "o", "cmr10", 9.96], - [4591163, 4915200, "r", "cmr10", 9.96], - [5066299, 4915200, "t", "cmr10", 9.96], - [5321162, 4915200, "e", "cmr10", 9.96], - [5612433, 4915200, "s", "cmr10", 9.96], - [5870936, 4915200, "t", "cmr10", 9.96], - [6125799, 4915200, "i", "cmr10", 9.96], - [6307844, 4915200, "n", "cmr10", 9.96], - [6671934, 4915200, "g", "cmr10", 9.96], - [7218068, 4915200, "m", "cmr10", 9.96], - [7764203, 4915200, "a", "cmr10", 9.96], - [8091884, 4915200, "t", "cmr10", 9.96], - [8346747, 4915200, "p", "cmr10", 9.96], - [8710837, 4915200, "l", "cmr10", 9.96], - [8892882, 4915200, "o", "cmr10", 9.96], - [9220563, 4915200, "t", "cmr10", 9.96], - [9475426, 4915200, "l", "cmr10", 9.96], - [9657471, 4915200, "i", "cmr10", 9.96], - [9839516, 4915200, "b", "cmr10", 9.96], - [10203606, 4915200, "'", "cmr10", 9.96], - [10385651, 4915200, "s", "cmr10", 9.96], - [10862607, 4915200, "d", "cmr10", 9.96], - [11226697, 4915200, "v", "cmr10", 9.96], - [11572583, 4915200, "i", "cmr10", 9.96], - [11754628, 4915200, "r", "cmr10", 9.96], - [12011311, 4915200, "e", "cmr10", 9.96], - [12302582, 4915200, "a", "cmr10", 9.96], - [12630263, 4915200, "d", "cmr10", 9.96], - [13686591, 6629148, "\u0019", "cmmi5", 4.98], - [13717140, 6963172, "2", "cmr5", 4.98], - [13355327, 7035991, "Z", "cmex10", 9.96], - [13406754, 8897228, "0", "cmr7", 6.97], - [14010688, 7200560, "\u0010", "cmex10", 9.96], - [14937658, 7484660, "x", "cmmi10", 9.96], - [14480727, 8377560, "s", "cmr10", 9.96], - [14739230, 8377560, "i", "cmr10", 9.96], - [14921275, 8377560, "n", "cmr10", 9.96], - [15394589, 8377560, "x", "cmmi10", 9.96], - [15847788, 7200560, "\u0011", "cmex10", 9.96], - [16239184, 7336365, "2", "cmr7", 6.97], - [16642411, 7928016, "d", "cmr10", 9.96], - [17006501, 7928016, "x", "cmmi10", 9.96] - ], - "boxes": [ - [4063232, 5701632, 65536, 22609920], - [13686591, 6703682, 26213, 284106], - [14480727, 7777282, 26213, 1288418] - ] - }, - { - "text": [ - [5046272, 4128768, "a", "cmr10", 9.96], - [5373953, 4128768, "n", "cmr10", 9.96], - [5738043, 4128768, "o", "cmr10", 9.96], - [6065724, 4128768, "t", "cmr10", 9.96], - [6320587, 4128768, "h", "cmr10", 9.96], - [6684677, 4128768, "e", "cmr10", 9.96], - [6975948, 4128768, "r", "cmr10", 9.96], - [7451084, 4128768, "p", "cmr10", 9.96], - [7815174, 4128768, "a", "cmr10", 9.96], - [8142855, 4128768, "g", "cmr10", 9.96], - [8470536, 4128768, "e", "cmr10", 9.96] - ], - "boxes": [] - } -] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.tex b/lib/matplotlib/tests/baseline_images/dviread/test.tex index 33220fedae3e..4a2d4720c065 100644 --- a/lib/matplotlib/tests/baseline_images/dviread/test.tex +++ b/lib/matplotlib/tests/baseline_images/dviread/test.tex @@ -1,17 +1,19 @@ -% source file for test.dvi \documentclass{article} +\usepackage{iftex} +\iftutex\usepackage{fontspec}\fi % xetex or luatex \pagestyle{empty} + \begin{document} -This is a \LaTeX\ test document\\ -for testing matplotlib's dviread +A \LaTeX { + \iftutex\fontspec{DejaVuSans.ttf}[ + FakeSlant=0.25, FakeStretch=1.25, FakeBold=2.5, Color=0000FF]\fi + doc. +} -\noindent\rule{\textwidth}{1pt} +\newpage \[ \int\limits_0^{\frac{\pi}{2}} \Bigl(\frac{x}{\sin x}\Bigr)^2\,\mathrm{d}x \] \special{Special!} -\newpage -another page - \end{document} diff --git a/lib/matplotlib/tests/baseline_images/dviread/xelatex.json b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json new file mode 100644 index 000000000000..8fb81ddf0c7e --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8176180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8677380, 4128768, "c", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [9127780, 4128768, ".", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738722, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920912, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/test_dviread.py b/lib/matplotlib/tests/test_dviread.py index 7b7ff151be18..7aecd1fc03ae 100644 --- a/lib/matplotlib/tests/test_dviread.py +++ b/lib/matplotlib/tests/test_dviread.py @@ -2,7 +2,8 @@ from pathlib import Path import shutil -import matplotlib.dviread as dr +from matplotlib import cbook, dviread as dr +from matplotlib.testing import subprocess_run_for_testing import pytest @@ -62,16 +63,45 @@ def test_PsfontsMap(monkeypatch): @pytest.mark.skipif(shutil.which("kpsewhich") is None, reason="kpsewhich is not available") -def test_dviread(): - dirpath = Path(__file__).parent / 'baseline_images/dviread' - with (dirpath / 'test.json').open() as f: - correct = json.load(f) - with dr.Dvi(str(dirpath / 'test.dvi'), None) as dvi: - data = [{'text': [[t.x, t.y, - chr(t.glyph), - t.font.texname.decode('ascii'), - round(t.font.size, 2)] - for t in page.text], - 'boxes': [[b.x, b.y, b.height, b.width] for b in page.boxes]} - for page in dvi] +@pytest.mark.parametrize("engine", ["pdflatex", "xelatex", "lualatex"]) +def test_dviread(tmp_path, engine, monkeypatch): + dirpath = Path(__file__).parent / "baseline_images/dviread" + shutil.copy(dirpath / "test.tex", tmp_path) + shutil.copy(cbook._get_data_path("fonts/ttf/DejaVuSans.ttf"), tmp_path) + cmd, fmt = { + "pdflatex": (["latex"], "dvi"), + "xelatex": (["xelatex", "-no-pdf"], "xdv"), + "lualatex": (["lualatex", "-output-format=dvi"], "dvi"), + }[engine] + if shutil.which(cmd[0]) is None: + pytest.skip(f"{cmd[0]} is not available") + subprocess_run_for_testing( + [*cmd, "test.tex"], cwd=tmp_path, check=True, capture_output=True) + # dviread must be run from the tmppath directory because {xe,lua}tex output + # records the path to DejaVuSans.ttf as it is written in the tex source, + # i.e. as a relative path. + monkeypatch.chdir(tmp_path) + with dr.Dvi(tmp_path / f"test.{fmt}", None) as dvi: + try: + pages = [*dvi] + except FileNotFoundError as exc: + for note in getattr(exc, "__notes__", []): + if "too-old version of luaotfload" in note: + pytest.skip(note) + raise + data = [ + { + "text": [ + [ + t.x, t.y, + t._as_unicode_or_name(), + t.font.resolve_path().name, + round(t.font.size, 2), + t.font.effects, + ] for t in page.text + ], + "boxes": [[b.x, b.y, b.height, b.width] for b in page.boxes] + } for page in pages + ] + correct = json.loads((dirpath / f"{engine}.json").read_text()) assert data == correct diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..8deae19c42e7 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -234,7 +234,9 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, # characters into strings. t1_encodings = {} for text in page.text: - font = get_font(text.font_path) + font = get_font(text.font.resolve_path()) + if text.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") char_id = self._get_char_id(font, text.glyph) if char_id not in glyph_map: font.clear() From ba01ff44490c3b553c5443f3bc21002c1a66202f Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:28:17 +0100 Subject: [PATCH 070/228] Update syntax for PR welcome workflow It looks like the syntax for this changed at https://github.com/actions/first-interaction/pull/311 --- .github/workflows/pr_welcome.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr_welcome.yml b/.github/workflows/pr_welcome.yml index 0a654753861a..874f8807b478 100644 --- a/.github/workflows/pr_welcome.yml +++ b/.github/workflows/pr_welcome.yml @@ -11,8 +11,8 @@ jobs: steps: - uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: >+ + repo_token: ${{ secrets.GITHUB_TOKEN }} + pr_message: >+ Thank you for opening your first PR into Matplotlib! From 628a68f122d5e7252bcb6d0c9d9bd910049827ad Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 29 Aug 2025 23:58:54 -0400 Subject: [PATCH 071/228] TST: Use a temporary directory for test_save_figure_return This avoids having to manually clean up the resulting file, which seems flaky. --- lib/matplotlib/tests/test_backend_qt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 3f34a58a765d..cbe2e9a5264c 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -215,14 +215,15 @@ def test_figureoptions(): @pytest.mark.backend('QtAgg', skip_on_importerror=True) -def test_save_figure_return(): +def test_save_figure_return(tmp_path): fig, ax = plt.subplots() ax.imshow([[1]]) + expected = tmp_path / "foobar.png" prop = "matplotlib.backends.qt_compat.QtWidgets.QFileDialog.getSaveFileName" - with mock.patch(prop, return_value=("foobar.png", None)): + with mock.patch(prop, return_value=(str(expected), None)): fname = fig.canvas.manager.toolbar.save_figure() - os.remove("foobar.png") - assert fname == "foobar.png" + assert fname == str(expected) + assert expected.exists() with mock.patch(prop, return_value=(None, None)): fname = fig.canvas.manager.toolbar.save_figure() assert fname is None From f791853952e93c6609d61a08c0e271a287c77ffe Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 2 Sep 2025 23:45:39 +0200 Subject: [PATCH 072/228] DOC: Clarify draft PR and move from ways to contribute to PR guidelines Closes #30436. --- doc/devel/contribute.rst | 16 +++++++--------- doc/devel/development_workflow.rst | 6 +++--- doc/devel/pr_guide.rst | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/doc/devel/contribute.rst b/doc/devel/contribute.rst index 558e19790d82..e2291e3255e6 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -29,7 +29,8 @@ Ways to contribute * **You are a Matplotlib user, and you see a bug, a potential improvement, or something that annoys you, and you can fix it.** - You can search our issue tracker for an existing issue that describes your problem or + You can search our `issue tracker `__ + for an existing issue that describes your problem or open a new issue to inform us of the problem you observed and discuss the best approach to fix it. If your contributions would not be captured on GitHub (social media, communication, educational content), you can also reach out to us on gitter_, @@ -42,14 +43,11 @@ Ways to contribute Awesome — you have a focus on a specific application and domain and can start there. In this case, maintainers can help you figure out the best - implementation; open an issue or pull request with a starting point, and we'll - be happy to discuss technical approaches. + implementation; `open an issue `__ + in our issue tracker, and we'll be happy to discuss technical approaches. - If you prefer, you can use the `GitHub functionality for "draft" pull requests - `__ - and request early feedback on whatever you are working on, but you should be - aware that maintainers may not review your contribution unless it has the - "Ready to review" state on GitHub. + If you can implement the solution yourself, even better! Consider contributing + the change as a :ref:`pull request ` right away. * **You are new to Matplotlib, both as a user and contributor, and want to start contributing but have yet to develop a particular interest.** @@ -287,7 +285,7 @@ guide you through each step: 4. Check existing pull requests (e.g., :ghpull:`28476`) and filter by the issue number to make sure the issue is not in progress: * If the issue has a pull request (is in progress), tag the user working on the issue, and ask to collaborate (optional). - * If a pull request does not exist, create a `draft pull request `_ and follow the `pull request guidelines `_. + * If there is no pull request, :ref:`create a new pull request `. 5. Please familiarize yourself with the pull request template (see below), and ensure you understand/are able to complete the template when you open your pull request. Additional information can be found in the `pull request guidelines `_. diff --git a/doc/devel/development_workflow.rst b/doc/devel/development_workflow.rst index 16766278f658..c0300acf1f7f 100644 --- a/doc/devel/development_workflow.rst +++ b/doc/devel/development_workflow.rst @@ -179,9 +179,9 @@ Enter a title for the set of changes with some explanation of what you've done. Mention anything you'd like particular attention for - such as a complicated change or some code you are not happy with. -If you don't think your request is ready to be merged, just say so in your pull -request message and use the "Draft PR" feature of GitHub. This is a good way of -getting some preliminary code review. +If you don't think your request is ready to be merged, make a +:ref:`draft pull request ` and state what aspects you want to have +feedback on. This is a good way of getting some preliminary code review. For more guidance on the mechanics of making a pull request, see GitHub's `pull request tutorial `_. diff --git a/doc/devel/pr_guide.rst b/doc/devel/pr_guide.rst index a02b52ad5a38..2dc4e34dd6fe 100644 --- a/doc/devel/pr_guide.rst +++ b/doc/devel/pr_guide.rst @@ -12,7 +12,7 @@ We value contributions from people with all levels of experience. In particular, if this is your first PR not everything has to be perfect. We'll guide you through the PR process. Nevertheless, please try to follow our guidelines as well as you can to help make the PR process quick and smooth. If your pull request is -incomplete or a work-in-progress, please mark it as a `draft pull requests `_ +incomplete or a work-in-progress, please mark it as a :ref:`draft pull request ` on GitHub and specify what feedback from the developers would be helpful. Please be patient with reviewers. We try our best to respond quickly, but we have @@ -118,6 +118,22 @@ Workflow Detailed guidelines =================== +.. _draft-pr: + +Draft PRs +--------- + +Authors may create a `draft PR`_ (or change to draft status later) if the code +is not yet ready for a regular full review. Typical use cases are posting code +as a basis for discussion or signalling that you intend to rework the code as +a result of feedback. Authors should clearly communicate why the PR has draft +status and what needs to be done to make it ready for review. In particular, +they should explicitly ask for targeted feedback if needed. By default, +reviewers will not look at the code of a draft PR and only respond to specific +questions by the author. + +.. _draft PR: https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests + .. _pr-documentation: Documentation From 155b19c2bdb8e0e9620eadfdb9fc32a6be9b52df Mon Sep 17 00:00:00 2001 From: David Stansby Date: Fri, 5 Sep 2025 08:44:43 +0100 Subject: [PATCH 073/228] Backport PR #30497: TST: Use a temporary directory for test_save_figure_return --- lib/matplotlib/tests/test_backend_qt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 962f5ce94206..1012b16f0212 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -219,14 +219,15 @@ def test_figureoptions(): @pytest.mark.backend('QtAgg', skip_on_importerror=True) -def test_save_figure_return(): +def test_save_figure_return(tmp_path): fig, ax = plt.subplots() ax.imshow([[1]]) + expected = tmp_path / "foobar.png" prop = "matplotlib.backends.qt_compat.QtWidgets.QFileDialog.getSaveFileName" - with mock.patch(prop, return_value=("foobar.png", None)): + with mock.patch(prop, return_value=(str(expected), None)): fname = fig.canvas.manager.toolbar.save_figure() - os.remove("foobar.png") - assert fname == "foobar.png" + assert fname == str(expected) + assert expected.exists() with mock.patch(prop, return_value=(None, None)): fname = fig.canvas.manager.toolbar.save_figure() assert fname is None From 6af9062f0aa178c5a951854351585ea475038d6c Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 7 Sep 2025 09:42:56 +0200 Subject: [PATCH 074/228] Add note on incompatibility with blitting of widgets --- doc/release/next_whats_new/pyplot-register-figure.rst | 3 +++ lib/matplotlib/tests/test_backends_interactive.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/doc/release/next_whats_new/pyplot-register-figure.rst b/doc/release/next_whats_new/pyplot-register-figure.rst index 6df2676e0ffa..c619aa428695 100644 --- a/doc/release/next_whats_new/pyplot-register-figure.rst +++ b/doc/release/next_whats_new/pyplot-register-figure.rst @@ -58,3 +58,6 @@ general, you should not store a reference to the canvas, but rather always obtain it from the figure with ``fig.canvas``. This will return the current canvas, which is either the original `.FigureCanvasBase` or a backend-dependent subclass, depending on whether the figure is registered with pyplot or not. +Additionally, the swapping of the canvas currently does not play well with +blitting of matplotlib widgets; in such cases either deactivate blitting or do not +continue to use the figure (e.g. saving it after closing the window). diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 671ad8466aee..d7069cd5377f 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -18,6 +18,7 @@ import matplotlib as mpl from matplotlib import _c_internal_utils +from matplotlib.backend_bases import FigureCanvasBase from matplotlib.backend_tools import ToolToggleBase from matplotlib.testing import subprocess_run_helper as _run_helper, is_ci_environment @@ -229,6 +230,7 @@ def check_alt_backend(alt_backend): # When the figure is closed, it's manager is removed and the canvas is reset to # FigureCanvasBase. Saving should still be possible. + assert type(fig.canvas) == FigureCanvasBase result_after = io.BytesIO() fig.savefig(result_after, format='png', dpi=100) From 95773bb204214056a2dfe0a279a4ec7eb088cb55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:29:58 +0000 Subject: [PATCH 075/228] Bump the actions group across 1 directory with 10 updates Bumps the actions group with 10 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/setup-python](https://github.com/actions/setup-python) | `5.6.0` | `6.0.0` | | [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) | `3.1.3` | `3.1.4` | | [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | `2.4.0` | `3.0.0` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.12.4` | `1.13.0` | | [reviewdog/action-setup](https://github.com/reviewdog/action-setup) | `1.3.2` | `1.4.0` | | [github/codeql-action](https://github.com/github/codeql-action) | `3.29.10` | `3.30.1` | | [actions/labeler](https://github.com/actions/labeler) | `5.0.0` | `6.0.1` | | [reviewdog/action-eslint](https://github.com/reviewdog/action-eslint) | `1.33.2` | `1.34.0` | | [actions/stale](https://github.com/actions/stale) | `9.1.0` | `10.0.0` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `5.4.3` | `5.5.1` | Updates `actions/setup-python` from 5.6.0 to 6.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...e797f83bcb11b83ae66e0230d6156d7c80228e7c) Updates `pypa/cibuildwheel` from 3.1.3 to 3.1.4 - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/352e01339f0a173aa2a3eb57f01492e341e83865...c923d83ad9c1bc00211c5041d0c3f73294ff88f6) Updates `actions/attest-build-provenance` from 2.4.0 to 3.0.0 - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/e8998f949152b193b063cb0ec769d69d929409be...977bb373ede98d70efdf65b84cb5f73e068dcc2a) Updates `pypa/gh-action-pypi-publish` from 1.12.4 to 1.13.0 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/76f52bc884231f62b9a034ebfe128415bbaabdfc...ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e) Updates `reviewdog/action-setup` from 1.3.2 to 1.4.0 - [Release notes](https://github.com/reviewdog/action-setup/releases) - [Commits](https://github.com/reviewdog/action-setup/compare/e04ffabe3898a0af8d0fb1af00c188831c4b5893...d8edfce3dd5e1ec6978745e801f9c50b5ef80252) Updates `github/codeql-action` from 3.29.10 to 3.30.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/96f518a34f7a870018057716cc4d7a5c014bd61c...f1f6e5f6af878fb37288ce1c627459e94dbf7d01) Updates `actions/labeler` from 5.0.0 to 6.0.1 - [Release notes](https://github.com/actions/labeler/releases) - [Commits](https://github.com/actions/labeler/compare/8558fd74291d67161a8a78ce36a881fa63b766a9...634933edcd8ababfe52f92936142cc22ac488b1b) Updates `reviewdog/action-eslint` from 1.33.2 to 1.34.0 - [Release notes](https://github.com/reviewdog/action-eslint/releases) - [Commits](https://github.com/reviewdog/action-eslint/compare/2fee6dd72a5419ff4113f694e2068d2a03bb35dd...556a3fdaf8b4201d4d74d406013386aa4f7dab96) Updates `actions/stale` from 9.1.0 to 10.0.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...3a9db7e6a41a89f618792c92c0e97cc736e1b13f) Updates `codecov/codecov-action` from 5.4.3 to 5.5.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/18283e04ce6e62d37312384ff67231eb8fd56d24...5a1091511ad55cbe89839c7260b706298ca349f7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pypa/cibuildwheel dependency-version: 3.1.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/attest-build-provenance dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: reviewdog/action-setup dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action dependency-version: 3.30.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/labeler dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: reviewdog/action-eslint dependency-version: 1.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/stale dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: 5.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/cibuildwheel.yml | 16 ++++++++-------- .github/workflows/circleci.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/labeler.yml | 2 +- .github/workflows/linting.yml | 12 ++++++------ .github/workflows/mypy-stubtest.yml | 4 ++-- .github/workflows/stale-tidy.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 9d4de069b078..8ec11e30e122 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -46,7 +46,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 name: Install Python with: python-version: '3.11' @@ -143,7 +143,7 @@ jobs: path: dist/ - name: Build wheels for CPython 3.14 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -153,7 +153,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - name: Build wheels for CPython 3.13 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -162,7 +162,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.12 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -170,7 +170,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.11 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -178,7 +178,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for PyPy - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -215,9 +215,9 @@ jobs: run: ls dist - name: Generate artifact attestation for sdist and wheel - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: dist/matplotlib-* - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index 3838a38004e0..9a2516efd4bb 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up reviewdog if: "${{ steps.fetch-artifacts.outputs.count != 0 }}" - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.2 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 with: reviewdog_version: latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index eebdd65105e3..07c9c1701cf1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 + uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 with: languages: ${{ matrix.language }} @@ -43,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 + uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8e2002353164..17c4922df054 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,6 +10,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: sync-labels: true diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f5cada1f3f9d..6878f2dac8b3 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.x" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' @@ -40,7 +40,7 @@ jobs: run: pip3 install ruff - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 - name: Run ruff env: @@ -61,7 +61,7 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' @@ -69,7 +69,7 @@ jobs: run: pip3 install -r requirements/testing/mypy.txt -r requirements/testing/all.txt - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 - name: Run mypy env: @@ -92,7 +92,7 @@ jobs: persist-credentials: false - name: eslint - uses: reviewdog/action-eslint@2fee6dd72a5419ff4113f694e2068d2a03bb35dd # v1.33.2 + uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0 with: filter_mode: nofilter github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mypy-stubtest.yml b/.github/workflows/mypy-stubtest.yml index b40909b371a6..9420f5e43ea7 100644 --- a/.github/workflows/mypy-stubtest.yml +++ b/.github/workflows/mypy-stubtest.yml @@ -17,12 +17,12 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 - name: Install tox run: python -m pip install tox diff --git a/.github/workflows/stale-tidy.yml b/.github/workflows/stale-tidy.yml index bc50dc892155..09b9cc49a8f8 100644 --- a/.github/workflows/stale-tidy.yml +++ b/.github/workflows/stale-tidy.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 300 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b65b44a59e88..624792ed171a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 20 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e965819628be..c6f1e25c1e92 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -396,7 +396,7 @@ jobs: fi - name: Upload code coverage if: ${{ !cancelled() && github.event_name != 'schedule' }} - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }}" token: ${{ secrets.CODECOV_TOKEN }} From 6b1797b27ef8c95c2b5f0174c527fe5a0dd2dd12 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:49:06 +0200 Subject: [PATCH 076/228] Fix assertion error import must be local --- lib/matplotlib/tests/test_backends_interactive.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index d7069cd5377f..18731ab3fa0b 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -18,7 +18,6 @@ import matplotlib as mpl from matplotlib import _c_internal_utils -from matplotlib.backend_bases import FigureCanvasBase from matplotlib.backend_tools import ToolToggleBase from matplotlib.testing import subprocess_run_helper as _run_helper, is_ci_environment @@ -163,7 +162,7 @@ def _test_interactive_impl(): import matplotlib as mpl from matplotlib import pyplot as plt - from matplotlib.backend_bases import KeyEvent + from matplotlib.backend_bases import KeyEvent, FigureCanvasBase mpl.rcParams.update({ "webagg.open_in_browser": False, "webagg.port_retries": 1, @@ -230,7 +229,7 @@ def check_alt_backend(alt_backend): # When the figure is closed, it's manager is removed and the canvas is reset to # FigureCanvasBase. Saving should still be possible. - assert type(fig.canvas) == FigureCanvasBase + assert type(fig.canvas) == FigureCanvasBase, str(fig.canvas) result_after = io.BytesIO() fig.savefig(result_after, format='png', dpi=100) From b42ce647ea4bff5cd4be77462f66bb1c71a8c06e Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 22:37:20 +0200 Subject: [PATCH 077/228] Fix macosx --- src/_macosx.m | 1 + 1 file changed, 1 insertion(+) diff --git a/src/_macosx.m b/src/_macosx.m index 1372157bc80d..a8f91266c676 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -686,6 +686,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) { [self->window close]; self->window = NULL; + [super destroy]; Py_RETURN_NONE; } From 2dd81752c8a8a26f8d67a7f322bea78b9a0a2104 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 23:59:38 +0200 Subject: [PATCH 078/228] MNT: Change default name of ListedColormaps from "from_list" to "unnamed", which makes more sense logically. While technically a breaking change, I anticipate that nobody is relying on that default value. --- doc/api/next_api_changes/behavior/30532-TH.rst | 4 ++++ lib/matplotlib/colors.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 doc/api/next_api_changes/behavior/30532-TH.rst diff --git a/doc/api/next_api_changes/behavior/30532-TH.rst b/doc/api/next_api_changes/behavior/30532-TH.rst new file mode 100644 index 000000000000..62bf481e1b75 --- /dev/null +++ b/doc/api/next_api_changes/behavior/30532-TH.rst @@ -0,0 +1,4 @@ +Default name of ``ListedColormap`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The default name of `ListedColormap` has changed from "from_list" to "unnamed". diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index f60c8eb48134..679f368bae30 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1322,7 +1322,7 @@ class ListedColormap(Colormap): "and will be removed in %(removal)s. Please ensure the list " "of passed colors is the required length instead." ) - def __init__(self, colors, name='from_list', N=None, *, + def __init__(self, colors, name='unnamed', N=None, *, bad=None, under=None, over=None): if N is None: self.colors = colors From 918f7ee93bdf25ec7b8dbf2220fb05d3f304a885 Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Tue, 9 Sep 2025 10:05:46 +0800 Subject: [PATCH 079/228] fix import warning --- lib/matplotlib/backends/backend_gtk3.py | 2 ++ lib/matplotlib/backends/backend_gtk4.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 888f5a770f5d..38c1541b8df1 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -18,6 +18,8 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi.require_version("Gtk", "3.0") + # Also require GioUnix to avoid PyGIWarning when Gio is imported + gi.require_version("GioUnix", "2.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index cd38968779ed..ec45c1f5b205 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -17,6 +17,8 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi.require_version("Gtk", "4.0") + # Also require GioUnix to avoid PyGIWarning when Gio is imported + gi.require_version("GioUnix", "2.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. From 2bef93f32f15fcfad77de3c8530731d84f7516cf Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Tue, 9 Sep 2025 10:33:53 +0800 Subject: [PATCH 080/228] fix --- lib/matplotlib/backends/backend_gtk3.py | 7 ++++++- lib/matplotlib/backends/backend_gtk4.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 38c1541b8df1..4e05119aa0f6 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -19,7 +19,12 @@ # required, or unavailable. gi.require_version("Gtk", "3.0") # Also require GioUnix to avoid PyGIWarning when Gio is imported - gi.require_version("GioUnix", "2.0") + # GioUnix is platform-specific and may not be available on all systems + try: + gi.require_version("GioUnix", "2.0") + except ValueError: + # GioUnix is not available on this platform, which is fine + pass except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index ec45c1f5b205..a45fa0bc490f 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -18,7 +18,12 @@ # required, or unavailable. gi.require_version("Gtk", "4.0") # Also require GioUnix to avoid PyGIWarning when Gio is imported - gi.require_version("GioUnix", "2.0") + # GioUnix is platform-specific and may not be available on all systems + try: + gi.require_version("GioUnix", "2.0") + except ValueError: + # GioUnix is not available on this platform, which is fine + pass except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. From c6210d85b2b9c24032eec18f8e77daac18d2106d Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 9 Sep 2025 07:00:02 +0200 Subject: [PATCH 081/228] Update doc/api/next_api_changes/behavior/30532-TH.rst Co-authored-by: Elliott Sales de Andrade --- doc/api/next_api_changes/behavior/30532-TH.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/next_api_changes/behavior/30532-TH.rst b/doc/api/next_api_changes/behavior/30532-TH.rst index 62bf481e1b75..3d368c566039 100644 --- a/doc/api/next_api_changes/behavior/30532-TH.rst +++ b/doc/api/next_api_changes/behavior/30532-TH.rst @@ -1,4 +1,4 @@ Default name of ``ListedColormap`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The default name of `ListedColormap` has changed from "from_list" to "unnamed". +The default name of `.ListedColormap` has changed from "from_list" to "unnamed". From 56911722d53aa8ddd58b8a123b89839de0193e19 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:49:01 +0200 Subject: [PATCH 082/228] Fix reviewdog version number --- .github/workflows/linting.yml | 4 ++-- .github/workflows/mypy-stubtest.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 6878f2dac8b3..f1c6d21019e3 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -40,7 +40,7 @@ jobs: run: pip3 install ruff - name: Set up reviewdog - uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Run ruff env: @@ -69,7 +69,7 @@ jobs: run: pip3 install -r requirements/testing/mypy.txt -r requirements/testing/all.txt - name: Set up reviewdog - uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Run mypy env: diff --git a/.github/workflows/mypy-stubtest.yml b/.github/workflows/mypy-stubtest.yml index 9420f5e43ea7..3815efd08954 100644 --- a/.github/workflows/mypy-stubtest.yml +++ b/.github/workflows/mypy-stubtest.yml @@ -22,7 +22,7 @@ jobs: python-version: '3.11' - name: Set up reviewdog - uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Install tox run: python -m pip install tox From 3b29f6d5089bae938fbe299379ad9ea63a3c3f43 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:12:45 +0200 Subject: [PATCH 083/228] DOC: Cleanup/restructure PR guidelines - move contents from "Approval" into new section "Merging" - move contents from "Number of commits and squashing" into new section "Review" - Update "Summary for pull request reviewers" accordingly --- doc/devel/pr_guide.rst | 55 ++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/doc/devel/pr_guide.rst b/doc/devel/pr_guide.rst index 2dc4e34dd6fe..e7e3ceba8f95 100644 --- a/doc/devel/pr_guide.rst +++ b/doc/devel/pr_guide.rst @@ -109,9 +109,10 @@ Workflow * The PR should :ref:`target the main branch `. * Tag with descriptive :ref:`labels `. * Set the :ref:`milestone `. -* Keep an eye on the :ref:`number of commits `. +* :ref:`Review ` the contents. * Approve if all of the above topics are handled. -* :ref:`Merge ` if a sufficient number of approvals is reached. +* Keep an eye on the :ref:`number of commits `. +* :ref:`Merge ` if a :ref:`sufficient number of approvals ` is reached. .. _pr-guidelines-details: @@ -190,10 +191,27 @@ All Pull Requests should target the main branch. The milestone tag triggers an :ref:`automatic backport ` for milestones which have a corresponding branch. -.. _pr-merging: +.. _pr-review: -Merging -------- +Review +------ + +* Do not let perfect be the enemy of the good, particularly for + documentation or example PRs. If you find yourself making many + small suggestions, either open a PR against the original branch, + push changes to the contributor branch, or merge the PR and then + open a new PR against upstream. + +* If you push to a contributor branch leave a comment explaining what + you did, ex "I took the liberty of pushing a small clean-up PR to + your branch, thanks for your work.". If you are going to make + substantial changes to the code or intent of the PR please check + with the contributor first. + +.. _pr-approval: + +Approval +-------- As a guiding principle, we require two `approvals`_ from core developers (those with commit rights) before merging a pull request. This two-pairs-of-eyes strategy shall ensure a consistent project direction and prevent accidental @@ -229,11 +247,6 @@ Some explicit rules following from this: A core dev should only champion one PR at a time and we should try to keep the flow of championed PRs reasonable. -After giving the last required approval, the author of the approval should -merge the PR. PR authors should not self-merge except for when another reviewer -explicitly allows it (e.g., "Approve modulo CI passing, may self merge when -green", or "Take or leave the comments. You may self merge".). - .. _pr-automated-tests: Automated tests @@ -241,6 +254,15 @@ Automated tests Before being merged, a PR should pass the :ref:`automated-tests`. If you are unsure why a test is failing, ask on the PR or in our :ref:`communication-channels` +.. _pr-merging: + +Merging +------- +After giving the last required :ref:`approval `, the author of the +approval should merge the PR. PR authors should not self-merge except for when +another reviewer explicitly allows it (e.g., "Approve modulo CI passing, may +self-merge when green", or "Take or leave the comments. You may self merge".). + .. _pr-squashing: Number of commits and squashing @@ -252,19 +274,6 @@ Number of commits and squashing about it is to eliminate binary files (ex multiple test image re-generations) and to remove upstream merges. -* Do not let perfect be the enemy of the good, particularly for - documentation or example PRs. If you find yourself making many - small suggestions, either open a PR against the original branch, - push changes to the contributor branch, or merge the PR and then - open a new PR against upstream. - -* If you push to a contributor branch leave a comment explaining what - you did, ex "I took the liberty of pushing a small clean-up PR to - your branch, thanks for your work.". If you are going to make - substantial changes to the code or intent of the PR please check - with the contributor first. - - .. _branches_and_backports: Branches and backports From 774d02c038c359d688672b1fd36c95ce7991e8aa Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:46:36 +0200 Subject: [PATCH 084/228] Call super().destroy() in _maxosx.m FigureManager --- src/_macosx.m | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/_macosx.m b/src/_macosx.m index a8f91266c676..1dbd52bb0a14 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -572,6 +572,8 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) }, }; +static PyTypeObject FigureManagerType; + typedef struct { PyObject_HEAD Window* window; @@ -686,7 +688,24 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) { [self->window close]; self->window = NULL; - [super destroy]; + + // call super().destroy() + PyObject *super_obj = PyObject_CallFunctionObjArgs( + (PyObject *)&PySuper_Type, + (PyObject *)&FigureManagerType, + self, + NULL + ); + if (super_obj == NULL) { + return NULL; // error + } + PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL); + Py_DECREF(super_obj); + if (result == NULL) { + return NULL; // error + } + Py_DECREF(result); + Py_RETURN_NONE; } From 289c2d9352523e896a0734f4defdd5a48a9e4fb4 Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Wed, 10 Sep 2025 15:16:17 +1000 Subject: [PATCH 085/228] Fix scale_unit/scale_units typo in quiver docs The example was missing the *s*, so copy+pasting the example gave an error when plotting. --- lib/matplotlib/quiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index 91c510ca7060..df693c57d272 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -144,7 +144,7 @@ length in y direction = $\\frac{v}{\\mathrm{scale}} \\mathrm{scale_unit}$ - For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_unit="width"`` results + For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_units="width"`` results in a horizontal arrow with a length of *0.5 / 10 * "width"*, i.e. 0.05 times the Axes width. From 56b2dcc6560804eff7b62a939792576859f45e66 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Wed, 10 Sep 2025 07:11:54 +0100 Subject: [PATCH 086/228] Backport PR #30539: Fix scale_unit/scale_units typo in quiver docs --- lib/matplotlib/quiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index e66f1f97b21f..b9963b0e1716 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -144,7 +144,7 @@ length in y direction = $\\frac{v}{\\mathrm{scale}} \\mathrm{scale_unit}$ - For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_unit="width"`` results + For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_units="width"`` results in a horizontal arrow with a length of *0.5 / 10 * "width"*, i.e. 0.05 times the Axes width. From f2a768287beffd25f5efa761f8a42653af01593f Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:01:14 +0200 Subject: [PATCH 087/228] ENH: Scroll to zoom (#30405) * ENH: Scroll to zoom Implements a minimal version of #20317, in particular https://github .com/matplotlib/matplotlib/pull/20317#issuecomment-2233156558: Co-authored-by: Elliott Sales de Andrade --- doc/release/next_whats_new/scroll_to_zoom.rst | 8 ++++ lib/matplotlib/backend_bases.py | 48 +++++++++++++++++++ lib/matplotlib/backend_bases.pyi | 6 +++ 3 files changed, 62 insertions(+) create mode 100644 doc/release/next_whats_new/scroll_to_zoom.rst diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst new file mode 100644 index 000000000000..bafa312c32a5 --- /dev/null +++ b/doc/release/next_whats_new/scroll_to_zoom.rst @@ -0,0 +1,8 @@ +Zooming using mouse wheel +~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Ctrl+MouseWheel`` can be used to zoom in the plot windows. + +The zoom focusses on the mouse pointer, and keeps the aspect ratio of the axes. + +Zooming is currently only supported on rectilinear Axes. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8107471955fe..7560db80d2c1 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2574,6 +2574,51 @@ def button_press_handler(event, canvas=None, toolbar=None): toolbar.forward() +def scroll_handler(event, canvas=None, toolbar=None): + ax = event.inaxes + if ax is None: + return + if ax.name != "rectilinear": + # zooming is currently only supported on rectilinear axes + return + + if toolbar is None: + toolbar = (canvas or event.canvas).toolbar + + if toolbar is None: + # technically we do not need a toolbar, but until wheel zoom was + # introduced, any interactive modification was only possible through + # the toolbar tools. For now, we keep the restriction that a toolbar + # is required for interactive navigation. + return + + if event.key == "control": # zoom towards the mouse position + toolbar.push_current() + + xmin, xmax = ax.get_xlim() + ymin, ymax = ax.get_ylim() + (xmin, ymin), (xmax, ymax) = ax.transScale.transform( + [(xmin, ymin), (xmax, ymax)]) + + # mouse position in scaled (e.g., log) data coordinates + x, y = ax.transScale.transform((event.xdata, event.ydata)) + + scale_factor = 0.85 ** event.step + new_xmin = x - (x - xmin) * scale_factor + new_xmax = x + (xmax - x) * scale_factor + new_ymin = y - (y - ymin) * scale_factor + new_ymax = y + (ymax - y) * scale_factor + + inv_scale = ax.transScale.inverted() + (new_xmin, new_ymin), (new_xmax, new_ymax) = inv_scale.transform( + [(new_xmin, new_ymin), (new_xmax, new_ymax)]) + + ax.set_xlim(new_xmin, new_xmax) + ax.set_ylim(new_ymin, new_ymax) + + ax.figure.canvas.draw_idle() + + class NonGuiException(Exception): """Raised when trying show a figure in a non-GUI backend.""" pass @@ -2653,11 +2698,14 @@ def __init__(self, canvas, num): self.key_press_handler_id = None self.button_press_handler_id = None + self.scroll_handler_id = None if rcParams['toolbar'] != 'toolmanager': self.key_press_handler_id = self.canvas.mpl_connect( 'key_press_event', key_press_handler) self.button_press_handler_id = self.canvas.mpl_connect( 'button_press_event', button_press_handler) + self.scroll_handler_id = self.canvas.mpl_connect( + 'scroll_event', scroll_handler) self.toolmanager = (ToolManager(canvas.figure) if mpl.rcParams['toolbar'] == 'toolmanager' diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index c65d39415472..7a2b28262249 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -407,6 +407,11 @@ def button_press_handler( canvas: FigureCanvasBase | None = ..., toolbar: NavigationToolbar2 | None = ..., ) -> None: ... +def scroll_handler( + event: MouseEvent, + canvas: FigureCanvasBase | None = ..., + toolbar: NavigationToolbar2 | None = ..., +) -> None: ... class NonGuiException(Exception): ... @@ -415,6 +420,7 @@ class FigureManagerBase: num: int | str key_press_handler_id: int | None button_press_handler_id: int | None + scroll_handler_id: int | None toolmanager: ToolManager | None toolbar: NavigationToolbar2 | ToolContainerBase | None def __init__(self, canvas: FigureCanvasBase, num: int | str) -> None: ... From b63a782ce2653ea0368dfc8c55e825032ac2920b Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Wed, 10 Sep 2025 21:21:15 +0800 Subject: [PATCH 088/228] feat: support x/y-axis zoom --- doc/release/next_whats_new/scroll_to_zoom.rst | 3 ++- lib/matplotlib/backend_bases.py | 21 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst index bafa312c32a5..1be522b7a255 100644 --- a/doc/release/next_whats_new/scroll_to_zoom.rst +++ b/doc/release/next_whats_new/scroll_to_zoom.rst @@ -2,7 +2,8 @@ Zooming using mouse wheel ~~~~~~~~~~~~~~~~~~~~~~~~~ ``Ctrl+MouseWheel`` can be used to zoom in the plot windows. +Additionally, ``x+MouseWheel`` zooms only the x-axis and ``y+MouseWheel`` zooms only the y-axis. -The zoom focusses on the mouse pointer, and keeps the aspect ratio of the axes. +The zoom focusses on the mouse pointer. With ``Ctrl``, the axes aspect ratio is kept; with ``x`` or ``y``, only the respective axis is scaled. Zooming is currently only supported on rectilinear Axes. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7560db80d2c1..994e41f9fdba 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2592,7 +2592,7 @@ def scroll_handler(event, canvas=None, toolbar=None): # is required for interactive navigation. return - if event.key == "control": # zoom towards the mouse position + if event.key in {"control", "x", "X", "y", "Y"}: # zoom towards the mouse position toolbar.push_current() xmin, xmax = ax.get_xlim() @@ -2604,10 +2604,21 @@ def scroll_handler(event, canvas=None, toolbar=None): x, y = ax.transScale.transform((event.xdata, event.ydata)) scale_factor = 0.85 ** event.step - new_xmin = x - (x - xmin) * scale_factor - new_xmax = x + (xmax - x) * scale_factor - new_ymin = y - (y - ymin) * scale_factor - new_ymax = y + (ymax - y) * scale_factor + # Determine which axes to scale based on key + zoom_x = event.key in {"control", "x", "X"} + zoom_y = event.key in {"control", "y", "Y"} + + if zoom_x: + new_xmin = x - (x - xmin) * scale_factor + new_xmax = x + (xmax - x) * scale_factor + else: + new_xmin, new_xmax = xmin, xmax + + if zoom_y: + new_ymin = y - (y - ymin) * scale_factor + new_ymax = y + (ymax - y) * scale_factor + else: + new_ymin, new_ymax = ymin, ymax inv_scale = ax.transScale.inverted() (new_xmin, new_ymin), (new_xmax, new_ymax) = inv_scale.transform( From b98840be02ed00f397cd73fa3b03303b5ab06d23 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 19 May 2025 14:46:20 +0200 Subject: [PATCH 089/228] Prepare for MetaFont/PK font support. TeX MetaFont (.mf) fonts (e.g., from `\usepackage{concmath}`) need to be converted to raster (.pk) fonts before usage. In dvipng or pdftex this is done by invoking mktexpk via kpsewhich. This patch ensures that our calls to kpsewhich likewise also generate and finds the raster files (in Matplotlib's own temporary cache) when needed. The resolution (600dpi) is the documented default (see e.g. the -D option in `man kpsewhich`). Note that this is only a preliminary step towards full MF/PK font support, which would also require parsing the PK file and embedding the glyphs into our final output. --- lib/matplotlib/dviread.py | 57 +++++++++++++++++++-------- lib/matplotlib/mpl-data/kpsewhich.lua | 3 +- lib/matplotlib/tests/test_dviread.py | 42 +++++++++++++++++++- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 1a0f9219a498..702543f9db26 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -32,6 +32,7 @@ import fontTools.agl import numpy as np +import matplotlib as mpl from matplotlib import _api, cbook, font_manager from matplotlib.ft2font import LoadFlags @@ -127,7 +128,13 @@ def glyph_name_or_index(self): def _as_unicode_or_name(self): if self.font.subfont: raise NotImplementedError("Indexing TTC fonts is not supported yet") - face = font_manager.get_font(self.font.resolve_path()) + path = self.font.resolve_path() + if path.name.lower().endswith("pk"): + # PK fonts have no encoding information; report glyphs as ASCII but + # with a "?" to indicate that this is just a guess. + return (f"{chr(self.glyph)}?" if chr(self.glyph).isprintable() else + f"pk{self.glyph:#02x}") + face = font_manager.get_font(path) glyph_name = face.get_glyph_name(self.index) glyph_str = fontTools.agl.toUnicode(glyph_name) return glyph_str or glyph_name @@ -758,13 +765,24 @@ def _height_depth_of(self, char): def resolve_path(self): if self._path is None: - psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - self._path = Path(psfont.filename) + fontmap = PsfontsMap(find_tex_file("pdftex.map")) + try: + psfont = fontmap[self.texname] + except LookupError as exc: + try: + find_tex_file(f"{self.texname.decode('ascii')}.mf") + except FileNotFoundError: + raise exc from None + else: + self._path = Path(find_tex_file( + f"{self.texname.decode('ascii')}.600pk")) + else: + if psfont.filename is None: + raise ValueError("No usable font file found for {} ({}); " + "the font may lack a Type-1 version" + .format(psfont.psname.decode("ascii"), + psfont.texname.decode("ascii"))) + self._path = Path(psfont.filename) return self._path @cached_property @@ -773,6 +791,8 @@ def subfont(self): @cached_property def effects(self): + if self.resolve_path().match("*.600pk"): + return {} return PsfontsMap(find_tex_file("pdftex.map"))[self.texname].effects def _index_dvi_to_freetype(self, idx): @@ -1233,9 +1253,12 @@ def __new__(cls): def _new_proc(self): return subprocess.Popen( - ["luatex", "--luaonly", - str(cbook._get_data_path("kpsewhich.lua"))], - stdin=subprocess.PIPE, stdout=subprocess.PIPE) + ["luatex", "--luaonly", str(cbook._get_data_path("kpsewhich.lua"))], + # mktexpk logs to stderr; suppress that. + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + # Store generated pk fonts in our own cache. + env={"MT_VARTEXFONTS": str(Path(mpl.get_cachedir(), "vartexfonts")), + **os.environ}) def search(self, filename): if self._proc.poll() is not None: # Dead, restart it. @@ -1287,13 +1310,16 @@ def find_tex_file(filename): kwargs = {'env': {**os.environ, 'command_line_encoding': 'utf-8'}, 'encoding': 'utf-8'} else: # On POSIX, run through the equivalent of os.fsdecode(). - kwargs = {'encoding': sys.getfilesystemencoding(), + kwargs = {'env': {**os.environ}, + 'encoding': sys.getfilesystemencoding(), 'errors': 'surrogateescape'} + kwargs['env'].update( + MT_VARTEXFONTS=str(Path(mpl.get_cachedir(), "vartexfonts"))) try: - path = (cbook._check_and_log_subprocess(['kpsewhich', filename], - _log, **kwargs) - .rstrip('\n')) + path = cbook._check_and_log_subprocess( + ['kpsewhich', '-mktex=pk', filename], _log, **kwargs, + ).rstrip('\n') except (FileNotFoundError, RuntimeError): path = None @@ -1327,7 +1353,6 @@ def _print_fields(*args): print(" ".join(map("{:>11}".format, args))) with Dvi(args.filename, args.dpi) as dvi: - fontmap = PsfontsMap(find_tex_file('pdftex.map')) for page in dvi: print(f"=== NEW PAGE === " f"(w: {page.width}, h: {page.height}, d: {page.descent})") diff --git a/lib/matplotlib/mpl-data/kpsewhich.lua b/lib/matplotlib/mpl-data/kpsewhich.lua index 8e9172a45082..dc526effeebe 100644 --- a/lib/matplotlib/mpl-data/kpsewhich.lua +++ b/lib/matplotlib/mpl-data/kpsewhich.lua @@ -1,3 +1,4 @@ -- see dviread._LuatexKpsewhich kpse.set_program_name("latex") -while true do print(kpse.lookup(io.read():gsub("\r", ""))); io.flush(); end +kpse.init_prog("", 600, "ljfour") +while true do print(kpse.lookup(io.read():gsub("\r", ""), {mktexpk=true})); io.flush(); end diff --git a/lib/matplotlib/tests/test_dviread.py b/lib/matplotlib/tests/test_dviread.py index 7aecd1fc03ae..33fe9bb150d2 100644 --- a/lib/matplotlib/tests/test_dviread.py +++ b/lib/matplotlib/tests/test_dviread.py @@ -3,7 +3,7 @@ import shutil from matplotlib import cbook, dviread as dr -from matplotlib.testing import subprocess_run_for_testing +from matplotlib.testing import subprocess_run_for_testing, _has_tex_package import pytest @@ -105,3 +105,43 @@ def test_dviread(tmp_path, engine, monkeypatch): ] correct = json.loads((dirpath / f"{engine}.json").read_text()) assert data == correct + + +@pytest.mark.skipif(shutil.which("latex") is None, reason="latex is not available") +@pytest.mark.skipif(not _has_tex_package("concmath"), reason="needs concmath.sty") +def test_dviread_pk(tmp_path): + (tmp_path / "test.tex").write_text(r""" + \documentclass{article} + \usepackage{concmath} + \pagestyle{empty} + \begin{document} + Hi! + \end{document} + """) + subprocess_run_for_testing( + ["latex", "test.tex"], cwd=tmp_path, check=True, capture_output=True) + with dr.Dvi(tmp_path / "test.dvi", None) as dvi: + pages = [*dvi] + data = [ + { + "text": [ + [ + t.x, t.y, + t._as_unicode_or_name(), + t.font.resolve_path().name, + round(t.font.size, 2), + t.font.effects, + ] for t in page.text + ], + "boxes": [[b.x, b.y, b.height, b.width] for b in page.boxes] + } for page in pages + ] + correct = [{ + 'boxes': [], + 'text': [ + [5046272, 4128768, 'H?', 'ccr10.600pk', 9.96, {}], + [5530510, 4128768, 'i?', 'ccr10.600pk', 9.96, {}], + [5716195, 4128768, '!?', 'ccr10.600pk', 9.96, {}], + ], + }] + assert data == correct From ccf199496c4089ab17d5d0aebcad8e2534836dbb Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:10:20 +0200 Subject: [PATCH 090/228] Better document super call --- src/_macosx.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/_macosx.m b/src/_macosx.m index 1dbd52bb0a14..70babd39c09c 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -572,7 +572,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) }, }; -static PyTypeObject FigureManagerType; +static PyTypeObject FigureManagerType; // forward declaration, needed in destroy() typedef struct { PyObject_HEAD @@ -689,7 +689,8 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) [self->window close]; self->window = NULL; - // call super().destroy() + // call super(self, FigureManager).destroy() - it seems we need the + // explicit arguments, and just super() doesn't work in the C API. PyObject *super_obj = PyObject_CallFunctionObjArgs( (PyObject *)&PySuper_Type, (PyObject *)&FigureManagerType, From 0e8ccea15fe9f6dd6b5e2605bb732ec89b52888c Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 22 Jan 2025 09:54:10 -0500 Subject: [PATCH 091/228] CI: remove xfail on OSX + tk due to issues in image --- lib/matplotlib/tests/test_backends_interactive.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 9725a79397bc..57d0c4ef5675 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -107,13 +107,7 @@ def _get_available_interactive_backends(): elif env["MPLBACKEND"].startswith('wx') and sys.platform == 'darwin': # ignore on macosx because that's currently broken (github #16849) marks.append(pytest.mark.xfail(reason='github #16849')) - elif (env['MPLBACKEND'] == 'tkagg' and - ('TF_BUILD' in os.environ or 'GITHUB_ACTION' in os.environ) and - sys.platform == 'darwin' and - sys.version_info[:2] < (3, 11) - ): - marks.append( # https://github.com/actions/setup-python/issues/649 - pytest.mark.xfail(reason='Tk version mismatch on Azure macOS CI')) + envs.append(({**env, 'BACKEND_DEPS': ','.join(deps)}, marks)) return envs From 623fe4d8d4818d2cbfca3c5e2cdf961f31a133ca Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 11 Sep 2025 22:34:48 +0200 Subject: [PATCH 092/228] Fix typos --- doc/release/next_whats_new/pyplot-register-figure.rst | 4 ++-- lib/matplotlib/tests/test_backends_interactive.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/release/next_whats_new/pyplot-register-figure.rst b/doc/release/next_whats_new/pyplot-register-figure.rst index c619aa428695..86ffcbf2294a 100644 --- a/doc/release/next_whats_new/pyplot-register-figure.rst +++ b/doc/release/next_whats_new/pyplot-register-figure.rst @@ -50,8 +50,8 @@ reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses the current canvas to save the figure (if possible). Since `.FigureCanvasBase` can not render the figure, when saving the figure, it will fallback to a suitable canvas subclass, e.g. `.FigureCanvasAgg` for raster outputs such as png. -Any Agg-based backend will create the same file output, however -There may be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as +Any Agg-based backend will create the same file output. However, there may be +slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as interactive backend, ``fig.savefig("file.png")`` may create a slightly different image depending on whether the figure is registered with pyplot or not. In general, you should not store a reference to the canvas, but rather always diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 18731ab3fa0b..e0763096c630 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -227,7 +227,7 @@ def check_alt_backend(alt_backend): # Ensure that the window is really closed. plt.pause(0.5) - # When the figure is closed, it's manager is removed and the canvas is reset to + # When the figure is closed, its manager is removed and the canvas is reset to # FigureCanvasBase. Saving should still be possible. assert type(fig.canvas) == FigureCanvasBase, str(fig.canvas) result_after = io.BytesIO() From f8583e08627a98464b21769df681da751dc09055 Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Fri, 12 Sep 2025 11:21:12 +0800 Subject: [PATCH 093/228] Update lib/matplotlib/backend_bases.py "X" would be generated by "Shift+x" Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 994e41f9fdba..7a58717db5a4 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2592,7 +2592,7 @@ def scroll_handler(event, canvas=None, toolbar=None): # is required for interactive navigation. return - if event.key in {"control", "x", "X", "y", "Y"}: # zoom towards the mouse position + if event.key in {"control", "x", "y"}: # zoom towards the mouse position toolbar.push_current() xmin, xmax = ax.get_xlim() From 0a744f0ba180dca0a73bf351db89796a9854c7d6 Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Fri, 12 Sep 2025 11:21:26 +0800 Subject: [PATCH 094/228] Update lib/matplotlib/backend_bases.py "X" would be generated by "Shift+x" Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/backend_bases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7a58717db5a4..07a48a768de0 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2605,8 +2605,8 @@ def scroll_handler(event, canvas=None, toolbar=None): scale_factor = 0.85 ** event.step # Determine which axes to scale based on key - zoom_x = event.key in {"control", "x", "X"} - zoom_y = event.key in {"control", "y", "Y"} + zoom_x = event.key in {"control", "x"} + zoom_y = event.key in {"control", "y"} if zoom_x: new_xmin = x - (x - xmin) * scale_factor From 4dcf15212467336482aa696cfc019d2ef73a6ec5 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:36:20 +0200 Subject: [PATCH 095/228] Fix stubtest with mypy 18 Closes #30551 by disabling checks for disjoint bases. There may be other solutions like decorating Container and dviread.Text with @disjoint_base, however I've not yet fully understood whether that'd be technically correct. We have survived without disjoint bases checks so far, so at least a a quick fix this should good enough. --- tools/stubtest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/stubtest.py b/tools/stubtest.py index b79ab2f40dd0..d73d966de19e 100644 --- a/tools/stubtest.py +++ b/tools/stubtest.py @@ -108,6 +108,7 @@ def visit_ClassDef(self, node): [ "stubtest", "--mypy-config-file=pyproject.toml", + "--ignore-disjoint-bases", "--allowlist=ci/mypy-stubtest-allowlist.txt", f"--allowlist={p}", "matplotlib", From 22c3bc266cd33f6eb593d50ad0c90092d6a1f897 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:02:30 +0000 Subject: [PATCH 096/228] Bump github/codeql-action from 3.30.1 to 3.30.3 in the actions group Bumps the actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 3.30.1 to 3.30.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f1f6e5f6af878fb37288ce1c627459e94dbf7d01...192325c86100d080feab897ff886c34abd4c83a3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 3.30.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 07c9c1701cf1..e5c31400d72d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 + uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 with: languages: ${{ matrix.language }} @@ -43,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 + uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 From f05ecf978533c2a95ec74d0bcd42ecc8a9f056c6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 23 Jun 2025 20:13:36 -0400 Subject: [PATCH 097/228] Make path-to-string conversion a little safer ... by replacing double pointers by fixed-size `std::array`, or a return `tuple`. With gcc (and optimization enabled?), this has no effect on code size, but gives compile-time (and better runtime) checks that there are no out-of-bounds access. --- src/_path.h | 52 +++++++++++++++++++++---------------------- src/_path_wrapper.cpp | 7 +----- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/_path.h b/src/_path.h index c03703776760..6f315f2109e1 100644 --- a/src/_path.h +++ b/src/_path.h @@ -3,12 +3,12 @@ #ifndef MPL_PATH_H #define MPL_PATH_H -#include -#include -#include -#include #include +#include +#include +#include #include +#include #include "agg_conv_contour.h" #include "agg_conv_curve.h" @@ -1051,15 +1051,14 @@ void cleanup_path(PathIterator &path, void quad2cubic(double x0, double y0, double x1, double y1, double x2, double y2, - double *outx, double *outy) + std::array &outx, std::array &outy) { - - outx[0] = x0 + 2./3. * (x1 - x0); - outy[0] = y0 + 2./3. * (y1 - y0); - outx[1] = outx[0] + 1./3. * (x2 - x0); - outy[1] = outy[0] + 1./3. * (y2 - y0); - outx[2] = x2; - outy[2] = y2; + std::get<0>(outx) = x0 + 2./3. * (x1 - x0); + std::get<0>(outy) = y0 + 2./3. * (y1 - y0); + std::get<1>(outx) = std::get<0>(outx) + 1./3. * (x2 - x0); + std::get<1>(outy) = std::get<0>(outy) + 1./3. * (y2 - y0); + std::get<2>(outx) = x2; + std::get<2>(outy) = y2; } @@ -1104,27 +1103,27 @@ void __add_number(double val, char format_code, int precision, template bool __convert_to_string(PathIterator &path, int precision, - char **codes, + const std::array &codes, bool postfix, std::string& buffer) { const char format_code = 'f'; - double x[3]; - double y[3]; + std::array x; + std::array y; double last_x = 0.0; double last_y = 0.0; unsigned code; - while ((code = path.vertex(&x[0], &y[0])) != agg::path_cmd_stop) { + while ((code = path.vertex(&std::get<0>(x), &std::get<0>(y))) != agg::path_cmd_stop) { if (code == CLOSEPOLY) { - buffer += codes[4]; + buffer += std::get<4>(codes); } else if (code < 5) { size_t size = NUM_VERTICES[code]; for (size_t i = 1; i < size; ++i) { - unsigned subcode = path.vertex(&x[i], &y[i]); + unsigned subcode = path.vertex(&x.at(i), &y.at(i)); if (subcode != code) { return false; } @@ -1133,29 +1132,29 @@ bool __convert_to_string(PathIterator &path, /* For formats that don't support quad curves, convert to cubic curves */ if (code == CURVE3 && codes[code - 1][0] == '\0') { - quad2cubic(last_x, last_y, x[0], y[0], x[1], y[1], x, y); + quad2cubic(last_x, last_y, x.at(0), y.at(0), x.at(1), y.at(1), x, y); code++; size = 3; } if (!postfix) { - buffer += codes[code - 1]; + buffer += codes.at(code - 1); buffer += ' '; } for (size_t i = 0; i < size; ++i) { - __add_number(x[i], format_code, precision, buffer); + __add_number(x.at(i), format_code, precision, buffer); buffer += ' '; - __add_number(y[i], format_code, precision, buffer); + __add_number(y.at(i), format_code, precision, buffer); buffer += ' '; } if (postfix) { - buffer += codes[code - 1]; + buffer += codes.at(code - 1); } - last_x = x[size - 1]; - last_y = y[size - 1]; + last_x = x.at(size - 1); + last_y = y.at(size - 1); } else { // Unknown code value return false; @@ -1174,7 +1173,7 @@ bool convert_to_string(PathIterator &path, bool simplify, SketchParams sketch_params, int precision, - char **codes, + const std::array &codes, bool postfix, std::string& buffer) { @@ -1211,7 +1210,6 @@ bool convert_to_string(PathIterator &path, sketch_t sketch(curve, sketch_params.scale, sketch_params.length, sketch_params.randomness); return __convert_to_string(sketch, precision, codes, postfix, buffer); } - } template diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index 2a297e49ac92..6375351cb52e 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -252,16 +252,11 @@ static py::object Py_convert_to_string(mpl::PathIterator path, agg::trans_affine trans, agg::rect_d cliprect, std::optional simplify, SketchParams sketch, int precision, - std::array codes_obj, bool postfix) + const std::array &codes, bool postfix) { - char *codes[5]; std::string buffer; bool status; - for (auto i = 0; i < 5; ++i) { - codes[i] = const_cast(codes_obj[i].c_str()); - } - if (!simplify.has_value()) { simplify = path.should_simplify(); } From 3373c9696a29cdb3a2fd1b11ae0c847329a098f6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Sep 2025 03:58:15 -0400 Subject: [PATCH 098/228] MNT: Fix type of _finalize_polygon::closed_only It is `bool` for the Python wrapper, while internally `int`, but can be `bool` consistently. Also mark it as `inline` since it's used in a template and the compiler warns about a possible ODR violation (which isn't a problem since it's only used in one file.) --- src/_path.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/_path.h b/src/_path.h index 6f315f2109e1..dc9058535f5f 100644 --- a/src/_path.h +++ b/src/_path.h @@ -43,7 +43,8 @@ struct XY typedef std::vector Polygon; -void _finalize_polygon(std::vector &result, int closed_only) +inline void +_finalize_polygon(std::vector &result, bool closed_only) { if (result.size() == 0) { return; @@ -691,12 +692,12 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto // Empty polygons aren't very useful, so skip them if (polygon1.size()) { - _finalize_polygon(results, 1); + _finalize_polygon(results, true); results.push_back(polygon1); } } while (code != agg::path_cmd_stop); - _finalize_polygon(results, 1); + _finalize_polygon(results, true); } template @@ -956,7 +957,7 @@ void convert_path_to_polygons(PathIterator &path, agg::trans_affine &trans, double width, double height, - int closed_only, + bool closed_only, std::vector &result) { typedef agg::conv_transform transformed_path_t; @@ -980,7 +981,7 @@ void convert_path_to_polygons(PathIterator &path, while ((code = curve.vertex(&x, &y)) != agg::path_cmd_stop) { if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) { - _finalize_polygon(result, 1); + _finalize_polygon(result, true); polygon = &result.emplace_back(); } else { if (code == agg::path_cmd_move_to) { From 56b9bffc312d8e0b0df2ec3be3fc6bb601e0759d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Sep 2025 04:56:54 -0400 Subject: [PATCH 099/228] path: Simplify extent_limits implementation a bit By using the existing `XY` type to replace x/y pairs, and taking advantage of struct methods. --- src/_path.h | 70 ++++++++++++++++++++----------------------- src/_path_wrapper.cpp | 12 ++++---- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/_path.h b/src/_path.h index dc9058535f5f..de6749e21995 100644 --- a/src/_path.h +++ b/src/_path.h @@ -312,43 +312,39 @@ inline bool point_on_path( struct extent_limits { - double x0; - double y0; - double x1; - double y1; - double xm; - double ym; -}; + XY start; + XY end; + /* minpos is the minimum positive values in the data; used by log scaling. */ + XY minpos; -void reset_limits(extent_limits &e) -{ - e.x0 = std::numeric_limits::infinity(); - e.y0 = std::numeric_limits::infinity(); - e.x1 = -std::numeric_limits::infinity(); - e.y1 = -std::numeric_limits::infinity(); - /* xm and ym are the minimum positive values in the data, used - by log scaling */ - e.xm = std::numeric_limits::infinity(); - e.ym = std::numeric_limits::infinity(); -} + extent_limits() : start{0,0}, end{0,0}, minpos{0,0} { + reset(); + } -inline void update_limits(double x, double y, extent_limits &e) -{ - if (x < e.x0) - e.x0 = x; - if (y < e.y0) - e.y0 = y; - if (x > e.x1) - e.x1 = x; - if (y > e.y1) - e.y1 = y; - /* xm and ym are the minimum positive values in the data, used - by log scaling */ - if (x > 0.0 && x < e.xm) - e.xm = x; - if (y > 0.0 && y < e.ym) - e.ym = y; -} + void reset() + { + start.x = std::numeric_limits::infinity(); + start.y = std::numeric_limits::infinity(); + end.x = -std::numeric_limits::infinity(); + end.y = -std::numeric_limits::infinity(); + minpos.x = std::numeric_limits::infinity(); + minpos.y = std::numeric_limits::infinity(); + } + + void update(double x, double y) + { + start.x = std::min(start.x, x); + start.y = std::min(start.y, y); + end.x = std::max(end.x, x); + end.y = std::max(end.y, y); + if (x > 0.0) { + minpos.x = std::min(minpos.x, x); + } + if (y > 0.0) { + minpos.y = std::min(minpos.y, y); + } + } +}; template void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_limits &extents) @@ -367,7 +363,7 @@ void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_li if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) { continue; } - update_limits(x, y, extents); + extents.update(x, y); } } @@ -390,7 +386,7 @@ void get_path_collection_extents(agg::trans_affine &master_transform, agg::trans_affine trans; - reset_limits(extent); + extent.reset(); for (auto i = 0; i < N; ++i) { typename PathGenerator::path_iterator path(paths(i % Npaths)); diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index 6375351cb52e..fd80b02b85ae 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -68,15 +68,15 @@ Py_get_path_collection_extents(agg::trans_affine master_transform, py::ssize_t dims[] = { 2, 2 }; py::array_t extents(dims); - *extents.mutable_data(0, 0) = e.x0; - *extents.mutable_data(0, 1) = e.y0; - *extents.mutable_data(1, 0) = e.x1; - *extents.mutable_data(1, 1) = e.y1; + *extents.mutable_data(0, 0) = e.start.x; + *extents.mutable_data(0, 1) = e.start.y; + *extents.mutable_data(1, 0) = e.end.x; + *extents.mutable_data(1, 1) = e.end.y; py::ssize_t minposdims[] = { 2 }; py::array_t minpos(minposdims); - *minpos.mutable_data(0) = e.xm; - *minpos.mutable_data(1) = e.ym; + *minpos.mutable_data(0) = e.minpos.x; + *minpos.mutable_data(1) = e.minpos.y; return py::make_tuple(extents, minpos); } From f2a9dac0b4b5783ea6053889847299eacd1b924a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Sep 2025 05:15:31 -0400 Subject: [PATCH 100/228] path: Simplify parts of clip_path_to_rect Use `XY` type to shorten internals, and `agg::rect_d::normalize` to shorten initialization. --- src/_path.h | 93 ++++++++++++++++++++----------------------- src/_path_wrapper.cpp | 4 +- 2 files changed, 44 insertions(+), 53 deletions(-) diff --git a/src/_path.h b/src/_path.h index de6749e21995..226d60231682 100644 --- a/src/_path.h +++ b/src/_path.h @@ -26,6 +26,8 @@ struct XY double x; double y; + XY() : x(0), y(0) {} + XY(double x_, double y_) : x(x_), y(y_) { } @@ -521,12 +523,14 @@ struct bisectx { } - inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const + inline XY bisect(const XY s, const XY p) const { - *bx = m_x; - double dx = px - sx; - double dy = py - sy; - *by = sy + dy * ((m_x - sx) / dx); + double dx = p.x - s.x; + double dy = p.y - s.y; + return { + m_x, + s.y + dy * ((m_x - s.x) / dx), + }; } }; @@ -536,9 +540,9 @@ struct xlt : public bisectx { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return x <= m_x; + return point.x <= m_x; } }; @@ -548,9 +552,9 @@ struct xgt : public bisectx { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return x >= m_x; + return point.x >= m_x; } }; @@ -562,12 +566,14 @@ struct bisecty { } - inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const + inline XY bisect(const XY s, const XY p) const { - *by = m_y; - double dx = px - sx; - double dy = py - sy; - *bx = sx + dx * ((m_y - sy) / dy); + double dx = p.x - s.x; + double dy = p.y - s.y; + return { + s.x + dx * ((m_y - s.y) / dy), + m_y, + }; } }; @@ -577,9 +583,9 @@ struct ylt : public bisecty { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return y <= m_y; + return point.y <= m_y; } }; @@ -589,9 +595,9 @@ struct ygt : public bisecty { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return y >= m_y; + return point.y >= m_y; } }; } @@ -606,46 +612,30 @@ inline void clip_to_rect_one_step(const Polygon &polygon, Polygon &result, const return; } - auto [sx, sy] = polygon.back(); - for (auto [px, py] : polygon) { - sinside = filter.is_inside(sx, sy); - pinside = filter.is_inside(px, py); + auto s = polygon.back(); + for (auto p : polygon) { + sinside = filter.is_inside(s); + pinside = filter.is_inside(p); if (sinside ^ pinside) { - double bx, by; - filter.bisect(sx, sy, px, py, &bx, &by); - result.emplace_back(bx, by); + result.emplace_back(filter.bisect(s, p)); } if (pinside) { - result.emplace_back(px, py); + result.emplace_back(p); } - sx = px; - sy = py; + s = p; } } template -void -clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vector &results) +auto +clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside) { - double xmin, ymin, xmax, ymax; - if (rect.x1 < rect.x2) { - xmin = rect.x1; - xmax = rect.x2; - } else { - xmin = rect.x2; - xmax = rect.x1; - } - - if (rect.y1 < rect.y2) { - ymin = rect.y1; - ymax = rect.y2; - } else { - ymin = rect.y2; - ymax = rect.y1; - } + rect.normalize(); + auto xmin = rect.x1, xmax = rect.x2; + auto ymin = rect.y1, ymax = rect.y2; if (!inside) { std::swap(xmin, xmax); @@ -656,26 +646,27 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto curve_t curve(path); Polygon polygon1, polygon2; - double x = 0, y = 0; + XY point; unsigned code = 0; curve.rewind(0); + std::vector results; do { // Grab the next subpath and store it in polygon1 polygon1.clear(); do { if (code == agg::path_cmd_move_to) { - polygon1.emplace_back(x, y); + polygon1.emplace_back(point); } - code = curve.vertex(&x, &y); + code = curve.vertex(&point.x, &point.y); if (code == agg::path_cmd_stop) { break; } if (code != agg::path_cmd_move_to) { - polygon1.emplace_back(x, y); + polygon1.emplace_back(point); } } while ((code & agg::path_cmd_end_poly) != agg::path_cmd_end_poly); @@ -694,6 +685,8 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto } while (code != agg::path_cmd_stop); _finalize_polygon(results, true); + + return results; } template diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index fd80b02b85ae..802189c428d3 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -109,9 +109,7 @@ Py_path_in_path(mpl::PathIterator a, agg::trans_affine atrans, static py::list Py_clip_path_to_rect(mpl::PathIterator path, agg::rect_d rect, bool inside) { - std::vector result; - - clip_path_to_rect(path, rect, inside, result); + auto result = clip_path_to_rect(path, rect, inside); return convert_polygon_vector(result); } From e52f78a2067ff78ef79507add85353e76e819ccb Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 16 Sep 2025 12:41:48 -0400 Subject: [PATCH 101/228] Backport PR #30558: Fix stubtest with mypy 18 --- tools/stubtest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/stubtest.py b/tools/stubtest.py index b79ab2f40dd0..d73d966de19e 100644 --- a/tools/stubtest.py +++ b/tools/stubtest.py @@ -108,6 +108,7 @@ def visit_ClassDef(self, node): [ "stubtest", "--mypy-config-file=pyproject.toml", + "--ignore-disjoint-bases", "--allowlist=ci/mypy-stubtest-allowlist.txt", f"--allowlist={p}", "matplotlib", From 31f6806e7b819c552fec9c460f67a9ac66f05211 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 16 Sep 2025 13:46:18 -0400 Subject: [PATCH 102/228] CI: remove macos13 The VM is being dropped by GH [1] on macOS is no longer supported by homebrew or Apple. This job is starting to fail on installing homebrew dependencies. Rather than try to fix this, move on to the next image which we need to do by the end of the CY anyway. This means we no longer are testing on intel macs. [1] https://github.blog/changelog/2025-07-11-upcoming-changes-to-macos-hosted-runners-macos-latest-migration-and-xcode-support-policy-updates/#macos-13-is-closing-down --- .github/workflows/tests.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c6f1e25c1e92..13b09cc4d6ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,8 +77,7 @@ jobs: pygobject-ver: '<3.52.0' - os: ubuntu-24.04 python-version: '3.12' - - os: macos-13 # This runner is on Intel chips. - # merge numpy and pandas install in nighties test when this runner is dropped + - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.11' - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.12' @@ -297,13 +296,7 @@ jobs: python -m pip install pytz tzdata # Must be installed for Pandas. python -m pip install \ --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ - --upgrade --only-binary=:all: numpy - # wheels for intel osx is not always available on nightly wheels index, merge this back into - # the above install command when the OSX-13 (intel) runners are dropped. - python -m pip install \ - --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ - --upgrade --only-binary=:all: pandas || true - + --upgrade --only-binary=:all: numpy pandas - name: Install Matplotlib run: | From c85328ae37e78678f639f12d014afa3d1b3500ae Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:16:03 +0200 Subject: [PATCH 103/228] DOC: improve description of boilerplate.py Closes #27190. --- lib/matplotlib/pyplot.py | 8 ++++++-- tools/boilerplate.py | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 4531cbd3b261..aaa04d464f30 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -1,5 +1,9 @@ -# Note: The first part of this file can be modified in place, but the latter -# part is autogenerated by the boilerplate.py script. +# Note: The first part of this file is hand-written and must be edited +# in-place. The second part, starting with +# ### REMAINING CONTENT GENERATED BY boilerplate.py ### +# is generated by the script boilerplate.py. It must not be edited here +# because all changes will be overwritten by the next run of the script. +# For more information see the description in boilerplate.py. """ `matplotlib.pyplot` is a state-based interface to matplotlib. It provides diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 11ec15ac1c44..a617d12c7072 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -1,12 +1,19 @@ """ Script to autogenerate pyplot wrappers. -When this script is run, the current contents of pyplot are -split into generatable and non-generatable content (via the magic header -:attr:`PYPLOT_MAGIC_HEADER`) and the generatable content is overwritten. -Hence, the non-generatable content should be edited in the pyplot.py file -itself, whereas the generatable content must be edited via templates in -this file. +pyplot.py consists of two parts: a hand-written part at the top, and an +automatically generated part at the bottom, starting with the comment + + ### REMAINING CONTENT GENERATED BY boilerplate.py ### + +This script generates the automatically generated part of pyplot.py. It +consists of colormap setter functions and wrapper functions for methods +of Figure and Axes. Whenever the API of one of the wrapped methods changes, +this script has to be rerun to keep pyplot.py up to date. + +The test ``lib/matplotlib/test_pyplot.py::test_pyplot_up_to_date`` checks +that the autogenerated part of pyplot.py is up to date. It will fail in the +case of an API mismatch and remind the developer to rerun this script. """ # Although it is possible to dynamically generate the pyplot functions at From b65c17e58b322247e9318b16fdcc9628292b78b9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 16 Sep 2025 17:03:16 -0400 Subject: [PATCH 104/228] CI: move 3.13 job to macos-15 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13b09cc4d6ee..aeb84468571f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,7 +83,7 @@ jobs: python-version: '3.12' # https://github.com/matplotlib/matplotlib/issues/29732 pygobject-ver: '<3.52.0' - - os: macos-14 # This runner is on M1 (arm64) chips. + - os: macos-15 # This runner is on M1 (arm64) chips. python-version: '3.13' # https://github.com/matplotlib/matplotlib/issues/29732 pygobject-ver: '<3.52.0' From c18a1656eb49de5eee99c55f475022fe8e7f60a2 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 16 Sep 2025 21:33:28 -0400 Subject: [PATCH 105/228] CI: pin back pygobject --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aeb84468571f..6da9c7a0a8fa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,6 +79,8 @@ jobs: python-version: '3.12' - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.11' + # https://github.com/matplotlib/matplotlib/issues/29732 + pygobject-ver: '<3.52.0' - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.12' # https://github.com/matplotlib/matplotlib/issues/29732 From d2ab525b557a29bf93a576466fed94fe44785e7f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 16 Sep 2025 23:56:14 -0400 Subject: [PATCH 106/228] Backport PR #30571: CI: remove macos13 --- .github/workflows/tests.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 282b015fcbf1..ea402d954137 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,8 +84,10 @@ jobs: pyqt6-ver: '!=6.6.0' # https://bugreports.qt.io/projects/PYSIDE/issues/PYSIDE-2346 pyside6-ver: '!=6.5.1' - - os: macos-13 # This runner is on Intel chips. + - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.10' + # https://github.com/matplotlib/matplotlib/issues/29732 + pygobject-ver: '<3.52.0' # https://bugreports.qt.io/projects/PYSIDE/issues/PYSIDE-2346 pyside6-ver: '!=6.5.1' - os: macos-14 # This runner is on M1 (arm64) chips. @@ -94,7 +96,7 @@ jobs: pygobject-ver: '<3.52.0' # https://bugreports.qt.io/projects/PYSIDE/issues/PYSIDE-2346 pyside6-ver: '!=6.5.1' - - os: macos-14 # This runner is on M1 (arm64) chips. + - os: macos-15 # This runner is on M1 (arm64) chips. python-version: '3.13' # https://github.com/matplotlib/matplotlib/issues/29732 pygobject-ver: '<3.52.0' From ec72aa03df308f4695220b509d119ff96dab4724 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 16 Sep 2025 10:50:57 +0200 Subject: [PATCH 107/228] Copy-edit the "fonts in pdf and postscript" table. Remove unnecessary info snippets e.g. re: fractal rendering (this isn't a table for font designers); merge Type 42 entry together with TrueType (they are essentially the same technology). --- galleries/users_explain/text/fonts.py | 62 ++++++++++----------------- 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/galleries/users_explain/text/fonts.py b/galleries/users_explain/text/fonts.py index 067ed2f3932a..40cc9eaa93eb 100644 --- a/galleries/users_explain/text/fonts.py +++ b/galleries/users_explain/text/fonts.py @@ -27,28 +27,24 @@ Matplotlib supports three font specifications (in addition to pdf 'core fonts', which are explained later in the guide): -.. table:: Type of Fonts - - +--------------------------+----------------------------+----------------------------+ - | Type 1 (PDF with usetex) | Type 3 (PDF/PS) | TrueType (PDF) | - +==========================+============================+============================+ - | One of the oldest types, | Similar to Type 1 in | Newer than previous types, | - | introduced by Adobe | terms of introduction | used commonly today, | - | | | introduced by Apple | - +--------------------------+----------------------------+----------------------------+ - | Restricted subset of | Full PostScript language, | Includes a virtual machine | - | PostScript, charstrings | allows embedding arbitrary | that can execute code! | - | are in bytecode | code (in theory, even | | - | | render fractals when | | - | | rasterizing!) | | - +--------------------------+----------------------------+----------------------------+ - | Supports font | Does not support font | Supports font hinting | - | hinting | hinting | (virtual machine processes | - | | | the "hints") | - +--------------------------+----------------------------+----------------------------+ - | Subsetted by code in | Subsetted via external module | - | `matplotlib._type1font` | `fontTools `__ | - +--------------------------+----------------------------+----------------------------+ +.. table:: Types of Fonts + + +--------------------------+----------------------------+-------------------------------+ + | Type 1 (PDF with usetex) | Type 3 (PDF/PS) | TrueType (PDF) / Type 42 (PS) | + +==========================+============================+===============================+ + | Old font types introduced by Adobe. | Newer font type introduced by | + | | Apple; commonly used today. | + +--------------------------+----------------------------+-------------------------------+ + | Restricted subset of | Full PostScript language, | Includes a virtual machine | + | PostScript, charstrings | allows embedding arbitrary | that can execute code. | + | are in bytecode. | code. | | + +--------------------------+----------------------------+-------------------------------+ + | Supports font hinting. | Does not support font | Supports font hinting, | + | | hinting. | through the virtual machine. | + +--------------------------+----------------------------+-------------------------------+ + | Subsetted by code in | Subsetted via external module | + | `matplotlib._type1font`. | `fontTools `__. | + +--------------------------+----------------------------+-------------------------------+ .. note:: @@ -59,23 +55,9 @@ __ https://helpx.adobe.com/fonts/kb/postscript-type-1-fonts-end-of-support.html -Other font specifications which Matplotlib supports: - -- Type 42 fonts (PS): - - - PostScript wrapper around TrueType fonts - - 42 is the `Answer to Life, the Universe, and Everything! - `_ - - Matplotlib uses the external library - `fontTools `__ to subset these types of - fonts - -- OpenType fonts: - - - OpenType is a new standard for digital type fonts, developed jointly by - Adobe and Microsoft - - Generally contain a much larger character set! - - Limited support with Matplotlib +Matplotlib also provides limited support for OpenType fonts, a newer standard +developed jointly by Adobe and Microsoft; such fonts generally contain a much +larger character set. Font subsetting ^^^^^^^^^^^^^^^ @@ -201,4 +183,4 @@ A majority of this work was done by Aitik Gupta supported by Google Summer of Code 2021. -""" +""" # noqa: E501 From 554b9c5bf75602c8186b67378b8f66827dd9c8fa Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:20:28 +0200 Subject: [PATCH 108/228] MNT: Move all Colormap extremes setter logic into a single _set_extremes() (#30577) --- lib/matplotlib/colors.py | 43 ++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 679f368bae30..8cacea8fbb05 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -877,9 +877,7 @@ def get_bad(self): def set_bad(self, color='k', alpha=None): """Set the color for masked values.""" - self._rgba_bad = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(bad=(color, alpha)) def get_under(self): """Get the color for low out-of-range values.""" @@ -889,9 +887,7 @@ def get_under(self): def set_under(self, color='k', alpha=None): """Set the color for low out-of-range values.""" - self._rgba_under = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(under=(color, alpha)) def get_over(self): """Get the color for high out-of-range values.""" @@ -901,21 +897,14 @@ def get_over(self): def set_over(self, color='k', alpha=None): """Set the color for high out-of-range values.""" - self._rgba_over = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(over=(color, alpha)) def set_extremes(self, *, bad=None, under=None, over=None): """ Set the colors for masked (*bad*) values and, when ``norm.clip = False``, low (*under*) and high (*over*) out-of-range values. """ - if bad is not None: - self.set_bad(bad) - if under is not None: - self.set_under(under) - if over is not None: - self.set_over(over) + self._set_extremes(bad=bad, under=under, over=over) def with_extremes(self, *, bad=None, under=None, over=None): """ @@ -924,10 +913,26 @@ def with_extremes(self, *, bad=None, under=None, over=None): out-of-range values, have been set accordingly. """ new_cm = self.copy() - new_cm.set_extremes(bad=bad, under=under, over=over) + new_cm._set_extremes(bad=bad, under=under, over=over) return new_cm - def _set_extremes(self): + def _set_extremes(self, bad=None, under=None, over=None): + """ + Set the colors for masked (*bad*) and out-of-range (*under* and *over*) values. + + Parameters that are None are left unchanged. + """ + if bad is not None: + self._rgba_bad = to_rgba(bad) + if under is not None: + self._rgba_under = to_rgba(under) + if over is not None: + self._rgba_over = to_rgba(over) + if self._isinit: + self._update_lut_extremes() + + def _update_lut_extremes(self): + """Ensure than an existing lookup table has the correct extreme values.""" if self._rgba_under: self._lut[self._i_under] = self._rgba_under else: @@ -1154,7 +1159,7 @@ def _init(self): self._lut[:-3, 3] = _create_lookup_table( self.N, self._segmentdata['alpha'], 1) self._isinit = True - self._set_extremes() + self._update_lut_extremes() def set_gamma(self, gamma): """Set a new gamma value and regenerate colormap.""" @@ -1346,7 +1351,7 @@ def _init(self): self._lut = np.zeros((self.N + 3, 4), float) self._lut[:-3] = to_rgba_array(self.colors) self._isinit = True - self._set_extremes() + self._update_lut_extremes() @property def monochrome(self): From 4a20d0fbe2c8af0478246ae716d506e12a6112a0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 18 Sep 2025 02:02:04 -0400 Subject: [PATCH 109/228] TST: Force Agg backend in test_openin_any_paranoid On some CI, the Qt backend seems to be picked, but something is confused and it fails due to an invalid `DISPLAY` variable. But this test doesn't need an interactive backend, so don't use one. --- lib/matplotlib/tests/test_texmanager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_texmanager.py b/lib/matplotlib/tests/test_texmanager.py index 64dcbf46456d..7e7b5632c7ac 100644 --- a/lib/matplotlib/tests/test_texmanager.py +++ b/lib/matplotlib/tests/test_texmanager.py @@ -70,6 +70,7 @@ def test_openin_any_paranoid(): 'import matplotlib.pyplot as plt;' 'plt.rcParams.update({"text.usetex": True});' 'plt.title("paranoid");' - 'plt.show(block=False);'], - env={**os.environ, 'openin_any': 'p'}, check=True, capture_output=True) + 'plt.gcf().canvas.draw();'], + env={**os.environ, 'MPLBACKEND': 'Agg', 'openin_any': 'p'}, + check=True, capture_output=True) assert completed.stderr == "" From a159804392c961d8be78bc27cd171feb18c3bdaf Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:37:37 +0200 Subject: [PATCH 110/228] MNT: Do not use colormap setters in tests Instead use the more modern ways to specify extremes during Colormap creation or using `with_extremes()` This is in preparation of deprecating setters in #30531. --- lib/matplotlib/contour.py | 13 +++++------ lib/matplotlib/tests/test_axes.py | 3 +-- lib/matplotlib/tests/test_colorbar.py | 7 +++--- lib/matplotlib/tests/test_colors.py | 22 ++++++------------- lib/matplotlib/tests/test_contour.py | 3 +-- lib/matplotlib/tests/test_image.py | 11 +++------- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 11 ++-------- .../mplot3d/tests/test_legend3d.py | 3 +-- 8 files changed, 25 insertions(+), 48 deletions(-) diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 1125b9c5fbc6..7fd0d3100889 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -735,13 +735,12 @@ def __init__(self, ax, *args, i0 = 1 cmap = mcolors.ListedColormap( - cbook._resize_sequence(color_sequence[i0:], ncolors)) - - if use_set_under_over: - if self._extend_min: - cmap.set_under(color_sequence[0]) - if self._extend_max: - cmap.set_over(color_sequence[-1]) + cbook._resize_sequence(color_sequence[i0:], ncolors), + under=(color_sequence[0] + if use_set_under_over and self._extend_min else None), + over=(color_sequence[-1] + if use_set_under_over and self._extend_max else None), + ) # label lists must be initialized here self.labelTexts = [] diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 6679f0a0055b..bdb0f16c6845 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2982,8 +2982,7 @@ def test_scatter_invalid_color(self, fig_test, fig_ref): def test_scatter_no_invalid_color(self, fig_test, fig_ref): # With plotnonfinite=False we plot only 2 points. ax = fig_test.subplots() - cmap = mpl.colormaps["viridis"].resampled(16) - cmap.set_bad("k", 1) + cmap = mpl.colormaps["viridis"].resampled(16).with_extremes(bad="k") ax.scatter(range(4), range(4), c=[1, np.nan, 2, np.nan], s=[1, 2, 3, 4], cmap=cmap, plotnonfinite=False) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index ba20f325f4d7..614c7ae5c20c 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -946,9 +946,10 @@ def test_proportional_colorbars(): levels = [-1.25, -0.5, -0.125, 0.125, 0.5, 1.25] cmap = mcolors.ListedColormap( - ['0.3', '0.5', 'white', 'lightblue', 'steelblue']) - cmap.set_under('darkred') - cmap.set_over('crimson') + ['0.3', '0.5', 'white', 'lightblue', 'steelblue'], + under='darkred', + over='crimson', + ) norm = mcolors.BoundaryNorm(levels, cmap.N) extends = ['neither', 'both'] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 42f364848b66..3ef19f681c1e 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -53,13 +53,9 @@ def test_resampled(): colorlist[:, 1] = 0.2 colorlist[:, 2] = np.linspace(1, 0, n) colorlist[:, 3] = 0.7 - lsc = mcolors.LinearSegmentedColormap.from_list('lsc', colorlist) - lc = mcolors.ListedColormap(colorlist) - # Set some bad values for testing too - for cmap in [lsc, lc]: - cmap.set_under('r') - cmap.set_over('g') - cmap.set_bad('b') + lsc = mcolors.LinearSegmentedColormap.from_list( + 'lsc', colorlist, under='red', over='green', bad='blue') + lc = mcolors.ListedColormap(colorlist, under='red', over='green', bad='blue') lsc3 = lsc.resampled(3) lc3 = lc.resampled(3) expected = np.array([[0.0, 0.2, 1.0, 0.7], @@ -371,9 +367,7 @@ def test_BoundaryNorm(): assert_array_equal(mynorm(x), ref) # Without interpolation - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_over('black') - cmref.set_under('white') + cmref = mcolors.ListedColormap(['blue', 'red'], under='white', over='black') cmshould = mcolors.ListedColormap(['white', 'blue', 'red', 'black']) assert mcolors.same_color(cmref.get_over(), 'black') @@ -395,8 +389,7 @@ def test_BoundaryNorm(): assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x))) # Just min - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_under('white') + cmref = mcolors.ListedColormap(['blue', 'red'], under='white') cmshould = mcolors.ListedColormap(['white', 'blue', 'red']) assert mcolors.same_color(cmref.get_under(), 'white') @@ -413,8 +406,7 @@ def test_BoundaryNorm(): assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x))) # Just max - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_over('black') + cmref = mcolors.ListedColormap(['blue', 'red'], over='black') cmshould = mcolors.ListedColormap(['blue', 'red', 'black']) assert mcolors.same_color(cmref.get_over(), 'black') @@ -928,7 +920,7 @@ def test_cmap_and_norm_from_levels_and_colors2(): for extend, i1, cases in tests: cmap, norm = mcolors.from_levels_and_colors(levels, colors[0:i1], extend=extend) - cmap.set_bad(bad) + cmap = cmap.with_extremes(bad=bad) for d_val, expected_color in cases.items(): if d_val == masked_value: d_val = np.ma.array([1], mask=True) diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 89c1c0a6cc96..87e2d0ce182e 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -616,8 +616,7 @@ def test_contourf_legend_elements(): cs = plt.contourf(h, levels=[10, 30, 50], colors=['#FFFF00', '#FF00FF', '#00FFFF'], extend='both') - cs.cmap.set_over('red') - cs.cmap.set_under('blue') + cs.cmap = cs.cmap.with_extremes(over='red', under='blue') cs.changed() artists, labels = cs.legend_elements() assert labels == ['$x \\leq -1e+250s$', diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 330a2fab503d..5c4622c5bb29 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -1,5 +1,4 @@ from contextlib import ExitStack -from copy import copy import functools import io import os @@ -1194,8 +1193,7 @@ def test_image_array_alpha_validation(): @mpl.style.context('mpl20') def test_exact_vmin(): - cmap = copy(mpl.colormaps["autumn_r"]) - cmap.set_under(color="lightgrey") + cmap = mpl.colormaps["autumn_r"].with_extremes(under="lightgrey") # make the image exactly 190 pixels wide fig = plt.figure(figsize=(1.9, 0.1), dpi=100) @@ -1469,9 +1467,7 @@ def test_rgba_antialias(): aa[70:90, 195:215] = 1e6 aa[20:30, 195:215] = -1e6 - cmap = plt.colormaps["RdBu_r"] - cmap.set_over('yellow') - cmap.set_under('cyan') + cmap = plt.colormaps["RdBu_r"].with_extremes(over='yellow', under='cyan') axs = axs.flatten() # zoom in @@ -1726,8 +1722,7 @@ def test_downsampling_speckle(): axs = axs.flatten() img = ((np.arange(1024).reshape(-1, 1) * np.ones(720)) // 50).T - cm = plt.get_cmap("viridis") - cm.set_over("m") + cm = plt.get_cmap("viridis").with_extremes(over="m") norm = colors.LogNorm(vmin=3, vmax=11) # old default cannot be tested because it creates over/under speckles diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index e38df4f80ba4..9fcd19b23f57 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -285,18 +285,11 @@ def test_contourf3d_extend(fig_test, fig_ref, extend, levels): # Z is in the range [0, 8] Z = X**2 + Y**2 - # Manually set the over/under colors to be the end of the colormap - cmap = mpl.colormaps['viridis'].copy() - cmap.set_under(cmap(0)) - cmap.set_over(cmap(255)) - # Set vmin/max to be the min/max values plotted on the reference image - kwargs = {'vmin': 1, 'vmax': 7, 'cmap': cmap} - ax_ref = fig_ref.add_subplot(projection='3d') - ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], **kwargs) + ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], vmin=1, vmax=7) ax_test = fig_test.add_subplot(projection='3d') - ax_test.contourf(X, Y, Z, levels, extend=extend, **kwargs) + ax_test.contourf(X, Y, Z, levels, extend=extend, vmin=1, vmax=7) for ax in [ax_ref, ax_test]: ax.set_xlim(-2, 2) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py index 091ae2c3e12f..9ca048e18ba9 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -90,8 +90,7 @@ def test_contourf_legend_elements(): cs = ax.contourf(x, y, h, levels=[10, 30, 50], colors=['#FFFF00', '#FF00FF', '#00FFFF'], extend='both') - cs.cmap.set_over('red') - cs.cmap.set_under('blue') + cs.cmap = cs.cmap.with_extremes(over='red', under='blue') cs.changed() artists, labels = cs.legend_elements() assert labels == ['$x \\leq -1e+250s$', From d8f00341709cadc939152574aa67dc977083ba70 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:30:45 +0200 Subject: [PATCH 111/228] MNT: Streamline deferred initialization of Colormap --- lib/matplotlib/colors.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 8cacea8fbb05..358d4ccc71fa 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -805,8 +805,7 @@ def _get_rgba_and_mask(self, X, alpha=None, bytes=False): mask : np.ndarray Boolean array with True where the input is ``np.nan`` or masked. """ - if not self._isinit: - self._init() + self._ensure_inited() xa = np.array(X, copy=True) if not xa.dtype.isnative: @@ -863,16 +862,13 @@ def __eq__(self, other): self.colorbar_extend != other.colorbar_extend): return False # To compare lookup tables the Colormaps have to be initialized - if not self._isinit: - self._init() - if not other._isinit: - other._init() + self._ensure_inited() + other._ensure_inited() return np.array_equal(self._lut, other._lut) def get_bad(self): """Get the color for masked values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_bad]) def set_bad(self, color='k', alpha=None): @@ -881,8 +877,7 @@ def set_bad(self, color='k', alpha=None): def get_under(self): """Get the color for low out-of-range values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_under]) def set_under(self, color='k', alpha=None): @@ -891,8 +886,7 @@ def set_under(self, color='k', alpha=None): def get_over(self): """Get the color for high out-of-range values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_over]) def set_over(self, color='k', alpha=None): @@ -957,8 +951,7 @@ def with_alpha(self, alpha): if not 0 <= alpha <= 1: raise ValueError("'alpha' must be between 0 and 1, inclusive") new_cm = self.copy() - if not new_cm._isinit: - new_cm._init() + new_cm._ensure_inited() new_cm._lut[:, 3] = alpha return new_cm @@ -966,10 +959,13 @@ def _init(self): """Generate the lookup table, ``self._lut``.""" raise NotImplementedError("Abstract class only") - def is_gray(self): - """Return whether the colormap is grayscale.""" + def _ensure_inited(self): if not self._isinit: self._init() + + def is_gray(self): + """Return whether the colormap is grayscale.""" + self._ensure_inited() return (np.all(self._lut[:, 0] == self._lut[:, 1]) and np.all(self._lut[:, 0] == self._lut[:, 2])) @@ -1363,9 +1359,7 @@ def monochrome(self): # TODO: It's a separate discussion whether we need this property on # colormaps at all (at least as public API). It's a very special edge # case and we only use it for contours internally. - if not self._isinit: - self._init() - + self._ensure_inited() return self.N <= 1 or np.all(self._lut[0] == self._lut[1:self.N]) def resampled(self, lutsize): From 4576add820d01e1164c37ae31af8fae28b396e46 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 18 Sep 2025 17:11:05 -0400 Subject: [PATCH 112/228] ci: Bump Ubuntu ARM builder to 24.04 Since earlier today, the `ubuntu-22.04-arm` image has actually been Ubuntu 24.04. Instead of working out how to fix that, switch to the newer image explicitly. --- .github/workflows/tests.yml | 12 +++++------- lib/matplotlib/tests/test_axes.py | 5 +++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6da9c7a0a8fa..8ed83a65579b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,10 +60,6 @@ jobs: extra-requirements: '-r requirements/testing/extra.txt' # https://github.com/matplotlib/matplotlib/issues/29844 pygobject-ver: '<3.52.0' - - os: ubuntu-22.04-arm - python-version: '3.12' - # https://github.com/matplotlib/matplotlib/issues/29844 - pygobject-ver: '<3.52.0' - name-suffix: "(Extra TeX packages)" os: ubuntu-22.04 python-version: '3.13' @@ -77,6 +73,8 @@ jobs: pygobject-ver: '<3.52.0' - os: ubuntu-24.04 python-version: '3.12' + - os: ubuntu-24.04-arm + python-version: '3.12' - os: macos-14 # This runner is on M1 (arm64) chips. python-version: '3.11' # https://github.com/matplotlib/matplotlib/issues/29732 @@ -150,7 +148,7 @@ jobs: if [[ "${{ matrix.name-suffix }}" != '(Minimum Versions)' ]]; then sudo apt-get install -yy --no-install-recommends ffmpeg poppler-utils fi - if [[ "${{ matrix.os }}" = ubuntu-22.04 || "${{ matrix.os }}" = ubuntu-22.04-arm ]]; then + if [[ "${{ matrix.os }}" = ubuntu-22.04 ]]; then sudo apt-get install -yy --no-install-recommends \ gir1.2-gtk-4.0 \ libgirepository1.0-dev @@ -257,7 +255,7 @@ jobs: ) # PyQt5 does not have any wheels for ARM on Linux. - if [[ "${{ matrix.os }}" != 'ubuntu-22.04-arm' ]]; then + if [[ "${{ matrix.os }}" != 'ubuntu-24.04-arm' ]]; then python -mpip install --upgrade --only-binary :all: pyqt5 && python -c 'import PyQt5.QtCore' && echo 'PyQt5 is available' || @@ -372,7 +370,7 @@ jobs: run: | if [[ "${{ runner.os }}" != 'macOS' ]]; then LCOV_IGNORE_ERRORS=',' # do not ignore any lcov errors by default - if [[ "${{ matrix.os }}" = ubuntu-24.04 ]]; then + if [[ "${{ matrix.os }}" = ubuntu-24.04 || "${{ matrix.os }}" = ubuntu-24.04-arm ]]; then # filter mismatch and unused-entity errors detected by lcov 2.x LCOV_IGNORE_ERRORS='mismatch,unused' fi diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 6679f0a0055b..b25b58c4a727 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1382,7 +1382,8 @@ def test_pcolorargs_5205(): plt.pcolor(X, Y, list(Z[:-1, :-1])) -@image_comparison(['pcolormesh'], remove_text=True) +@image_comparison(['pcolormesh'], remove_text=True, + tol=0.11 if platform.machine() == 'aarch64' else 0) def test_pcolormesh(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False @@ -1434,7 +1435,7 @@ def test_pcolormesh_small(): @image_comparison(['pcolormesh_alpha'], extensions=["png", "pdf"], remove_text=True, - tol=0.2 if platform.machine() == "aarch64" else 0) + tol=0.4 if platform.machine() == "aarch64" else 0) def test_pcolormesh_alpha(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False From 224828f89414185f1c7086b70cd82517d0b5a38a Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 23:11:53 +0200 Subject: [PATCH 113/228] MNT: Pending-deprecate setting colormap extremes Per #29141 we have the long-term plan to make colormaps immutable. As a result, the in-place modifications have to be removed. We take this particularly slow with at least two minor releases of pending deprecation and two further releases of actual deprecations. As it's quite a common concept and migrating away will take time. --- .../deprecations/30531-TH.rst | 16 +++++++++ lib/matplotlib/colors.py | 36 ++++++++++++++----- lib/matplotlib/tests/test_axes.py | 3 +- lib/matplotlib/tests/test_colors.py | 12 ++++--- 4 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30531-TH.rst diff --git a/doc/api/next_api_changes/deprecations/30531-TH.rst b/doc/api/next_api_changes/deprecations/30531-TH.rst new file mode 100644 index 000000000000..19d51fd2fb6c --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30531-TH.rst @@ -0,0 +1,16 @@ +In-place modifications of colormaps +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Colormaps are planned to become immutable in the long term. + +As a first step, in-place modifications of colormaps are now pending-deprecated. +This affects the following methods of `.Colormap`: + +- `.Colormap.set_bad` - use ``cmap.with_extremes(bad=...)`` instead +- `.Colormap.set_under` - use ``cmap.with_extremes(under=...)`` instead +- `.Colormap.set_over` - use ``cmap.with_extremes(over=...)`` instead +- `.Colormap.set_extremes` - use ``cmap.with_extremes(...)`` instead + +Use the respective `.Colormap.with_extremes` and appropriate keyword arguments +instead which returns a copy of the colormap (available since matplotlib 3.4). +Alternatively, if you create the colormap yourself, you can also pass the +respective arguments to the constructor (available since matplotlib 3.11). diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 358d4ccc71fa..07cbe4a79cb0 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -871,6 +871,10 @@ def get_bad(self): self._ensure_inited() return np.array(self._lut[self._i_bad]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(bad=...) or Colormap(bad=...)") def set_bad(self, color='k', alpha=None): """Set the color for masked values.""" self._set_extremes(bad=(color, alpha)) @@ -880,6 +884,10 @@ def get_under(self): self._ensure_inited() return np.array(self._lut[self._i_under]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(under=...) or Colormap(under=...)") def set_under(self, color='k', alpha=None): """Set the color for low out-of-range values.""" self._set_extremes(under=(color, alpha)) @@ -889,10 +897,19 @@ def get_over(self): self._ensure_inited() return np.array(self._lut[self._i_over]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(over=...) or Colormap(over=...)") def set_over(self, color='k', alpha=None): """Set the color for high out-of-range values.""" self._set_extremes(over=(color, alpha)) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(bad=..., under=..., over=...) or " + "Colormap(bad=..., under=..., over=...)") def set_extremes(self, *, bad=None, under=None, over=None): """ Set the colors for masked (*bad*) values and, when ``norm.clip = @@ -1614,14 +1631,16 @@ def with_extremes(self, *, bad=None, under=None, over=None): f" i.e. be of length {len(new_cm)}.") else: for c, b in zip(new_cm, under): - c.set_under(b) + # in-place change is ok, since we've just created c as a copy + c._set_extremes(under=b) if over is not None: if not np.iterable(over) or len(over) != len(new_cm): raise ValueError("*over* must contain a color for each scalar colormap" f" i.e. be of length {len(new_cm)}.") else: for c, b in zip(new_cm, over): - c.set_over(b) + # in-place change is ok, since we've just created c as a copy + c._set_extremes(over=b) return new_cm @property @@ -2070,26 +2089,27 @@ def __getitem__(self, item): """Creates and returns a colorbar along the selected axis""" if not self._isinit: self._init() + extremes = ( + dict(bad=self._rgba_bad, over=self._rgba_outside, under=self._rgba_outside) + if self.shape in ['ignore', 'circleignore'] + else dict(bad=self._rgba_bad) + ) if item == 0: origin_1_as_int = int(self._origin[1]*self.M) if origin_1_as_int > self.M-1: origin_1_as_int = self.M-1 one_d_lut = self._lut[:, origin_1_as_int] - new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_0') + new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_0', **extremes) elif item == 1: origin_0_as_int = int(self._origin[0]*self.N) if origin_0_as_int > self.N-1: origin_0_as_int = self.N-1 one_d_lut = self._lut[origin_0_as_int, :] - new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_1') + new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_1', **extremes) else: raise KeyError(f"only 0 or 1 are" f" valid keys for BivarColormap, not {item!r}") - new_cmap._rgba_bad = self._rgba_bad - if self.shape in ['ignore', 'circleignore']: - new_cmap.set_over(self._rgba_outside) - new_cmap.set_under(self._rgba_outside) return new_cmap def _repr_png_(self): diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index cd5689e58526..3bb374302eaa 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2966,8 +2966,7 @@ def test_scatter_edgecolor_RGB(self): @check_figures_equal() def test_scatter_invalid_color(self, fig_test, fig_ref): ax = fig_test.subplots() - cmap = mpl.colormaps["viridis"].resampled(16) - cmap.set_bad("k", 1) + cmap = mpl.colormaps["viridis"].resampled(16).with_extremes(bad="black") # Set a nonuniform size to prevent the last call to `scatter` (plotting # the invalid points separately in fig_ref) from using the marker # stamping fast path, which would result in slightly offset markers. diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 3ef19f681c1e..4efe4165843a 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -111,7 +111,8 @@ def test_colormap_copy(): with np.errstate(invalid='ignore'): ret1 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) cmap2 = copy.copy(copied_cmap) - cmap2.set_bad('g') + with pytest.warns(PendingDeprecationWarning): + cmap2.set_bad('g') with np.errstate(invalid='ignore'): ret2 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) assert_array_equal(ret1, ret2) @@ -121,7 +122,8 @@ def test_colormap_copy(): with np.errstate(invalid='ignore'): ret1 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) cmap2 = copy.copy(copied_cmap) - cmap2.set_bad('g') + with pytest.warns(PendingDeprecationWarning): + cmap2.set_bad('g') with np.errstate(invalid='ignore'): ret2 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) assert_array_equal(ret1, ret2) @@ -135,7 +137,8 @@ def test_colormap_equals(): # But the same data should be equal assert cm_copy == cmap # Change the copy - cm_copy.set_bad('y') + with pytest.warns(PendingDeprecationWarning): + cm_copy.set_bad('y') assert cm_copy != cmap # Make sure we can compare different sizes without failure cm_copy._lut = cm_copy._lut[:10, :] @@ -1535,7 +1538,8 @@ def test_get_under_over_bad(): def test_non_mutable_get_values(kind): cmap = copy.copy(mpl.colormaps['viridis']) init_value = getattr(cmap, f'get_{kind}')() - getattr(cmap, f'set_{kind}')('k') + with pytest.warns(PendingDeprecationWarning): + getattr(cmap, f'set_{kind}')('k') black_value = getattr(cmap, f'get_{kind}')() assert np.all(black_value == [0, 0, 0, 1]) assert not np.all(init_value == black_value) From a00cbed0725628176cdb88ba3c47de81317ea45a Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 24 Sep 2025 15:06:44 +0200 Subject: [PATCH 114/228] Cleanup donuts example. --- .../examples/shapes_and_collections/donut.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/galleries/examples/shapes_and_collections/donut.py b/galleries/examples/shapes_and_collections/donut.py index 8764101beb3e..3aa5b0b1a75c 100644 --- a/galleries/examples/shapes_and_collections/donut.py +++ b/galleries/examples/shapes_and_collections/donut.py @@ -15,27 +15,21 @@ def wise(v): - if v == 1: - return "CCW" - else: - return "CW" + return {+1: "CCW", -1: "CW"}[v] def make_circle(r): t = np.arange(0, np.pi * 2.0, 0.01) - t = t.reshape((len(t), 1)) x = r * np.cos(t) y = r * np.sin(t) - return np.hstack((x, y)) + return np.column_stack((x, y)) -Path = mpath.Path fig, ax = plt.subplots() inside_vertices = make_circle(0.5) outside_vertices = make_circle(1.0) -codes = np.ones( - len(inside_vertices), dtype=mpath.Path.code_type) * mpath.Path.LINETO +codes = np.full(len(inside_vertices), mpath.Path.LINETO) codes[0] = mpath.Path.MOVETO for i, (inside, outside) in enumerate(((1, 1), (1, -1), (-1, 1), (-1, -1))): @@ -44,23 +38,20 @@ def make_circle(r): vertices = np.concatenate((outside_vertices[::outside], inside_vertices[::inside])) # Shift the path - vertices[:, 0] += i * 2.5 + vertices += (i * 2.5, 0) # The codes will be all "LINETO" commands, except for "MOVETO"s at the # beginning of each subpath all_codes = np.concatenate((codes, codes)) # Create the Path object path = mpath.Path(vertices, all_codes) - # Add plot it + # And plot it patch = mpatches.PathPatch(path, facecolor='#885500', edgecolor='black') ax.add_patch(patch) ax.annotate(f"Outside {wise(outside)},\nInside {wise(inside)}", (i * 2.5, -1.5), va="top", ha="center") -ax.set_xlim(-2, 10) -ax.set_ylim(-3, 2) -ax.set_title('Mmm, donuts!') -ax.set_aspect(1.0) +ax.set(xlim=(-2, 10), ylim=(-3, 2), aspect=1, title="Mmm, donuts!") plt.show() # %% From d53d7fecc346c7ce65f5e91f16308198168814e8 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sat, 20 Sep 2025 23:35:11 +0200 Subject: [PATCH 115/228] MNT: Define Protocol for Animation.event_source Also, event_source is a mandatory parameter and cannot be None, because Animation.__init__ calls `event_source.add_callback()`. --- ci/mypy-stubtest-allowlist.txt | 3 +++ lib/matplotlib/animation.py | 4 ++-- lib/matplotlib/animation.pyi | 12 +++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 46ec06e0a9f1..12b6feb9b2e0 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -49,3 +49,6 @@ matplotlib\.figure\.FigureBase\.get_figure # getitem method only exists for 3.10 deprecation backcompatability matplotlib\.inset\.InsetIndicator\.__getitem__ + +# only defined in stubs; not present at runtime +matplotlib\.animation\.EventSourceProtocol diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 8756cb0c1439..56fda4ec6849 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -861,7 +861,7 @@ class Animation: fig : `~matplotlib.figure.Figure` The figure object used to get needed events, such as draw or resize. - event_source : object, optional + event_source : object A class that can run a callback when desired events are generated, as well as be stopped and started. @@ -877,7 +877,7 @@ class Animation: FuncAnimation, ArtistAnimation """ - def __init__(self, fig, event_source=None, blit=False): + def __init__(self, fig, event_source, blit=False): self._draw_was_started = False self._fig = fig diff --git a/lib/matplotlib/animation.pyi b/lib/matplotlib/animation.pyi index f725df8ebb22..e90a0103aefd 100644 --- a/lib/matplotlib/animation.pyi +++ b/lib/matplotlib/animation.pyi @@ -6,7 +6,7 @@ from matplotlib.artist import Artist from matplotlib.backend_bases import TimerBase from matplotlib.figure import Figure -from typing import Any +from typing import Any, Protocol subprocess_creation_flags: int @@ -152,11 +152,17 @@ class HTMLWriter(FileMovieWriter): def grab_frame(self, **savefig_kwargs): ... def finish(self) -> None: ... +class EventSourceProtocol(Protocol): + def add_callback(self, func: Callable): ... + def remove_callback(self, func: Callable): ... + def start(self): ... + def stop(self): ... + class Animation: frame_seq: Iterable[Artist] - event_source: Any + event_source: EventSourceProtocol | None # TODO: We should remove None def __init__( - self, fig: Figure, event_source: Any | None = ..., blit: bool = ... + self, fig: Figure, event_source: EventSourceProtocol, blit: bool = ... ) -> None: ... def __del__(self) -> None: ... def save( From 8a6e2f085e359eb7e7faab00141bd123db71e02d Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 25 Sep 2025 11:04:53 +0200 Subject: [PATCH 116/228] Use auto to remove long typedefs in dlsym/GetProcAddress calls. --- src/_c_internal_utils.cpp | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/_c_internal_utils.cpp b/src/_c_internal_utils.cpp index 0dddefaf32e3..31eb92444862 100644 --- a/src/_c_internal_utils.cpp +++ b/src/_c_internal_utils.cpp @@ -41,11 +41,11 @@ mpl_xdisplay_is_valid(void) // than dlopen(). if (getenv("DISPLAY") && (libX11 = dlopen("libX11.so.6", RTLD_LAZY))) { - typedef struct Display* (*XOpenDisplay_t)(char const*); - typedef int (*XCloseDisplay_t)(struct Display*); struct Display* display = nullptr; - XOpenDisplay_t XOpenDisplay = (XOpenDisplay_t)dlsym(libX11, "XOpenDisplay"); - XCloseDisplay_t XCloseDisplay = (XCloseDisplay_t)dlsym(libX11, "XCloseDisplay"); + auto XOpenDisplay = (struct Display* (*)(char const*)) + dlsym(libX11, "XOpenDisplay"); + auto XCloseDisplay = (int (*)(struct Display*)) + dlsym(libX11, "XCloseDisplay"); if (XOpenDisplay && XCloseDisplay && (display = XOpenDisplay(nullptr))) { XCloseDisplay(display); @@ -73,13 +73,11 @@ mpl_display_is_valid(void) void* libwayland_client; if (getenv("WAYLAND_DISPLAY") && (libwayland_client = dlopen("libwayland-client.so.0", RTLD_LAZY))) { - typedef struct wl_display* (*wl_display_connect_t)(char const*); - typedef void (*wl_display_disconnect_t)(struct wl_display*); struct wl_display* display = nullptr; - wl_display_connect_t wl_display_connect = - (wl_display_connect_t)dlsym(libwayland_client, "wl_display_connect"); - wl_display_disconnect_t wl_display_disconnect = - (wl_display_disconnect_t)dlsym(libwayland_client, "wl_display_disconnect"); + auto wl_display_connect = (struct wl_display* (*)(char const*)) + dlsym(libwayland_client, "wl_display_connect"); + auto wl_display_disconnect = (void (*)(struct wl_display*)) + dlsym(libwayland_client, "wl_display_disconnect"); if (wl_display_connect && wl_display_disconnect && (display = wl_display_connect(nullptr))) { wl_display_disconnect(display); @@ -162,25 +160,19 @@ mpl_SetProcessDpiAwareness_max(void) #ifdef _DPI_AWARENESS_CONTEXTS_ // These functions and options were added in later Windows 10 updates, so // must be loaded dynamically. - typedef BOOL (WINAPI *IsValidDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); - typedef BOOL (WINAPI *SetProcessDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); - HMODULE user32 = LoadLibrary("user32.dll"); - IsValidDpiAwarenessContext_t IsValidDpiAwarenessContextPtr = - (IsValidDpiAwarenessContext_t)GetProcAddress( - user32, "IsValidDpiAwarenessContext"); - SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr = - (SetProcessDpiAwarenessContext_t)GetProcAddress( - user32, "SetProcessDpiAwarenessContext"); + auto IsValidDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT)) + GetProcAddress(user32, "IsValidDpiAwarenessContext"); + auto SetProcessDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT)) + GetProcAddress(user32, "SetProcessDpiAwarenessContext"); DPI_AWARENESS_CONTEXT ctxs[3] = { DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, // Win10 Creators Update DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, // Win10 DPI_AWARENESS_CONTEXT_SYSTEM_AWARE}; // Win10 - if (IsValidDpiAwarenessContextPtr != NULL - && SetProcessDpiAwarenessContextPtr != NULL) { + if (IsValidDpiAwarenessContext && SetProcessDpiAwarenessContext) { for (size_t i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) { - if (IsValidDpiAwarenessContextPtr(ctxs[i])) { - SetProcessDpiAwarenessContextPtr(ctxs[i]); + if (IsValidDpiAwarenessContext(ctxs[i])) { + SetProcessDpiAwarenessContext(ctxs[i]); break; } } From 0cb518f11527955d8a674abd62fe2c22812c06d4 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 26 Sep 2025 02:45:17 -0400 Subject: [PATCH 117/228] MNT: Fix some broken deprecations The `ngrids` deprecation was missing the version, and style registration incorrectly included the micro version. --- lib/matplotlib/patches.py | 5 ++--- lib/mpl_toolkits/axes_grid1/axes_grid.py | 2 +- lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index d750e86e401f..ec8f55d20ac8 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2382,10 +2382,9 @@ def pprint_styles(cls): @classmethod @_api.deprecated( - '3.10.0', + '3.10', message="This method is never used internally.", - alternative="No replacement. Please open an issue if you use this." - ) + alternative="No replacement. Please open an issue if you use this.") def register(cls, name, style): """Register a new style.""" if not issubclass(style, cls._Base): diff --git a/lib/mpl_toolkits/axes_grid1/axes_grid.py b/lib/mpl_toolkits/axes_grid1/axes_grid.py index f7d2968f1990..b26c87edce1c 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_grid.py +++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py @@ -190,7 +190,7 @@ def _get_col_row(self, n): return col, row n_axes = property(lambda self: len(self.axes_all)) - ngrids = _api.deprecated(property(lambda self: len(self.axes_all))) + ngrids = _api.deprecated('3.11')(property(lambda self: len(self.axes_all))) # Good to propagate __len__ if we have __getitem__ def __len__(self): diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index b6d72e408a52..f550dc9f531e 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -806,3 +806,5 @@ def test_grid_n_axes(): fig = plt.figure() grid = Grid(fig, 111, (3, 3), n_axes=5) assert len(fig.axes) == grid.n_axes == 5 + with pytest.warns(mpl.MatplotlibDeprecationWarning, match="ngrids attribute"): + assert grid.ngrids == 5 From 47fc02daa998362f588ff75e4a9384443e9225c4 Mon Sep 17 00:00:00 2001 From: Aaratrika-Shelly Date: Fri, 26 Sep 2025 21:14:38 +0000 Subject: [PATCH 118/228] DOC: Fix raw string in mathtext unicode example --- galleries/users_explain/text/mathtext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galleries/users_explain/text/mathtext.py b/galleries/users_explain/text/mathtext.py index 7ff317804f98..4a4f80c12695 100644 --- a/galleries/users_explain/text/mathtext.py +++ b/galleries/users_explain/text/mathtext.py @@ -370,4 +370,4 @@ # If a particular symbol does not have a name (as is true of many of the more # obscure symbols in the STIX fonts), Unicode characters can also be used:: # -# r'$\u23ce$' +# '$\u23ce$' From 0d57c31ac0f7e1a93248e5b559277a6b2d642857 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 27 Sep 2025 15:51:59 +0200 Subject: [PATCH 119/228] Bump mpl-sphinx-theme version --- requirements/doc/doc-requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements/doc/doc-requirements.txt b/requirements/doc/doc-requirements.txt index 77cb606130b0..1a352eaae975 100644 --- a/requirements/doc/doc-requirements.txt +++ b/requirements/doc/doc-requirements.txt @@ -14,8 +14,7 @@ ipywidgets ipykernel numpydoc>=1.0 packaging>=20 -pydata-sphinx-theme~=0.15.0 -mpl-sphinx-theme~=3.9.0 +mpl-sphinx-theme~=3.10.0 pyyaml PyStemmer sphinxcontrib-svg2pdfconverter>=1.1.0 From c43796a8740c8ef3e4248624b3cfe93fe9128ae1 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:41:30 +0100 Subject: [PATCH 120/228] MNT: update black pin --- environment.yml | 2 +- lib/matplotlib/pyplot.py | 30 ++++++++++++++--------------- lib/matplotlib/tests/test_pyplot.py | 2 +- requirements/testing/all.txt | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/environment.yml b/environment.yml index eaa6ed6386b6..bc89131a6742 100644 --- a/environment.yml +++ b/environment.yml @@ -53,7 +53,7 @@ dependencies: - sphinxcontrib-video>=0.2.1 - pikepdf # testing - - black<24 + - black<26 - coverage - gtk4 - ipykernel diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index f44d3c59f06d..196b4cdb75d3 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -3269,8 +3269,9 @@ def cohere( NFFT: int = 256, Fs: float = 2, Fc: int = 0, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] = mlab.detrend_none, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] + ) = mlab.detrend_none, window: Callable[[ArrayLike], ArrayLike] | ArrayLike = mlab.window_hanning, noverlap: int = 0, pad_to: int | None = None, @@ -3327,9 +3328,9 @@ def csd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -4004,9 +4005,9 @@ def psd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -4114,9 +4115,9 @@ def specgram( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, cmap: str | Colormap | None = None, @@ -4433,10 +4434,9 @@ def violinplot( showmedians: bool = False, quantiles: Sequence[float | Sequence[float]] | None = None, points: int = 100, - bw_method: Literal["scott", "silverman"] - | float - | Callable[[GaussianKDE], float] - | None = None, + bw_method: ( + Literal["scott", "silverman"] | float | Callable[[GaussianKDE], float] | None + ) = None, side: Literal["both", "low", "high"] = "both", facecolor: Sequence[ColorType] | ColorType | None = None, linecolor: Sequence[ColorType] | ColorType | None = None, diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 44555a333a8c..1cca7332aa0c 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -13,7 +13,7 @@ def test_pyplot_up_to_date(tmp_path): - pytest.importorskip("black") + pytest.importorskip("black", minversion="24.1") gen_script = Path(mpl.__file__).parents[2] / "tools/boilerplate.py" if not gen_script.exists(): diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index a41073bdf47e..dd1dbf3f29fd 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -1,6 +1,6 @@ # pip requirements for all the CI builds -black<24 +black<26 certifi coverage!=6.3 psutil From eb42d8637ba5891aa806fa6b0ed73835c16b2741 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sun, 28 Sep 2025 14:08:57 +0100 Subject: [PATCH 121/228] Backport PR #30612: MNT: update black pin --- environment.yml | 2 +- lib/matplotlib/pyplot.py | 30 ++++++++++++++--------------- lib/matplotlib/tests/test_pyplot.py | 2 +- requirements/testing/all.txt | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/environment.yml b/environment.yml index d95cab0509ff..bea37db1e10f 100644 --- a/environment.yml +++ b/environment.yml @@ -51,7 +51,7 @@ dependencies: - sphinxcontrib-video>=0.2.1 - pikepdf # testing - - black<24 + - black<26 - coverage - flake8>=3.8,<7.2 - flake8-docstrings>=1.4.0 diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 2dd8e76d98b4..9cca3b0370a6 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -3145,8 +3145,9 @@ def cohere( NFFT: int = 256, Fs: float = 2, Fc: int = 0, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] = mlab.detrend_none, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] + ) = mlab.detrend_none, window: Callable[[ArrayLike], ArrayLike] | ArrayLike = mlab.window_hanning, noverlap: int = 0, pad_to: int | None = None, @@ -3203,9 +3204,9 @@ def csd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -3876,9 +3877,9 @@ def psd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -3986,9 +3987,9 @@ def specgram( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, cmap: str | Colormap | None = None, @@ -4299,10 +4300,9 @@ def violinplot( showmedians: bool = False, quantiles: Sequence[float | Sequence[float]] | None = None, points: int = 100, - bw_method: Literal["scott", "silverman"] - | float - | Callable[[GaussianKDE], float] - | None = None, + bw_method: ( + Literal["scott", "silverman"] | float | Callable[[GaussianKDE], float] | None + ) = None, side: Literal["both", "low", "high"] = "both", *, data=None, diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 1aaa8dd93ca2..46bd6e456d32 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -12,7 +12,7 @@ def test_pyplot_up_to_date(tmp_path): - pytest.importorskip("black") + pytest.importorskip("black", minversion="24.1") gen_script = Path(mpl.__file__).parents[2] / "tools/boilerplate.py" if not gen_script.exists(): diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index a41073bdf47e..dd1dbf3f29fd 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -1,6 +1,6 @@ # pip requirements for all the CI builds -black<24 +black<26 certifi coverage!=6.3 psutil From df150b2c76c76afa050c819402a578d2d9bd1cbe Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:20:04 +0100 Subject: [PATCH 122/228] DOC: add what's new info for violin_stats --- doc/release/next_whats_new/violin_stats.rst | 31 +++++++++++++++++++++ lib/matplotlib/axes/_axes.py | 2 ++ lib/matplotlib/cbook.py | 5 +++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 doc/release/next_whats_new/violin_stats.rst diff --git a/doc/release/next_whats_new/violin_stats.rst b/doc/release/next_whats_new/violin_stats.rst new file mode 100644 index 000000000000..a764db01da59 --- /dev/null +++ b/doc/release/next_whats_new/violin_stats.rst @@ -0,0 +1,31 @@ +``violin_stats`` simpler *method* parameter +------------------------------------------- + +The *method* parameter of `~.cbook.violin_stats` may now be specified as tuple of +strings, and has a new default ``("GaussianKDE", "scott")``. Calling +`~.cbook.violin_stats` followed by `~.Axes.violin` is therefore now equivalent to +calling `~.Axes.violinplot`. + +.. plot:: + :include-source: true + :alt: Example showing violin_stats followed by violin gives the same result as violinplot + + import matplotlib.pyplot as plt + from matplotlib.cbook import violin_stats + import numpy as np + + rng = np.random.default_rng(19680801) + data = rng.normal(size=(10, 3)) + + fig, (ax1, ax2) = plt.subplots(ncols=2, layout='constrained', figsize=(6.4, 3.5)) + + # Create the violin plot in one step + ax1.violinplot(data) + ax1.set_title('One Step') + + # Process the data and then create the violin plot + vstats = violin_stats(data) + ax2.violin(vstats) + ax2.set_title('Two Steps') + + plt.show() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 0db8bdd6f643..34b85464f841 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -9006,6 +9006,8 @@ def violin(self, vpstats, positions=None, vert=None, -------- violinplot : Draw a violin plot from data instead of pre-computed statistics. + .cbook.violin_stats: + Calculate a *vpstats* dictionary from data, suitable for passing to violin. """ # Statistical quantities to be plotted on the violins diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index f20ed008f147..b9de982c18bd 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -1443,7 +1443,7 @@ def violin_stats(X, method=("GaussianKDE", "scott"), points=100, quantiles=None) dictionary. Users can skip this function and pass a user-defined set of dictionaries - with the same keys to `~.axes.Axes.violinplot` instead of using Matplotlib + with the same keys to `~.axes.Axes.violin` instead of using Matplotlib to do the calculations. See the *Returns* section below for the keys that must be present in the dictionaries. @@ -1471,6 +1471,9 @@ def method(data: ndarray, coords: ndarray) -> ndarray It should return the KDE of *data* evaluated at *coords*. + .. versionadded:: 3.11 + Support for ``(name, bw_method)`` tuple. + points : int, default: 100 Defines the number of points to evaluate each of the gaussian kernel density estimates at. From 451e1a07cad7ba3070d5f0d908db32da5b282dc3 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 30 Sep 2025 09:12:56 +0200 Subject: [PATCH 123/228] Include step info in str(scroll_event). Although event.step is only nonzero for scroll events, it seems reasonable to always add it to str(MouseEvent). After all, that str() always contains e.g. dblclick, which doesn't make sense for motion_notify_event either. (IOW the alternative would be to more carefully write different str()s for each kind of MouseEvents.) --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2ec6e42ddd02..e8d370dbceb8 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1433,7 +1433,7 @@ def _from_ax_coords(cls, name, ax, xy, *args, **kwargs): def __str__(self): return (f"{self.name}: " f"xy=({self.x}, {self.y}) xydata=({self.xdata}, {self.ydata}) " - f"button={self.button} dblclick={self.dblclick} " + f"button={self.button} dblclick={self.dblclick} step={self.step} " f"inaxes={self.inaxes}") From 23b9ffdd88a669a5505f391e9cc78644f5aaab6b Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 30 Sep 2025 09:20:10 +0200 Subject: [PATCH 124/228] Add --debug flag to python -mmatplotlib.dviread CLI. The dviread module logs some information at the debug level (e.g., dvi "specials"). Allow printing them from the CLI, to ease verification of dvi internals. --- lib/matplotlib/dviread.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 702543f9db26..f07157a63524 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -1347,8 +1347,12 @@ def _fontfile(cls, suffix, texname): parser = ArgumentParser() parser.add_argument("filename") parser.add_argument("dpi", nargs="?", type=float, default=None) + parser.add_argument("-d", "--debug", action="store_true") args = parser.parse_args() + if args.debug: + logging.basicConfig(level=logging.DEBUG) + def _print_fields(*args): print(" ".join(map("{:>11}".format, args))) From 8fadc71e0e372f05aa0305ef425c5152d70d6f39 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:15:24 +0200 Subject: [PATCH 125/228] Backport PR #29745: Use PEP8 style method and function names from (#30589) * Backport PR #29745: Use PEP8 style method and function names from pyparsing * Bump minimum pyparsing version * Use PEP8 method names from pyparsing * Add api note * Clean up pyparsing<3 code * Remove unused import * Clean up pyparsing import * Sort imports (cherry picked from commit d55bddedbb0636b44804a1b2aab4fbc91dccd08b) * Remove erroneous pillow bump Co-authored-by: Thomas A Caswell * Update pyproject.toml Co-authored-by: Elliott Sales de Andrade --------- Co-authored-by: David Stansby Co-authored-by: Thomas A Caswell Co-authored-by: Elliott Sales de Andrade --- .../next_api_changes/development/29745-DS.rst | 4 ++ doc/install/dependencies.rst | 2 +- environment.yml | 2 +- lib/matplotlib/_fontconfig_pattern.py | 8 +-- lib/matplotlib/_mathtext.py | 50 ++++++++----------- pyproject.toml | 2 +- requirements/testing/minver.txt | 2 +- requirements/testing/mypy.txt | 2 +- 8 files changed, 35 insertions(+), 37 deletions(-) create mode 100644 doc/api/next_api_changes/development/29745-DS.rst diff --git a/doc/api/next_api_changes/development/29745-DS.rst b/doc/api/next_api_changes/development/29745-DS.rst new file mode 100644 index 000000000000..7d9b1c2b143b --- /dev/null +++ b/doc/api/next_api_changes/development/29745-DS.rst @@ -0,0 +1,4 @@ +New minimum version of pyparsing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The minimum required version of ``pyparsing`` has been updated from 2.3.1 to 3.0.0. diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index c97dcdd64444..95d170a06b00 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -29,7 +29,7 @@ reference. * `NumPy `_ (>= 1.23) * `packaging `_ (>= 20.0) * `Pillow `_ (>= 8.0) -* `pyparsing `_ (>= 2.3.1) +* `pyparsing `_ (>= 3) .. _optional_dependencies: diff --git a/environment.yml b/environment.yml index bea37db1e10f..2a4f3eff69ea 100644 --- a/environment.yml +++ b/environment.yml @@ -22,7 +22,7 @@ dependencies: - pillow>=8 - pkg-config - pygobject - - pyparsing>=2.3.1 + - pyparsing>=3 - pyqt - python>=3.10 - python-dateutil>=2.1 diff --git a/lib/matplotlib/_fontconfig_pattern.py b/lib/matplotlib/_fontconfig_pattern.py index a1341c633243..48bb2956bd7e 100644 --- a/lib/matplotlib/_fontconfig_pattern.py +++ b/lib/matplotlib/_fontconfig_pattern.py @@ -13,7 +13,7 @@ import re from pyparsing import ( - Group, Optional, ParseException, Regex, StringEnd, Suppress, ZeroOrMore, oneOf) + Group, Optional, ParseException, Regex, StringEnd, Suppress, ZeroOrMore, one_of) _family_punc = r'\\\-:,' @@ -61,7 +61,7 @@ def comma_separated(elem): size = Regex(r"([0-9]+\.?[0-9]*|\.[0-9]+)") name = Regex(r"[a-z]+") value = Regex(fr"([^{_value_punc}]|(\\[{_value_punc}]))*") - prop = Group((name + Suppress("=") + comma_separated(value)) | oneOf(_CONSTANTS)) + prop = Group((name + Suppress("=") + comma_separated(value)) | one_of(_CONSTANTS)) return ( Optional(comma_separated(family)("families")) + Optional("-" + comma_separated(size)("sizes")) @@ -82,11 +82,11 @@ def parse_fontconfig_pattern(pattern): """ parser = _make_fontconfig_parser() try: - parse = parser.parseString(pattern) + parse = parser.parse_string(pattern) except ParseException as err: # explain becomes a plain method on pyparsing 3 (err.explain(0)). raise ValueError("\n" + ParseException.explain(err, 0)) from None - parser.resetCache() + parser.reset_cache() props = {} if "families" in parse: props["family"] = [*map(_family_unescape, parse["families"])] diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 9e20ea3da9b7..cf35dc1de7db 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -19,10 +19,10 @@ import numpy as np from pyparsing import ( - Empty, Forward, Literal, NotAny, oneOf, OneOrMore, Optional, + Empty, Forward, Literal, Group, NotAny, OneOrMore, Optional, ParseBaseException, ParseException, ParseExpression, ParseFatalException, ParserElement, ParseResults, QuotedString, Regex, StringEnd, ZeroOrMore, - pyparsing_common, Group) + pyparsing_common, nested_expr, one_of) import matplotlib as mpl from . import cbook @@ -31,18 +31,12 @@ from .font_manager import FontProperties, findfont, get_font from .ft2font import FT2Font, FT2Image, Kerning, LoadFlags -from packaging.version import parse as parse_version -from pyparsing import __version__ as pyparsing_version -if parse_version(pyparsing_version).major < 3: - from pyparsing import nestedExpr as nested_expr -else: - from pyparsing import nested_expr if T.TYPE_CHECKING: from collections.abc import Iterable from .ft2font import Glyph -ParserElement.enablePackrat() +ParserElement.enable_packrat() _log = logging.getLogger("matplotlib.mathtext") @@ -1745,7 +1739,7 @@ def Error(msg: str) -> ParserElement: def raise_error(s: str, loc: int, toks: ParseResults) -> T.Any: raise ParseFatalException(s, loc, msg) - return Empty().setParseAction(raise_error) + return Empty().set_parse_action(raise_error) class ParserState: @@ -1981,10 +1975,10 @@ def set_names_and_parse_actions() -> None: # token, placeable, and auto_delim are forward references which # are left without names to ensure useful error messages if key not in ("token", "placeable", "auto_delim"): - val.setName(key) + val.set_name(key) # Set actions if hasattr(self, key): - val.setParseAction(getattr(self, key)) + val.set_parse_action(getattr(self, key)) # Root definitions. @@ -2007,9 +2001,9 @@ def csnames(group: str, names: Iterable[str]) -> Regex: ) p.float_literal = Regex(r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)") - p.space = oneOf(self._space_widths)("space") + p.space = one_of(self._space_widths)("space") - p.style_literal = oneOf( + p.style_literal = one_of( [str(e.value) for e in self._MathStyle])("style_literal") p.symbol = Regex( @@ -2017,14 +2011,14 @@ def csnames(group: str, names: Iterable[str]) -> Regex: r"|\\[%${}\[\]_|]" + r"|\\(?:{})(?![A-Za-z])".format( "|".join(map(re.escape, tex2uni))) - )("sym").leaveWhitespace() + )("sym").leave_whitespace() p.unknown_symbol = Regex(r"\\[A-Za-z]+")("name") p.font = csnames("font", self._fontnames) - p.start_group = Optional(r"\math" + oneOf(self._fontnames)("font")) + "{" + p.start_group = Optional(r"\math" + one_of(self._fontnames)("font")) + "{" p.end_group = Literal("}") - p.delim = oneOf(self._delims) + p.delim = one_of(self._delims) # Mutually recursive definitions. (Minimizing the number of Forward # elements is important for speed.) @@ -2085,7 +2079,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex: r"\underset", p.optional_group("annotation") + p.optional_group("body")) - p.text = cmd(r"\text", QuotedString('{', '\\', endQuoteChar="}")) + p.text = cmd(r"\text", QuotedString('{', '\\', end_quote_char="}")) p.substack = cmd(r"\substack", nested_expr(opener="{", closer="}", @@ -2094,7 +2088,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex: p.subsuper = ( (Optional(p.placeable)("nucleus") - + OneOrMore(oneOf(["_", "^"]) - p.placeable)("subsuper") + + OneOrMore(one_of(["_", "^"]) - p.placeable)("subsuper") + Regex("'*")("apostrophes")) | Regex("'+")("apostrophes") | (p.named_placeable("nucleus") + Regex("'*")("apostrophes")) @@ -2143,8 +2137,8 @@ def csnames(group: str, names: Iterable[str]) -> Regex: # Leaf definitions. p.math = OneOrMore(p.token) - p.math_string = QuotedString('$', '\\', unquoteResults=False) - p.non_math = Regex(r"(?:(?:\\[$])|[^$])*").leaveWhitespace() + p.math_string = QuotedString('$', '\\', unquote_results=False) + p.non_math = Regex(r"(?:(?:\\[$])|[^$])*").leave_whitespace() p.main = ( p.non_math + ZeroOrMore(p.math_string + p.non_math) + StringEnd() ) @@ -2167,7 +2161,7 @@ def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hli ParserState(fonts_object, 'default', 'rm', fontsize, dpi)] self._em_width_cache: dict[tuple[str, float, float], float] = {} try: - result = self._expression.parseString(s) + result = self._expression.parse_string(s) except ParseBaseException as err: # explain becomes a plain method on pyparsing 3 (err.explain(0)). raise ValueError("\n" + ParseException.explain(err, 0)) from None @@ -2175,7 +2169,7 @@ def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hli self._in_subscript_or_superscript = False # prevent operator spacing from leaking into a new expression self._em_width_cache = {} - ParserElement.resetCache() + ParserElement.reset_cache() return T.cast(Hlist, result[0]) # Known return type from main. def get_state(self) -> ParserState: @@ -2191,13 +2185,13 @@ def push_state(self) -> None: self._state_stack.append(self.get_state().copy()) def main(self, toks: ParseResults) -> list[Hlist]: - return [Hlist(toks.asList())] + return [Hlist(toks.as_list())] def math_string(self, toks: ParseResults) -> ParseResults: - return self._math_expression.parseString(toks[0][1:-1], parseAll=True) + return self._math_expression.parse_string(toks[0][1:-1], parse_all=True) def math(self, toks: ParseResults) -> T.Any: - hlist = Hlist(toks.asList()) + hlist = Hlist(toks.as_list()) self.pop_state() return [hlist] @@ -2210,7 +2204,7 @@ def non_math(self, toks: ParseResults) -> T.Any: self.get_state().font = mpl.rcParams['mathtext.default'] return [hlist] - float_literal = staticmethod(pyparsing_common.convertToFloat) + float_literal = staticmethod(pyparsing_common.convert_to_float) def text(self, toks: ParseResults) -> T.Any: self.push_state() @@ -2809,7 +2803,7 @@ def auto_delim(self, toks: ParseResults) -> T.Any: return self._auto_sized_delimiter( toks["left"], # if "mid" in toks ... can be removed when requiring pyparsing 3. - toks["mid"].asList() if "mid" in toks else [], + toks["mid"].as_list() if "mid" in toks else [], toks["right"]) def boldsymbol(self, toks: ParseResults) -> T.Any: diff --git a/pyproject.toml b/pyproject.toml index e6d1abaf530b..25f4ce8d4948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "numpy >= 1.23", "packaging >= 20.0", "pillow >= 8", - "pyparsing >= 2.3.1", + "pyparsing >= 3", "python-dateutil >= 2.7", ] # Also keep in sync with find_program of meson.build. diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt index d30ebf08f04b..8ecd075fb64b 100644 --- a/requirements/testing/minver.txt +++ b/requirements/testing/minver.txt @@ -10,7 +10,7 @@ meson==1.1.0 numpy==1.23.0 packaging==20.0 pillow==8.3.2 -pyparsing==2.3.1 +pyparsing==3.0.0 pytest==7.0.0 python-dateutil==2.7 diff --git a/requirements/testing/mypy.txt b/requirements/testing/mypy.txt index aa20581ee69b..0cef979a34bf 100644 --- a/requirements/testing/mypy.txt +++ b/requirements/testing/mypy.txt @@ -20,7 +20,7 @@ fonttools>=4.22.0 kiwisolver>=1.3.1 packaging>=20.0 pillow>=8 -pyparsing>=2.3.1 +pyparsing>=3 python-dateutil>=2.7 setuptools_scm>=7 setuptools>=64 From 3b835f87e265d81318b1594fa51ad5e148c956e4 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 2 Oct 2025 15:54:49 -0400 Subject: [PATCH 126/228] MNT: Fix new F401 unused imports warnings It appears ruff now notices these, so either remove, or mark as explicitly done. --- lib/matplotlib/backends/backend_webagg.py | 8 +++----- lib/matplotlib/backends/backend_wx.py | 2 +- lib/matplotlib/pyplot.py | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index dfc5747ef77c..e4808e8d0d32 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -21,14 +21,12 @@ import threading try: - import tornado + import tornado.web + import tornado.ioloop + import tornado.websocket except ImportError as err: raise RuntimeError("The WebAgg backend requires Tornado.") from err -import tornado.web -import tornado.ioloop -import tornado.websocket - import matplotlib as mpl from matplotlib.backend_bases import _Backend from matplotlib._pylab_helpers import Gcf diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 6645077defc9..0acb4499ed87 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -30,7 +30,7 @@ from matplotlib.transforms import Affine2D import wx -import wx.svg +import wx.svg # noqa: F401 _log = logging.getLogger(__name__) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 196b4cdb75d3..cecd3dc24737 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -58,7 +58,6 @@ from cycler import cycler # noqa: F401 import matplotlib -import matplotlib.colorbar import matplotlib.image from matplotlib import _api # Re-exported (import x as x) for typing. From eb8cb0ce31f6965713a5d46fe4fadf2dffb74773 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 3 Oct 2025 10:42:21 +0200 Subject: [PATCH 127/228] Backport PR #30626: MNT: Fix new F401 unused imports warnings --- lib/matplotlib/backends/backend_webagg.py | 8 +++----- lib/matplotlib/backends/backend_wx.py | 2 +- lib/matplotlib/pyplot.py | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index dfc5747ef77c..e4808e8d0d32 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -21,14 +21,12 @@ import threading try: - import tornado + import tornado.web + import tornado.ioloop + import tornado.websocket except ImportError as err: raise RuntimeError("The WebAgg backend requires Tornado.") from err -import tornado.web -import tornado.ioloop -import tornado.websocket - import matplotlib as mpl from matplotlib.backend_bases import _Backend from matplotlib._pylab_helpers import Gcf diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..027000c1d78c 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -30,7 +30,7 @@ from matplotlib.transforms import Affine2D import wx -import wx.svg +import wx.svg # noqa: F401 _log = logging.getLogger(__name__) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 9cca3b0370a6..562fc02715ec 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -54,7 +54,6 @@ from cycler import cycler # noqa: F401 import matplotlib -import matplotlib.colorbar import matplotlib.image from matplotlib import _api # Re-exported (import x as x) for typing. From 703c128e0e6590c19fd1893438b14a756c139c25 Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Fri, 3 Oct 2025 22:03:21 +0800 Subject: [PATCH 128/228] Update first-interaction to v3.1.0 --- .github/workflows/pr_welcome.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_welcome.yml b/.github/workflows/pr_welcome.yml index 874f8807b478..2580b5a008a0 100644 --- a/.github/workflows/pr_welcome.yml +++ b/.github/workflows/pr_welcome.yml @@ -9,7 +9,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0 + - uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0 with: repo_token: ${{ secrets.GITHUB_TOKEN }} pr_message: >+ From f70a4a2232b9549274cc1210f329813f3cf3044f Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:33:43 +0200 Subject: [PATCH 129/228] FIX: Keep legacy alpha behavior for violinplot without facecolor Closes #30613. --- lib/matplotlib/axes/_axes.py | 18 ++++++++++++++---- lib/matplotlib/tests/test_axes.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 34b85464f841..edd68ddf198d 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -8967,6 +8967,14 @@ def violin(self, vpstats, positions=None, vert=None, .. versionadded:: 3.11 + For backward compatibility, if *facecolor* is not given, the body + will get an Artist-level transparency `alpha <.Artist.set_alpha>` + of 0.3, which will persist if you afterwards change the facecolor, + e.g. via ``result['bodies'][0].set_facecolor('red')``. + If *facecolor* is given, there is no Artist-level transparency. + To set transparency for *facecolor* or *edgecolor* use + ``(color, alpha)`` tuples. + linecolor : :mpltype:`color` or list of :mpltype:`color`, optional If provided, will set the line color(s) of the violins (the horizontal and vertical spines and body edges). @@ -9074,13 +9082,14 @@ def cycle_color(color, alpha=None): if facecolor is not None: facecolor = cycle_color(facecolor) + body_artist_alpha = None else: - default_facealpha = 0.3 + body_artist_alpha = 0.3 # Use default colors if user doesn't provide them if mpl.rcParams['_internal.classic_mode']: - facecolor = cycle_color('y', alpha=default_facealpha) + facecolor = cycle_color('y') else: - facecolor = cycle_color(next_color, alpha=default_facealpha) + facecolor = cycle_color(next_color) if mpl.rcParams['_internal.classic_mode']: # Classic mode uses patch.force_edgecolor=True, so we need to @@ -9129,7 +9138,8 @@ def cycle_color(color, alpha=None): bodies += [fill(stats['coords'], -vals + pos if side in ['both', 'low'] else pos, vals + pos if side in ['both', 'high'] else pos, - facecolor=facecolor, edgecolor=body_edgecolor)] + facecolor=facecolor, edgecolor=body_edgecolor, + alpha=body_artist_alpha)] means.append(stats['mean']) mins.append(stats['min']) maxes.append(stats['max']) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 3bb374302eaa..5214ce51d13c 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4171,6 +4171,10 @@ def color_violins(parts, facecolor=None, linecolor=None): if facecolor is not None: for pc in parts['bodies']: pc.set_facecolor(facecolor) + # disable alpha Artist property to counter the legacy behavior + # that applies an alpha of 0.3 to the bodies if no facecolor + # was set + pc.set_alpha(None) if linecolor is not None: for partname in ('cbars', 'cmins', 'cmaxes', 'cmeans', 'cmedians'): if partname in parts: @@ -4228,6 +4232,33 @@ def assert_colors_equal(colors1, colors2): assert_colors_equal(colors_test, mcolors.to_rgba_array(linecolors)) +def test_violinplot_alpha(): + matplotlib.style.use('default') + data = [(np.random.normal(0, 1, 100))] + + fig, ax = plt.subplots() + parts = ax.violinplot(data, positions=[1]) + + # Case 1: If facecolor is unspecified, it's the first color from the color cycle + # with Artist-level alpha=0.3 + facecolor = ('y' if mpl.rcParams['_internal.classic_mode'] + else plt.rcParams['axes.prop_cycle'].by_key()['color'][0]) + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), (facecolor, 0.3)) + assert parts['bodies'][0].get_alpha() == 0.3 + # setting a new facecolor maintains the alpha + parts['bodies'][0].set_facecolor('red') + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), ('red', 0.3)) + + # Case 2: If facecolor is explicitly given, it's alpha does not become an + # Artist property + parts = ax.violinplot(data, positions=[1], facecolor=('blue', 0.3)) + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), ('blue', 0.3)) + assert parts['bodies'][0].get_alpha() is None + # so setting a new color does not maintain the alpha + parts['bodies'][0].set_facecolor('red') + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), 'red') + + @check_figures_equal() def test_violinplot_single_list_quantiles(fig_test, fig_ref): # Ensures quantile list for 1D can be passed in as single list From 509dc1f85981e97d1633c6b7f3a4d19fc124ebcc Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 6 Oct 2025 17:02:10 +0200 Subject: [PATCH 130/228] Don't force axes limits in hist2d. (#30634) Standard autoscaling works just fine there. --- doc/api/next_api_changes/behavior/30634-AL.rst | 6 ++++++ lib/matplotlib/axes/_axes.py | 18 +++++++++--------- lib/matplotlib/tests/test_axes.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/30634-AL.rst diff --git a/doc/api/next_api_changes/behavior/30634-AL.rst b/doc/api/next_api_changes/behavior/30634-AL.rst new file mode 100644 index 000000000000..585de1ea14eb --- /dev/null +++ b/doc/api/next_api_changes/behavior/30634-AL.rst @@ -0,0 +1,6 @@ +hist2d no longer forces axes limits +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Previously, `.Axes.hist2d` would force the axes x and y limits to the extents +of the histogrammed data, ignoring any other artists. `.Axes.hist2d` now +behaves similarly to `.Axes.imshow`: axes limits are updated to fit the data, +but autoscaling is not otherwise disabled. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 34b85464f841..259f742ac072 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -7772,13 +7772,15 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, Notes ----- - - Currently ``hist2d`` calculates its own axis limits, and any limits - previously set are ignored. - - Rendering the histogram with a logarithmic color scale is - accomplished by passing a `.colors.LogNorm` instance to the *norm* - keyword argument. Likewise, power-law normalization (similar - in effect to gamma correction) can be accomplished with - `.colors.PowerNorm`. + Rendering the histogram with a logarithmic color scale is accomplished + by passing a `.colors.LogNorm` instance to the *norm* keyword + argument. Likewise, power-law normalization (similar in effect to gamma + correction) can be accomplished with `.colors.PowerNorm`. + + .. versionchanged:: 3.11 + Previously, `~.Axes.hist2d` would force the axes limits to match the + extents of the histogram; now, autoscaling also takes other plot + elements into account. """ h, xedges, yedges = np.histogram2d(x, y, bins=bins, range=range, @@ -7790,8 +7792,6 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, h[h > cmax] = None pc = self.pcolormesh(xedges, yedges, h.T, **kwargs) - self.set_xlim(xedges[0], xedges[-1]) - self.set_ylim(yedges[0], yedges[-1]) return h, xedges, yedges, pc diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 3bb374302eaa..304ba0413686 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2837,6 +2837,16 @@ def test_hist2d_density(): obj.hist2d(x, y, density=True) +@mpl.style.context("mpl20") +def test_hist2d_autolimits(): + x, y = np.random.random((2, 100)) + ax = plt.figure().add_subplot() + ax.hist2d(x, y) + assert ax.get_xlim() == (x.min(), x.max()) + assert ax.get_ylim() == (y.min(), y.max()) + assert ax.get_autoscale_on() # Autolimits have not been disabled. + + class TestScatter: @image_comparison(['scatter'], style='mpl20', remove_text=True) def test_scatter_plot(self): From f380a441597c71678055f43423b1a981630721f6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 6 Oct 2025 11:03:32 -0400 Subject: [PATCH 131/228] Fix test_mult_norm_call_types on 32-bit systems (#30629) In that case, the default int is also 32-bit, so the test will fail to be equal to `int64`. So instead of the implicit type, use an explicit one. --- lib/matplotlib/tests/test_colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 4efe4165843a..89299b73cf0c 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -2016,7 +2016,7 @@ def test_mult_norm_call_types(): [[0., 0.5, 1.], [1, 3, 5]]) assert no_norm_out[0].dtype == np.dtype('float64') - assert no_norm_out[1].dtype == np.dtype('int64') + assert no_norm_out[1].dtype == vals.dtype # test with NoNorm, structured array as input mn_no_norm = mpl.colors.MultiNorm(['linear', mcolors.NoNorm()]) From 6e95994dec16146a68ebcf90a61889be9df6f744 Mon Sep 17 00:00:00 2001 From: G Karthik Koundinya <144328549+G26karthik@users.noreply.github.com> Date: Wed, 8 Oct 2025 06:42:20 +0530 Subject: [PATCH 132/228] DOC: Add note about linear colorbar scale option for TwoSlopeNorm (#30639) * DOC: Add note about linear colorbar scale option for TwoSlopeNorm Addresses #22197 by documenting the cb.ax.set_yscale('linear') workaround for users who prefer linear colorbar spacing instead of the default scaled spacing introduced in matplotlib 3.5. * FIX: Apply pre-commit fixes (end-of-file and line endings) * FIX: Use proper reStructuredText markup for inline code and class references * DOC: Add visual example comparing scaled vs linear colorbar for TwoSlopeNorm Replace note with side-by-side example showing: - Left: Default scaled colorbar (centered at midpoint) - Right: Linear colorbar using cb.ax.set_yscale('linear') Addresses maintainer feedback from @jklymak to make the difference more clear with a visual demonstration. * DOC: Refine TwoSlopeNorm colorbar documentation per maintainer feedback - Change title from 'norms with a scale' to TwoSlopeNorm-specific - Clarify that colorbar is divided into two equal parts - Explain screen-space vs data range difference - Note that color-to-value mapping remains unchanged - Address review comments from @timhoffm * FIX: Add proper reStructuredText markup for code references - Add backticks around .TwoSlopeNorm class reference - Use double backticks for cb.ax.set_yscale('linear') code snippet - Fixes documentation build errors * Update galleries/users_explain/colors/colormapnorms.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> * DOC: Address jklymak review feedback on colorbar explanation - Rewrite explanation to avoid 'screen space' term per jklymak - Clarify that colorbars adopt their norm's scaling by default - Better explain TwoSlopeNorm splits colormap evenly between halves - Change tick specification to use np.arange for clarity - Update both cb1 and cb2 to use np.arange(-500, 4001, 500) Addresses @jklymak review comments * FIX: Resolve linting errors - Fix E501: Break long line (line 289) to stay under 88 character limit - Fix E302: Add missing blank line before function definition All ruff checks now pass. * FIX: Remove square brackets from np.arange in set_ticks calls The set_ticks() method expects a 1D array with shape (N,), but wrapping np.arange() in square brackets creates a list containing an array with shape (1, N). This fixes the ValueError by passing the array directly. * Update galleries/users_explain/colors/colormapnorms.py Co-authored-by: Jody Klymak * FIX: Remove extra blank line before section marker Precommit linter expects only one blank line before # %% markers. * FIX: Ensure file ends with single newline The end-of-file-fixer precommit hook requires files to end with exactly one newline, not multiple. * Update galleries/users_explain/colors/colormapnorms.py Co-authored-by: Jody Klymak * Refactor TwoSlopeNorm colorbar example to use loop - Replace repeated code blocks with a loop as suggested by jklymak - Iterate over both axes with corresponding titles - Act on the last colorbar (cb) to set linear scale for right plot - Reduces code duplication and improves maintainability * Fix linting errors: line length and inline comment spacing - Wrap long comment lines to stay within 88 character limit - Split zip() arguments across lines for better readability - Add blank line before section marker - All ruff checks now pass --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Co-authored-by: Jody Klymak --- .../users_explain/colors/colormapnorms.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/galleries/users_explain/colors/colormapnorms.py b/galleries/users_explain/colors/colormapnorms.py index af50cef357b3..66bff534b3b0 100644 --- a/galleries/users_explain/colors/colormapnorms.py +++ b/galleries/users_explain/colors/colormapnorms.py @@ -281,6 +281,32 @@ cb.set_ticks([-500, 0, 1000, 2000, 3000, 4000]) plt.show() +# %% +# Using a linear scale on the colormap +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# By default, colorbars adopt the same axis scaling as their associated norm. +# For example, for a `.TwoSlopeNorm`, colormap segments are distributed +# linearly and the colorbar ticks positions are spaced non-linearly (as above, +# and the left-hand colorbar below). To make the tick spacing linear instead, +# you can change the scale by calling ``cb.ax.set_yscale('linear')``, as shown +# in the right-hand colorbar below. The ticks will then be evenly spaced, the +# colormap will appear compressed in the smaller of the two slope regions. + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4)) +divnorm = colors.TwoSlopeNorm(vmin=-500., vcenter=0, vmax=4000) + +for ax, title in zip([ax1, ax2], + ['Default: Scaled colorbar', 'Linear colorbar spacing']): + pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm, + cmap=terrain_map, shading='auto') + ax.set_aspect(1 / np.cos(np.deg2rad(49))) + ax.set_title(title) + cb = fig.colorbar(pcm, ax=ax, shrink=0.6) + cb.set_ticks(np.arange(-500, 4001, 500)) + +# Set linear scale for the right colorbar +cb.ax.set_yscale('linear') # %% # FuncNorm: Arbitrary function normalization @@ -290,6 +316,7 @@ # `~.colors.FuncNorm` to define your own. Note that this example is the same # as `~.colors.PowerNorm` with a power of 0.5: + def _forward(x): return np.sqrt(x) From f550261fadf30702a42bc85f71e3489a3c2f6b99 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 29 Aug 2025 18:35:39 -0500 Subject: [PATCH 133/228] Zenodo v3.10.6 --- doc/_static/zenodo_cache/16999430.svg | 35 +++++++++++++++++++++++++++ doc/project/citing.rst | 3 +++ tools/cache_zenodo_svg.py | 1 + 3 files changed, 39 insertions(+) create mode 100644 doc/_static/zenodo_cache/16999430.svg diff --git a/doc/_static/zenodo_cache/16999430.svg b/doc/_static/zenodo_cache/16999430.svg new file mode 100644 index 000000000000..44c448643e91 --- /dev/null +++ b/doc/_static/zenodo_cache/16999430.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.16999430 + + + 10.5281/zenodo.16999430 + + + \ No newline at end of file diff --git a/doc/project/citing.rst b/doc/project/citing.rst index 249f568625db..ae2061e7349c 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,9 @@ By version .. START OF AUTOGENERATED +v3.10.6 + .. image:: ../_static/zenodo_cache/16999430.svg + :target: https://doi.org/10.5281/zenodo.16999430 v3.10.5 .. image:: ../_static/zenodo_cache/16644850.svg :target: https://doi.org/10.5281/zenodo.16644850 diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 59d6fce55162..c6783dd9f19d 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,7 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.6": "16999430", "v3.10.5": "16644850", "v3.10.3": "15375714", "v3.10.1": "14940554", From a604a8beed19bdda1355a29a12ab2904367cf140 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 8 Oct 2025 17:26:43 -0500 Subject: [PATCH 134/228] Github Stats v3.10.7 --- doc/users/github_stats.rst | 60 +++++++-------- .../prev_whats_new/github_stats_3.10.6.rst | 76 +++++++++++++++++++ 2 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 doc/users/prev_whats_new/github_stats_3.10.6.rst diff --git a/doc/users/github_stats.rst b/doc/users/github_stats.rst index 84c2cc5867fd..c40bedb4cb5b 100644 --- a/doc/users/github_stats.rst +++ b/doc/users/github_stats.rst @@ -1,23 +1,23 @@ .. _github-stats: -GitHub statistics for 3.10.6 (Aug 29, 2025) +GitHub statistics for 3.10.7 (Oct 08, 2025) =========================================== -GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/08/29 +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/10/08 These lists are automatically generated, and may be incomplete or contain duplicates. -We closed 4 issues and merged 19 pull requests. -The full list can be seen `on GitHub `__ +We closed 4 issues and merged 16 pull requests. +The full list can be seen `on GitHub `__ -The following 31 authors contributed 380 commits. +The following 32 authors contributed 422 commits. -* Alan Burlot +* Aasma Gupta +* AASMA GUPTA * Antony Lee * Christine P. Chai * David Stansby * dependabot[bot] -* Doron Behar * Elliott Sales de Andrade * G.D. McBain * Greg Lucas @@ -41,39 +41,37 @@ The following 31 authors contributed 380 commits. * saikarna913 * Scott Shambaugh * Thomas A Caswell +* Tim Heap * Tim Hoffmann * Trygve Magnus Ræder GitHub issues and pull requests: -Pull Requests (19): +Pull Requests (16): -* :ghpull:`30487`: Backport PR #30484 on branch v3.10.x (FIX: be more cautious about checking widget size) -* :ghpull:`30484`: FIX: be more cautious about checking widget size -* :ghpull:`30481`: Backport PR #30394 on branch v3.10.x (ENH: Gracefully handle python-build-standalone ImportError with Tk) -* :ghpull:`30477`: Backport PR #30476 on branch v3.10.x (ci: Remove cibuildwheel override for win_arm64/Py3.14) -* :ghpull:`30394`: ENH: Gracefully handle python-build-standalone ImportError with Tk -* :ghpull:`30476`: ci: Remove cibuildwheel override for win_arm64/Py3.14 -* :ghpull:`30461`: Backport PR #30451 on branch v3.10.x (doc: factor out quick install tab for reuse) -* :ghpull:`30448`: Backport PR #30412 on branch v3.10.x ({Check,Radio}Buttons: Improve docs of label_props) -* :ghpull:`30412`: {Check,Radio}Buttons: Improve docs of label_props -* :ghpull:`30445`: Backport PR #30444 on branch v3.10.x (Small correction of a typo in the galleries: axis instead of axes) -* :ghpull:`30444`: Small correction of a typo in the galleries: axis instead of axes -* :ghpull:`30430`: Backport PR #30426 on branch v3.10.x (Fix a race condition in TexManager.make_dvi.) -* :ghpull:`30434`: Backport PR #30426: Fix a race condition in TexManager.make_dvi & make_png. -* :ghpull:`30431`: Use pathlib in texmanager. -* :ghpull:`30428`: Backport PR #30399 on branch v3.10.x (Qt: Fix HiDPI handling on X11/Windows) -* :ghpull:`30426`: Fix a race condition in TexManager.make_dvi. -* :ghpull:`30399`: Qt: Fix HiDPI handling on X11/Windows -* :ghpull:`30415`: Backport PR #30414 on branch v3.10.x (DOC: update Cartopy url) -* :ghpull:`30414`: DOC: update Cartopy url +* :ghpull:`30628`: Backport PR #30626 on branch v3.10.x (MNT: Fix new F401 unused imports warnings) +* :ghpull:`30626`: MNT: Fix new F401 unused imports warnings +* :ghpull:`30589`: Backport PR #29745: Use PEP8 style method and function names from +* :ghpull:`30614`: Backport PR #30612 on branch v3.10.x (MNT: update black pin) +* :ghpull:`30612`: MNT: update black pin +* :ghpull:`30572`: Backport PR #30571 on branch v3.10.x (CI: remove macos13) +* :ghpull:`30571`: CI: remove macos13 +* :ghpull:`30570`: Backport PR #30558 on branch v3.10.x (Fix stubtest with mypy 18) +* :ghpull:`30558`: Fix stubtest with mypy 18 +* :ghpull:`30540`: Backport PR #30539 on branch v3.10.x (Fix scale_unit/scale_units typo in quiver docs) +* :ghpull:`30539`: Fix scale_unit/scale_units typo in quiver docs +* :ghpull:`30518`: Backport PR #30497 on branch v3.10.x (TST: Use a temporary directory for test_save_figure_return) +* :ghpull:`30497`: TST: Use a temporary directory for test_save_figure_return +* :ghpull:`30506`: Backport PR #30490 on branch v3.10.x (Fix SVG rendering error in def update_background) +* :ghpull:`30490`: Fix SVG rendering error in def update_background +* :ghpull:`30494`: Backport PR #30492 on branch v3.10.x (DOC: pytz link should be from PyPI) Issues (4): -* :ghissue:`29618`: [Bug]: FigureCanvasQT is seemingly prematurely freed under certain conditions -* :ghissue:`30390`: [ENH]: Gracefully handle python-build-standalone ImportError -* :ghissue:`30420`: [ENH]: Support parallel plotting -* :ghissue:`30386`: BUG: Qt hi-dpi regression on windows and X11 with mpl 3.10.5 +* :ghissue:`30611`: [MNT]: black version +* :ghissue:`30551`: [Bug]: Mypy stubtest failure on disjoint_base +* :ghissue:`30493`: [Bug]: test_save_figure_return seems flaky +* :ghissue:`30485`: [Bug]: figures with SpanSelector(..., useblit=True) can't be saved to SVG or PDF Previous GitHub statistics diff --git a/doc/users/prev_whats_new/github_stats_3.10.6.rst b/doc/users/prev_whats_new/github_stats_3.10.6.rst new file mode 100644 index 000000000000..fb88af0ae10f --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.10.6.rst @@ -0,0 +1,76 @@ +.. _github-stats-3_10_6: + +GitHub statistics for 3.10.6 (Aug 29, 2025) +=========================================== + +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/08/29 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 4 issues and merged 19 pull requests. +The full list can be seen `on GitHub `__ + +The following 31 authors contributed 380 commits. + +* Alan Burlot +* Antony Lee +* Christine P. Chai +* David Stansby +* dependabot[bot] +* Doron Behar +* Elliott Sales de Andrade +* G.D. McBain +* Greg Lucas +* hannah +* hu-xiaonan +* Ian Thomas +* Inês Cachola +* Jody Klymak +* Jouni K. Seppänen +* Khushi_29 +* Kyle Sunden +* Lumberbot (aka Jack) +* N R Navaneet +* Nathan G. Wiseman +* Oscar Gustafsson +* Praful Gulani +* Qian Zhang +* Raphael Erik Hviding +* Roman +* Ruth Comer +* saikarna913 +* Scott Shambaugh +* Thomas A Caswell +* Tim Hoffmann +* Trygve Magnus Ræder + +GitHub issues and pull requests: + +Pull Requests (19): + +* :ghpull:`30487`: Backport PR #30484 on branch v3.10.x (FIX: be more cautious about checking widget size) +* :ghpull:`30484`: FIX: be more cautious about checking widget size +* :ghpull:`30481`: Backport PR #30394 on branch v3.10.x (ENH: Gracefully handle python-build-standalone ImportError with Tk) +* :ghpull:`30477`: Backport PR #30476 on branch v3.10.x (ci: Remove cibuildwheel override for win_arm64/Py3.14) +* :ghpull:`30394`: ENH: Gracefully handle python-build-standalone ImportError with Tk +* :ghpull:`30476`: ci: Remove cibuildwheel override for win_arm64/Py3.14 +* :ghpull:`30461`: Backport PR #30451 on branch v3.10.x (doc: factor out quick install tab for reuse) +* :ghpull:`30448`: Backport PR #30412 on branch v3.10.x ({Check,Radio}Buttons: Improve docs of label_props) +* :ghpull:`30412`: {Check,Radio}Buttons: Improve docs of label_props +* :ghpull:`30445`: Backport PR #30444 on branch v3.10.x (Small correction of a typo in the galleries: axis instead of axes) +* :ghpull:`30444`: Small correction of a typo in the galleries: axis instead of axes +* :ghpull:`30430`: Backport PR #30426 on branch v3.10.x (Fix a race condition in TexManager.make_dvi.) +* :ghpull:`30434`: Backport PR #30426: Fix a race condition in TexManager.make_dvi & make_png. +* :ghpull:`30431`: Use pathlib in texmanager. +* :ghpull:`30428`: Backport PR #30399 on branch v3.10.x (Qt: Fix HiDPI handling on X11/Windows) +* :ghpull:`30426`: Fix a race condition in TexManager.make_dvi. +* :ghpull:`30399`: Qt: Fix HiDPI handling on X11/Windows +* :ghpull:`30415`: Backport PR #30414 on branch v3.10.x (DOC: update Cartopy url) +* :ghpull:`30414`: DOC: update Cartopy url + +Issues (4): + +* :ghissue:`29618`: [Bug]: FigureCanvasQT is seemingly prematurely freed under certain conditions +* :ghissue:`30390`: [ENH]: Gracefully handle python-build-standalone ImportError +* :ghissue:`30420`: [ENH]: Support parallel plotting +* :ghissue:`30386`: BUG: Qt hi-dpi regression on windows and X11 with mpl 3.10.5 From 4aeb773422464799998d900198b35cb80e94b3e1 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 8 Oct 2025 17:46:31 -0500 Subject: [PATCH 135/228] REL: v3.10.7 This is the latest bugfix release in the 3.10.x series. The most important update in this release is that the minimum version of `pyparsing` has been updated to version 3.0. From 25693547e4af574c7c2c508ce2bd5ca1e7c911ed Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 8 Oct 2025 17:50:28 -0500 Subject: [PATCH 136/228] REL: Bump from v3.10.7 tag From c8125fe88227db007d1a2b739267ec96bc9c1cd5 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 8 Oct 2025 17:58:01 -0500 Subject: [PATCH 137/228] Zenodo v3.10.7 --- doc/_static/switcher.json | 6 +++--- doc/api/next_api_changes/development/29745-DS.rst | 4 ---- doc/project/citing.rst | 3 +++ doc/users/release_notes.rst | 2 ++ tools/cache_zenodo_svg.py | 1 + 5 files changed, 9 insertions(+), 7 deletions(-) delete mode 100644 doc/api/next_api_changes/development/29745-DS.rst diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json index 2d02ec01ae56..3912afd8a17d 100644 --- a/doc/_static/switcher.json +++ b/doc/_static/switcher.json @@ -1,7 +1,7 @@ [ { "name": "3.10 (stable)", - "version": "3.10.6", + "version": "3.10.7", "url": "https://matplotlib.org/stable/", "preferred": true }, @@ -12,8 +12,8 @@ }, { "name": "3.9", - "version": "3.9.4", - "url": "https://matplotlib.org/3.9.4/" + "version": "3.9.3", + "url": "https://matplotlib.org/3.9.3/" }, { "name": "3.8", diff --git a/doc/api/next_api_changes/development/29745-DS.rst b/doc/api/next_api_changes/development/29745-DS.rst deleted file mode 100644 index 7d9b1c2b143b..000000000000 --- a/doc/api/next_api_changes/development/29745-DS.rst +++ /dev/null @@ -1,4 +0,0 @@ -New minimum version of pyparsing -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The minimum required version of ``pyparsing`` has been updated from 2.3.1 to 3.0.0. diff --git a/doc/project/citing.rst b/doc/project/citing.rst index ae2061e7349c..c5e56e6f12d4 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,9 @@ By version .. START OF AUTOGENERATED +v3.10.7 + .. image:: ../_static/zenodo_cache/17298696.svg + :target: https://doi.org/10.5281/zenodo.17298696 v3.10.6 .. image:: ../_static/zenodo_cache/16999430.svg :target: https://doi.org/10.5281/zenodo.16999430 diff --git a/doc/users/release_notes.rst b/doc/users/release_notes.rst index 1ca483ab15a2..ea0541d5f04b 100644 --- a/doc/users/release_notes.rst +++ b/doc/users/release_notes.rst @@ -18,9 +18,11 @@ Version 3.10 :maxdepth: 1 prev_whats_new/whats_new_3.10.0.rst + ../api/prev_api_changes/api_changes_3.10.7.rst ../api/prev_api_changes/api_changes_3.10.1.rst ../api/prev_api_changes/api_changes_3.10.0.rst github_stats.rst + prev_whats_new/github_stats_3.10.6.rst prev_whats_new/github_stats_3.10.5.rst prev_whats_new/github_stats_3.10.3.rst prev_whats_new/github_stats_3.10.1.rst diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index c6783dd9f19d..07b67a3e04ee 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,7 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.7": "17298696", "v3.10.6": "16999430", "v3.10.5": "16644850", "v3.10.3": "15375714", From 1f3ab23ea727907edeb401bcf91bf24cbd2c3e2f Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 8 Oct 2025 17:59:33 -0500 Subject: [PATCH 138/228] Zenodo v3.10.7 --- doc/_static/zenodo_cache/17298696.svg | 35 +++++++++++++++++++ .../prev_api_changes/api_changes_3.10.7.rst | 10 ++++++ 2 files changed, 45 insertions(+) create mode 100644 doc/_static/zenodo_cache/17298696.svg create mode 100644 doc/api/prev_api_changes/api_changes_3.10.7.rst diff --git a/doc/_static/zenodo_cache/17298696.svg b/doc/_static/zenodo_cache/17298696.svg new file mode 100644 index 000000000000..9aa8d7c94349 --- /dev/null +++ b/doc/_static/zenodo_cache/17298696.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.17298696 + + + 10.5281/zenodo.17298696 + + + \ No newline at end of file diff --git a/doc/api/prev_api_changes/api_changes_3.10.7.rst b/doc/api/prev_api_changes/api_changes_3.10.7.rst new file mode 100644 index 000000000000..16b00597bbfb --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.10.7.rst @@ -0,0 +1,10 @@ +API Changes for 3.10.1 +====================== + +Development +----------- + +New minimum version of pyparsing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The minimum required version of ``pyparsing`` has been updated from 2.3.1 to 3.0.0. From 7bfa393fb41dcce6cab73736e153efc0c985935f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 10 Oct 2025 10:57:00 -0400 Subject: [PATCH 139/228] DOC: fix version string Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- doc/api/prev_api_changes/api_changes_3.10.7.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/prev_api_changes/api_changes_3.10.7.rst b/doc/api/prev_api_changes/api_changes_3.10.7.rst index 16b00597bbfb..a60061e86277 100644 --- a/doc/api/prev_api_changes/api_changes_3.10.7.rst +++ b/doc/api/prev_api_changes/api_changes_3.10.7.rst @@ -1,4 +1,4 @@ -API Changes for 3.10.1 +API Changes for 3.10.7 ====================== Development From 0f60f0b774088b1c119bf8b849c64896933d83da Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Wed, 15 Oct 2025 08:01:04 +0100 Subject: [PATCH 140/228] Stale action: sort issues by last updated --- .github/workflows/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 624792ed171a..3eb68b5b10ca 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -36,3 +36,4 @@ jobs: ascending: true exempt-issue-labels: "keep" exempt-pr-labels: "keep,status: orphaned PR" + sort-by: updated From a72f03b60da7160768ccb0cb03faba760f02e2dd Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Tue, 6 Feb 2024 13:36:02 +0000 Subject: [PATCH 141/228] simplify ContourSet.draw --- lib/matplotlib/contour.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 7fd0d3100889..dfc39ed664f9 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -1268,7 +1268,8 @@ def draw(self, renderer): if edgecolors.size == 0: edgecolors = ("none",) for idx in range(n_paths): - with cbook._setattr_cm(self, _paths=[paths[idx]]), self._cm_set( + with self._cm_set( + paths=[paths[idx]], hatch=self.hatches[idx % len(self.hatches)], array=[self.get_array()[idx]], linewidths=[self.get_linewidths()[idx % len(self.get_linewidths())]], From 11fc120d4a22a4ef449823c1c2eb3dc680836fc9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 17 Sep 2024 09:22:10 -0400 Subject: [PATCH 142/228] MNT: improve how we manage the cache for font metrics Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_text.py | 40 +++++++++++++- lib/matplotlib/text.py | 90 +++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 02cecea1c6c6..66586f52312a 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -1,4 +1,6 @@ from datetime import datetime +import gc +import inspect import io import warnings @@ -874,7 +876,12 @@ def test_pdf_chars_beyond_bmp(): @needs_usetex def test_metrics_cache(): - mpl.text._get_text_metrics_with_cache_impl.cache_clear() + # dig into the signature to get the mutable default used as a cache + renderer_cache = inspect.signature( + mpl.text._get_text_metrics_function + ).parameters['_cache'].default + + renderer_cache.clear() fig = plt.figure() fig.text(.3, .5, "foo\nbar") @@ -882,6 +889,8 @@ def test_metrics_cache(): fig.text(.5, .5, "foo\nbar", usetex=True) fig.canvas.draw() renderer = fig._get_renderer() + assert renderer in renderer_cache + ys = {} # mapping of strings to where they were drawn in y with draw_tex. def call(*args, **kwargs): @@ -897,12 +906,39 @@ def call(*args, **kwargs): # get incorrectly reused by the first TeX string. assert len(ys["foo"]) == len(ys["bar"]) == 1 - info = mpl.text._get_text_metrics_with_cache_impl.cache_info() + info = renderer_cache[renderer].cache_info() # Every string gets a miss for the first layouting (extents), then a hit # when drawing, but "foo\nbar" gets two hits as it's drawn twice. assert info.hits > info.misses +def test_metrics_cache2(): + # dig into the signature to get the mutable default used as a cache + renderer_cache = inspect.signature( + mpl.text._get_text_metrics_function + ).parameters['_cache'].default + gc.collect() + assert len(renderer_cache) == 0 + + def helper(): + fig, ax = plt.subplots() + fig.draw_without_rendering() + # show we hit the outer cache + assert len(renderer_cache) == 1 + func = renderer_cache[fig.canvas.get_renderer()] + cache_info = func.cache_info() + # show we hit the inner cache + assert cache_info.currsize > 0 + assert cache_info.currsize == cache_info.misses + assert cache_info.hits > cache_info.misses + plt.close(fig) + + helper() + gc.collect() + # show the outer cache has a lifetime tied to the renderer (via the figure) + assert len(renderer_cache) == 0 + + def test_annotate_offset_fontsize(): # Test that offset_fontsize parameter works and uses accurate values fig, ax = plt.subplots() diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index acde4fb179a2..0be32ca86009 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -64,17 +64,89 @@ def _get_textbox(text, renderer): def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): """Call ``renderer.get_text_width_height_descent``, caching the results.""" - # Cached based on a copy of fontprop so that later in-place mutations of - # the passed-in argument do not mess up the cache. - return _get_text_metrics_with_cache_impl( - weakref.ref(renderer), text, fontprop.copy(), ismath, dpi) + # hit the outer cache layer and get the function to compute the metrics + # for this renderer instance + get_text_metrics = _get_text_metrics_function(renderer) + # call the function to compute the metrics and return + # + # We pass a copy of the fontprop because FontProperties is both mutable and + # has a `__hash__` that depends on that mutable state. This is not ideal + # as it means the hash of an object is not stable over time which leads to + # very confusing behavior when used as keys in dictionaries or hashes. + return get_text_metrics(text, fontprop.copy(), ismath, dpi) -@functools.lru_cache(4096) -def _get_text_metrics_with_cache_impl( - renderer_ref, text, fontprop, ismath, dpi): - # dpi is unused, but participates in cache invalidation (via the renderer). - return renderer_ref().get_text_width_height_descent(text, fontprop, ismath) + +def _get_text_metrics_function(input_renderer, _cache=weakref.WeakKeyDictionary()): + """ + Helper function to provide a two-layered cache for font metrics + + + To get the rendered size of a size of string we need to know: + - what renderer we are using + - the current dpi of the renderer + - the string + - the font properties + - is it math text or not + + We do this as a two-layer cache with the outer layer being tied to a + renderer instance and the inner layer handling everything else. + + The outer layer is implemented as `.WeakKeyDictionary` keyed on the + renderer. As long as someone else is holding a hard ref to the renderer + we will keep the cache alive, but it will be automatically dropped when + the renderer is garbage collected. + + The inner layer is provided by an lru_cache with a large maximum size (such + that we expect very few cache misses in actual use cases). As the + dpi is mutable on the renderer, we need to explicitly include it as part of + the cache key on the inner layer even though we do not directly use it (it is + used in the method call on the renderer). + + This function takes a renderer and returns a function that can be used to + get the font metrics. + + Parameters + ---------- + input_renderer : maplotlib.backend_bases.RendererBase + The renderer to set the cache up for. + + _cache : dict, optional + We are using the mutable default value to attach the cache to the function. + + In principle you could pass a different dict-like to this function to inject + a different cache, but please don't. This is an internal function not meant to + be reused outside of the narrow context we need it for. + + There is a possible race condition here between threads, we may need to drop the + mutable default and switch to a threadlocal variable in the future. + + """ + if (_text_metrics := _cache.get(input_renderer, None)) is None: + # We are going to include this in the closure we put as values in the + # cache. Closing over a hard-ref would create an unbreakable reference + # cycle. + renderer_ref = weakref.ref(input_renderer) + + # define the function locally to get a new lru_cache per renderer + @functools.lru_cache(4096) + # dpi is unused, but participates in cache invalidation (via the renderer). + def _text_metrics(text, fontprop, ismath, dpi): + # this should never happen under normal use, but this is a better error to + # raise than an AttributeError on `None` + if (local_renderer := renderer_ref()) is None: + raise RuntimeError( + "Trying to get text metrics for a renderer that no longer exists. " + "This should never happen and is evidence of a bug elsewhere." + ) + # do the actual method call we need and return the result + return local_renderer.get_text_width_height_descent(text, fontprop, ismath) + + # stash the function for later use. + _cache[input_renderer] = _text_metrics + + # return the inner function + return _text_metrics @_docstring.interpd From 94d6fc825db3193b79f69e8e3471506d5f161abd Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 11 Sep 2025 14:00:35 -0400 Subject: [PATCH 143/228] TST: clear renderer cache in tests Only on windows test_metrics_cache2 was starting with 2 renderers in the cache. Given that his is only happening on windows (and not osx/linux) on CI going with the assumption that these is something being held alive longer than we expect someplace else in the tests. --- lib/matplotlib/tests/test_text.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 66586f52312a..97b37b91f697 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -918,7 +918,7 @@ def test_metrics_cache2(): mpl.text._get_text_metrics_function ).parameters['_cache'].default gc.collect() - assert len(renderer_cache) == 0 + renderer_cache.clear() def helper(): fig, ax = plt.subplots() From 3ecf087ab73bd79f948fb03861c1c870901dd1fa Mon Sep 17 00:00:00 2001 From: heinrich5991 Date: Fri, 17 Oct 2025 09:03:46 +0200 Subject: [PATCH 144/228] Fix `AttributeError: module 'gi' has no attribute 'require_version'` On Arch Linux, automatic backend selection fails when [`at-spi2-core`](https://archlinux.org/packages/extra/x86_64/at-spi2-core/files/) is installed but [`python-gobject`](https://archlinux.org/packages/extra/x86_64/python-gobject/files/) is not. Detect this by importign `gi.require_version` directly. ```python >>> import matplotlib.pyplot as plt >>> plt.plot([0, 1], [0, 1]) Traceback (most recent call last): File "", line 1, in plt.plot([0, 1], [0, 1]) ~~~~~~~~^^^^^^^^^^^^^^^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 3838, in plot return gca().plot( ~~~^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 2785, in gca return gcf().gca() ~~~^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 1108, in gcf return figure() File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 1042, in figure manager = new_figure_manager( num, figsize=figsize, dpi=dpi, facecolor=facecolor, edgecolor=edgecolor, frameon=frameon, FigureClass=FigureClass, **kwargs) File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 551, in new_figure_manager _warn_if_gui_out_of_main_thread() ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 528, in _warn_if_gui_out_of_main_thread canvas_class = cast(type[FigureCanvasBase], _get_backend_mod().FigureCanvas) ~~~~~~~~~~~~~~~~^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 369, in _get_backend_mod switch_backend(rcParams._get("backend")) ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 411, in switch_backend switch_backend(candidate) ~~~~~~~~~~~~~~^^^^^^^^^^^ File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 425, in switch_backend module = backend_registry.load_backend_module(newbackend) File "/usr/lib/python3.13/site-packages/matplotlib/backends/registry.py", line 317, in load_backend_module return importlib.import_module(module_name) ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^ File "/usr/lib/python3.13/importlib/__init__.py", line 88, in import_module return _bootstrap._gcd_import(name[level:], package, level) ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "", line 1387, in _gcd_import File "", line 1360, in _find_and_load File "", line 1331, in _find_and_load_unlocked File "", line 935, in _load_unlocked File "", line 1026, in exec_module File "", line 488, in _call_with_frames_removed File "/usr/lib/python3.13/site-packages/matplotlib/backends/backend_gtk4agg.py", line 4, in from . import backend_agg, backend_gtk4 File "/usr/lib/python3.13/site-packages/matplotlib/backends/backend_gtk4.py", line 19, in gi.require_version("Gtk", "4.0") ^^^^^^^^^^^^^^^^^^ AttributeError: module 'gi' has no attribute 'require_version' ``` Fixes #30654. --- lib/matplotlib/backends/backend_gtk3.py | 6 +++--- lib/matplotlib/backends/backend_gtk4.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 810429d656d0..9c6d6250f486 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -10,18 +10,18 @@ CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) try: - import gi + from gi import require_version as gi_require_version except ImportError as err: raise ImportError("The GTK3 backends require PyGObject") from err try: # :raises ValueError: If module/version is already loaded, already # required, or unavailable. - gi.require_version("Gtk", "3.0") + gi_require_version("Gtk", "3.0") # Also require GioUnix to avoid PyGIWarning when Gio is imported # GioUnix is platform-specific and may not be available on all systems try: - gi.require_version("GioUnix", "2.0") + gi_require_version("GioUnix", "2.0") except ValueError: # GioUnix is not available on this platform, which is fine pass diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index a45fa0bc490f..2fe2115b73cf 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -9,18 +9,18 @@ KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent) try: - import gi + from gi import require_version as gi_require_version except ImportError as err: raise ImportError("The GTK4 backends require PyGObject") from err try: # :raises ValueError: If module/version is already loaded, already # required, or unavailable. - gi.require_version("Gtk", "4.0") + gi_require_version("Gtk", "4.0") # Also require GioUnix to avoid PyGIWarning when Gio is imported # GioUnix is platform-specific and may not be available on all systems try: - gi.require_version("GioUnix", "2.0") + gi_require_version("GioUnix", "2.0") except ValueError: # GioUnix is not available on this platform, which is fine pass @@ -29,6 +29,7 @@ # auto-backend selection logic correctly skips. raise ImportError(e) from e +import gi from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf from . import _backend_gtk from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 From 563f3de5029690c31db6b6f9336f41dbcc428faa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:03:04 +0000 Subject: [PATCH 145/228] Bump the actions group across 1 directory with 6 updates Bumps the actions group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) | `3.1.4` | `3.2.0` | | [scientific-python/circleci-artifacts-redirector-action](https://github.com/scientific-python/circleci-artifacts-redirector-action) | `1.2.0` | `1.3.1` | | [github/codeql-action](https://github.com/github/codeql-action) | `3.30.3` | `3.30.6` | | [actions/cache](https://github.com/actions/cache) | `4.2.4` | `4.3.0` | | [peter-evans/create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) | `4.0.0` | `5.0.0` | | [actions/stale](https://github.com/actions/stale) | `10.0.0` | `10.1.0` | Updates `pypa/cibuildwheel` from 3.1.4 to 3.2.0 - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/c923d83ad9c1bc00211c5041d0c3f73294ff88f6...7c619efba910c04005a835b110b057fc28fd6e93) Updates `scientific-python/circleci-artifacts-redirector-action` from 1.2.0 to 1.3.1 - [Release notes](https://github.com/scientific-python/circleci-artifacts-redirector-action/releases) - [Commits](https://github.com/scientific-python/circleci-artifacts-redirector-action/compare/839631420e45a08af893032e5a5e8843bf47e8ff...5d358ff96e96429a5c64a969bb4a574555439f4f) Updates `github/codeql-action` from 3.30.3 to 3.30.6 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/192325c86100d080feab897ff886c34abd4c83a3...64d10c13136e1c5bce3e5fbde8d4906eeaafc885) Updates `actions/cache` from 4.2.4 to 4.3.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/0400d5f644dc74513175e3cd8d07132dd4860809...0057852bfaa89a56745cba8c7296529d2fc39830) Updates `peter-evans/create-or-update-comment` from 4.0.0 to 5.0.0 - [Release notes](https://github.com/peter-evans/create-or-update-comment/releases) - [Commits](https://github.com/peter-evans/create-or-update-comment/compare/71345be0265236311c031f5c7866368bd1eff043...e8674b075228eee787fea43ef493e45ece1004c9) Updates `actions/stale` from 10.0.0 to 10.1.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/3a9db7e6a41a89f618792c92c0e97cc736e1b13f...5f858e3efba33a5ca4407a664cc011ad407f2008) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: scientific-python/circleci-artifacts-redirector-action dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action dependency-version: 3.30.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/cache dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: peter-evans/create-or-update-comment dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/stale dependency-version: 10.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/cibuildwheel.yml | 10 +++++----- .github/workflows/circleci.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/cygwin.yml | 6 +++--- .github/workflows/good-first-issue.yml | 2 +- .github/workflows/stale-tidy.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/tests.yml | 8 ++++---- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 8ec11e30e122..579c14bf0f1d 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -143,7 +143,7 @@ jobs: path: dist/ - name: Build wheels for CPython 3.14 - uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -153,7 +153,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - name: Build wheels for CPython 3.13 - uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -162,7 +162,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.12 - uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -170,7 +170,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.11 - uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -178,7 +178,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for PyPy - uses: pypa/cibuildwheel@c923d83ad9c1bc00211c5041d0c3f73294ff88f6 # v3.1.4 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index 9a2516efd4bb..ab410b194754 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: GitHub Action step uses: - scientific-python/circleci-artifacts-redirector-action@839631420e45a08af893032e5a5e8843bf47e8ff # v1.2.0 + scientific-python/circleci-artifacts-redirector-action@5d358ff96e96429a5c64a969bb4a574555439f4f # v1.3.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e5c31400d72d..96115add7bd5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: languages: ${{ matrix.language }} @@ -43,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml index 071368531d3f..ba1fd6f57790 100644 --- a/.github/workflows/cygwin.yml +++ b/.github/workflows/cygwin.yml @@ -140,21 +140,21 @@ jobs: # FreeType build fails with bash, succeeds with dash - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: C:\cygwin\home\runneradmin\.cache\pip key: Cygwin-py3.${{ matrix.python-minor-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} restore-keys: ${{ matrix.os }}-py3.${{ matrix.python-minor-version }}-pip- - name: Cache ccache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: C:\cygwin\home\runneradmin\.ccache key: Cygwin-py3.${{ matrix.python-minor-version }}-ccache-${{ hashFiles('src/*') }} restore-keys: Cygwin-py3.${{ matrix.python-minor-version }}-ccache- - name: Cache Matplotlib - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | C:\cygwin\home\runneradmin\.cache\matplotlib diff --git a/.github/workflows/good-first-issue.yml b/.github/workflows/good-first-issue.yml index cc15717e3351..5bb69db0595c 100644 --- a/.github/workflows/good-first-issue.yml +++ b/.github/workflows/good-first-issue.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Add comment - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/.github/workflows/stale-tidy.yml b/.github/workflows/stale-tidy.yml index 09b9cc49a8f8..85c8fec38e63 100644 --- a/.github/workflows/stale-tidy.yml +++ b/.github/workflows/stale-tidy.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 300 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3eb68b5b10ca..bbc58eca4fb0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 20 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ed83a65579b..1419687b58d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -178,7 +178,7 @@ jobs: esac - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: startsWith(runner.os, 'Linux') with: path: ~/.cache/pip @@ -186,7 +186,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: startsWith(runner.os, 'macOS') with: path: ~/Library/Caches/pip @@ -194,7 +194,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache ccache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.ccache @@ -202,7 +202,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-ccache- - name: Cache Matplotlib - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cache/matplotlib From ab06772d3d1b6e0d2dfa3b6cd5a15c6be7a16504 Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Tue, 21 Oct 2025 15:32:56 -0700 Subject: [PATCH 146/228] DOC: Lets -> Let's [ci doc] --- lib/matplotlib/tests/test_dates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 8ee12131fdbe..23b35e78c1f5 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -275,7 +275,7 @@ def test_DateFormatter(): import matplotlib.testing.jpl_units as units units.register() - # Lets make sure that DateFormatter will allow us to have tick marks + # Let's make sure that DateFormatter will allow us to have tick marks # at intervals of fractional seconds. t0 = datetime.datetime(2001, 1, 1, 0, 0, 0) From ce0f0703c2522ce241ef99e68b66e4ee5b3f0c60 Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Tue, 21 Oct 2025 15:33:36 -0700 Subject: [PATCH 147/228] DOC: Fix typo in test_sankey.py comment [ci docs] --- lib/matplotlib/tests/test_sankey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py index 253bfa4fa093..b562c35339b8 100644 --- a/lib/matplotlib/tests/test_sankey.py +++ b/lib/matplotlib/tests/test_sankey.py @@ -6,7 +6,7 @@ def test_sankey(): - # lets just create a sankey instance and check the code runs + # Let's just create a sankey instance and check the code runs sankey = Sankey() sankey.add() From bd81e73c44f4b4ce1682ba71bf5bd480850892fd Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Tue, 21 Oct 2025 15:33:56 -0700 Subject: [PATCH 148/228] Fix comment casing in test_sankey.py [ci docs] --- lib/matplotlib/tests/test_sankey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py index b562c35339b8..745db5f767b2 100644 --- a/lib/matplotlib/tests/test_sankey.py +++ b/lib/matplotlib/tests/test_sankey.py @@ -6,7 +6,7 @@ def test_sankey(): - # Let's just create a sankey instance and check the code runs + # let's just create a sankey instance and check the code runs sankey = Sankey() sankey.add() From adf3798d5079ead77650c3bf37cd02e57aa5e384 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 21 Oct 2025 03:56:43 -0400 Subject: [PATCH 149/228] cibw: Switch macos 13 to 15 Intel The former is deprecated. --- .github/workflows/cibuildwheel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 8ec11e30e122..a783533328e7 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -130,7 +130,7 @@ jobs: cibw_archs: "AMD64" - os: windows-11-arm cibw_archs: "ARM64" - - os: macos-13 + - os: macos-15-intel cibw_archs: "x86_64" - os: macos-14 cibw_archs: "arm64" From 01c667d55fc8149132287be58bef9543fd46cbca Mon Sep 17 00:00:00 2001 From: Rafael Katri Date: Wed, 22 Oct 2025 14:53:48 -0300 Subject: [PATCH 150/228] Use pathlib.Path instead of matplotlib.path.Path in text.pyi --- lib/matplotlib/text.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index 41c7b761ae32..7223693945ec 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -2,7 +2,7 @@ from .artist import Artist from .backend_bases import RendererBase from .font_manager import FontProperties from .offsetbox import DraggableAnnotation -from .path import Path +from pathlib import Path from .patches import FancyArrowPatch, FancyBboxPatch from .textpath import ( # noqa: F401, reexported API TextPath as TextPath, From 29e75c2c60c278e59fc6a05d261f28e991661d26 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 18 Sep 2025 03:09:37 -0400 Subject: [PATCH 151/228] Revert "Merge pull request #30535 from LangQi99/fix/import" Upstream has fixed PyGObject in 3.51.1. This reverts commit 75e78023aa2ff2bcde6af9d906b95025cf2c96b5, reversing changes made to 352b419b2b16e2ad06748cbdc44d26fb2196859c. --- lib/matplotlib/backends/backend_gtk3.py | 7 ------- lib/matplotlib/backends/backend_gtk4.py | 7 ------- 2 files changed, 14 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 9c6d6250f486..b53c272e65e8 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -18,13 +18,6 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "3.0") - # Also require GioUnix to avoid PyGIWarning when Gio is imported - # GioUnix is platform-specific and may not be available on all systems - try: - gi_require_version("GioUnix", "2.0") - except ValueError: - # GioUnix is not available on this platform, which is fine - pass except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 2fe2115b73cf..e3d3548c1cd1 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -17,13 +17,6 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "4.0") - # Also require GioUnix to avoid PyGIWarning when Gio is imported - # GioUnix is platform-specific and may not be available on all systems - try: - gi_require_version("GioUnix", "2.0") - except ValueError: - # GioUnix is not available on this platform, which is fine - pass except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. From e386fb89328860bfff7eaa337e516529abc3afbc Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 8 Sep 2025 21:50:33 -0400 Subject: [PATCH 152/228] gtk: Add more explicit version requirements This adds requirements for all of Gdk/GdkPixbuf before using them, as we do for Gtk. Note that Gio/GLib/GObject are already accepted without version by PyGObject as they are tied to the version it's compiled against. Fixes #30525 --- lib/matplotlib/backends/backend_gtk3.py | 1 + lib/matplotlib/backends/backend_gtk4.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index b53c272e65e8..20a1a3c8f0a9 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -18,6 +18,7 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "3.0") + gi_require_version("Gdk", "3.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index e3d3548c1cd1..95b116e9a6ba 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -17,6 +17,8 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "4.0") + gi_require_version("Gdk", "4.0") + gi_require_version("GdkPixbuf", "2.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. From ec9f001e1958156e2195763cce0e1d1047d35dd6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Sep 2025 01:21:49 -0400 Subject: [PATCH 153/228] TST: Remove test_backend_gtk3::test_correct_key This test has been an expected failure since it was created, and in that time, `gtk_test_widget_send_key` was never made to work, nor was a replacement found. In GTK4, that API doesn't exist either. --- lib/matplotlib/tests/test_backend_gtk3.py | 45 ----------------------- 1 file changed, 45 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_gtk3.py b/lib/matplotlib/tests/test_backend_gtk3.py index b4c6e3d7fca8..a299d21a4b7b 100644 --- a/lib/matplotlib/tests/test_backend_gtk3.py +++ b/lib/matplotlib/tests/test_backend_gtk3.py @@ -5,51 +5,6 @@ from unittest import mock -@pytest.mark.backend("gtk3agg", skip_on_importerror=True) -def test_correct_key(): - pytest.xfail("test_widget_send_event is not triggering key_press_event") - - from gi.repository import Gdk, Gtk # type: ignore[import] - fig = plt.figure() - buf = [] - - def send(event): - for key, mod in [ - (Gdk.KEY_a, Gdk.ModifierType.SHIFT_MASK), - (Gdk.KEY_a, 0), - (Gdk.KEY_a, Gdk.ModifierType.CONTROL_MASK), - (Gdk.KEY_agrave, 0), - (Gdk.KEY_Control_L, Gdk.ModifierType.MOD1_MASK), - (Gdk.KEY_Alt_L, Gdk.ModifierType.CONTROL_MASK), - (Gdk.KEY_agrave, - Gdk.ModifierType.CONTROL_MASK - | Gdk.ModifierType.MOD1_MASK - | Gdk.ModifierType.MOD4_MASK), - (0xfd16, 0), # KEY_3270_Play. - (Gdk.KEY_BackSpace, 0), - (Gdk.KEY_BackSpace, Gdk.ModifierType.CONTROL_MASK), - ]: - # This is not actually really the right API: it depends on the - # actual keymap (e.g. on Azerty, shift+agrave -> 0). - Gtk.test_widget_send_key(fig.canvas, key, mod) - - def receive(event): - buf.append(event.key) - if buf == [ - "A", "a", "ctrl+a", - "\N{LATIN SMALL LETTER A WITH GRAVE}", - "alt+control", "ctrl+alt", - "ctrl+alt+super+\N{LATIN SMALL LETTER A WITH GRAVE}", - # (No entry for KEY_3270_Play.) - "backspace", "ctrl+backspace", - ]: - plt.close(fig) - - fig.canvas.mpl_connect("draw_event", send) - fig.canvas.mpl_connect("key_press_event", receive) - plt.show() - - @pytest.mark.backend("gtk3agg", skip_on_importerror=True) def test_save_figure_return(): from gi.repository import Gtk From eafc58f02d1105983f2a60a87a6e7b0d9400ad77 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Sep 2025 02:22:26 -0400 Subject: [PATCH 154/228] TST: Run test_getattr for backends in a subprocess For example, GTK3 and GTK4 conflict, and which one is tested depends on which one loads first. Running in a subprocess ensures that both work, but we only do that for backends as adding ~100 more subprocesses is overkill. --- lib/matplotlib/tests/test_getattr.py | 59 +++++++++++++++++++++------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/tests/test_getattr.py b/lib/matplotlib/tests/test_getattr.py index f0f5823600ca..fe302220067a 100644 --- a/lib/matplotlib/tests/test_getattr.py +++ b/lib/matplotlib/tests/test_getattr.py @@ -1,25 +1,29 @@ from importlib import import_module from pkgutil import walk_packages +import sys +import warnings -import matplotlib import pytest +import matplotlib +from matplotlib.testing import is_ci_environment, subprocess_run_helper + # Get the names of all matplotlib submodules, # except for the unit tests and private modules. -module_names = [ - m.name - for m in walk_packages( - path=matplotlib.__path__, prefix=f'{matplotlib.__name__}.' - ) - if not m.name.startswith(__package__) - and not any(x.startswith('_') for x in m.name.split('.')) -] +module_names = [] +backend_module_names = [] +for m in walk_packages(path=matplotlib.__path__, prefix=f'{matplotlib.__name__}.'): + if m.name.startswith(__package__): + continue + if any(x.startswith('_') for x in m.name.split('.')): + continue + if 'backends.backend_' in m.name: + backend_module_names.append(m.name) + else: + module_names.append(m.name) -@pytest.mark.parametrize('module_name', module_names) -@pytest.mark.filterwarnings('ignore::DeprecationWarning') -@pytest.mark.filterwarnings('ignore::ImportWarning') -def test_getattr(module_name): +def _test_getattr(module_name, use_pytest=True): """ Test that __getattr__ methods raise AttributeError for unknown keys. See #20822, #20855. @@ -28,8 +32,35 @@ def test_getattr(module_name): module = import_module(module_name) except (ImportError, RuntimeError, OSError) as e: # Skip modules that cannot be imported due to missing dependencies - pytest.skip(f'Cannot import {module_name} due to {e}') + if use_pytest: + pytest.skip(f'Cannot import {module_name} due to {e}') + else: + print(f'SKIP: Cannot import {module_name} due to {e}') + return key = 'THIS_SYMBOL_SHOULD_NOT_EXIST' if hasattr(module, key): delattr(module, key) + + +@pytest.mark.parametrize('module_name', module_names) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +@pytest.mark.filterwarnings('ignore::ImportWarning') +def test_getattr(module_name): + _test_getattr(module_name) + + +def _test_module_getattr(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + warnings.filterwarnings('ignore', category=ImportWarning) + module_name = sys.argv[1] + _test_getattr(module_name, use_pytest=False) + + +@pytest.mark.parametrize('module_name', backend_module_names) +def test_backend_getattr(module_name): + proc = subprocess_run_helper(_test_module_getattr, module_name, + timeout=120 if is_ci_environment() else 20) + if 'SKIP: ' in proc.stdout: + pytest.skip(proc.stdout.removeprefix('SKIP: ')) + print(proc.stdout) From 996736d2da28a354ae6e9087c54c1fa788ca9d52 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 18 Sep 2025 18:47:34 -0400 Subject: [PATCH 155/228] ci: Install GTK4 gir on Ubuntu 24.04 as well --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ed83a65579b..d73757c81bce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -115,6 +115,7 @@ jobs: fonts-wqy-zenhei \ gdb \ gir1.2-gtk-3.0 \ + gir1.2-gtk-4.0 \ graphviz \ inkscape \ language-pack-de \ @@ -150,7 +151,6 @@ jobs: fi if [[ "${{ matrix.os }}" = ubuntu-22.04 ]]; then sudo apt-get install -yy --no-install-recommends \ - gir1.2-gtk-4.0 \ libgirepository1.0-dev else # ubuntu-24.04 sudo apt-get install -yy --no-install-recommends \ From 0640c919125e74ed285927987f09df26c1ffaec5 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:21:24 +0000 Subject: [PATCH 156/228] Update README links to static images --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7dce2a5f472..8f9edaad2b5b 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ [![Codecov status](https://codecov.io/github/matplotlib/matplotlib/badge.svg?branch=main&service=github)](https://app.codecov.io/gh/matplotlib/matplotlib) [![EffVer Versioning](https://img.shields.io/badge/version_scheme-EffVer-0097a7)](https://jacobtomlinson.dev/effver) -![Matplotlib logotype](https://matplotlib.org/_static/logo2.svg) +![Matplotlib logotype](https://matplotlib.org/stable/_static/logo2.svg) Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python. Check out our [home page](https://matplotlib.org/) for more information. -![image](https://matplotlib.org/_static/readme_preview.png) +![image](https://matplotlib.org/stable/_static/readme_preview.png) Matplotlib produces publication-quality figures in a variety of hardcopy formats and interactive environments across platforms. Matplotlib can be From d00a72e74a9726846cdd238eaf72a50d09832056 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:00:28 +0100 Subject: [PATCH 157/228] DOC: Remove notebook instructions from image tutorial This information is not needed for the tutorial. For a topic tutorial, we can assume that the user has a working matplotlib setup one way or the other and basically knows how to operate it. The notebook instructions are also not that specific or valuable that it would be worth extracting to another place. Closes #20554. --- galleries/tutorials/images.py | 47 +---------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/galleries/tutorials/images.py b/galleries/tutorials/images.py index a7c474dab40b..6c4e68c32416 100644 --- a/galleries/tutorials/images.py +++ b/galleries/tutorials/images.py @@ -7,40 +7,6 @@ Image tutorial ============== -A short tutorial on plotting images with Matplotlib. - -.. _imaging_startup: - -Startup commands -=================== - -First, let's start IPython. It is a most excellent enhancement to the -standard Python prompt, and it ties in especially well with -Matplotlib. Start IPython either directly at a shell, or with the Jupyter -Notebook (where IPython as a running kernel). - -With IPython started, we now need to connect to a GUI event loop. This -tells IPython where (and how) to display plots. To connect to a GUI -loop, execute the **%matplotlib** magic at your IPython prompt. There's more -detail on exactly what this does at `IPython's documentation on GUI -event loops -`_. - -If you're using Jupyter Notebook, the same commands are available, but -people commonly use a specific argument to the %matplotlib magic: - -.. sourcecode:: ipython - - In [1]: %matplotlib inline - -This turns on inline plotting, where plot graphics will appear in your notebook. This -has important implications for interactivity. For inline plotting, commands in -cells below the cell that outputs a plot will not affect the plot. For example, -changing the colormap is not possible from cells below the cell that creates a plot. -However, for other backends, such as Qt, that open a separate window, -cells below those that create the plot will change the plot - it is a -live object in memory. - This tutorial will use Matplotlib's implicit plotting interface, pyplot. This interface maintains global state, and is very useful for quickly and easily experimenting with various plot settings. The alternative is the explicit, @@ -147,15 +113,6 @@ # %% # -# .. note:: -# -# However, remember that in the Jupyter Notebook with the inline backend, -# you can't make changes to plots that have already been rendered. If you -# create imgplot here in one cell, you cannot call set_cmap() on it in a later -# cell and expect the earlier plot to change. Make sure that you enter these -# commands together in one cell. plt commands will not change plots from earlier -# cells. -# # There are many other colormap schemes available. See the :ref:`list and images # of the colormaps`. # @@ -201,9 +158,7 @@ # %% # This can also be done by calling the # :meth:`~matplotlib.cm.ScalarMappable.set_clim` method of the returned image -# plot object, but make sure that you do so in the same cell as your plot -# command when working with the Jupyter Notebook - it will not change -# plots from earlier cells. +# plot object. imgplot = plt.imshow(lum_img) imgplot.set_clim(0, 175) From 949d883e71e8314e3a2b8137c029cd5066637fe8 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:34:54 +0100 Subject: [PATCH 158/228] DOC: Fix pip link The pip docs were restructured in https://github.com/pypa/pip/commit/5a17132f46ab6620a3329a16620fa275b280b76c As a result our doc builds fail with the warning > /home/circleci/project/doc/install/dependencies.rst:223: WARNING: external std:doc reference target not found: reference/build-system/pyproject-toml [ref.doc] Therefore, our pip doc link must be adapted. --- doc/install/dependencies.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 1fb463ab18e9..e19d8e79faf2 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -220,7 +220,7 @@ Build dependencies Python ------ -``pip`` normally builds packages using :external+pip:doc:`build isolation `, +``pip`` normally builds packages using :external+pip:doc:`build isolation `, which means that ``pip`` installs the dependencies listed here for the duration of the build process. However, build isolation is disabled via the the :external+pip:ref:`--no-build-isolation ` flag From 060fa0035b023044d1b85aee83684e8ca01bf4c2 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 28 Oct 2025 11:34:32 -0600 Subject: [PATCH 159/228] BUG: raise when creating a MacOS FigureManager outside the main thread --- lib/matplotlib/tests/test_backend_macosx.py | 16 ++++++++++++++++ src/_macosx.m | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/matplotlib/tests/test_backend_macosx.py b/lib/matplotlib/tests/test_backend_macosx.py index fe4c9a6fba3c..a451d16d9131 100644 --- a/lib/matplotlib/tests/test_backend_macosx.py +++ b/lib/matplotlib/tests/test_backend_macosx.py @@ -1,4 +1,5 @@ import os +import threading from pathlib import Path import pytest @@ -84,3 +85,18 @@ def _test_save_figure_return(): def test_save_figure_return(): subprocess_run_helper(_test_save_figure_return, timeout=_test_timeout, extra_env={"MPLBACKEND": "macosx"}) + + +@pytest.mark.backend('macosx', skip_on_importerror=True) +def test_create_figure_on_worker_thread_fails(): + + def create_figure(): + warn_msg = "Matplotlib GUI outside of the main thread will likely fail." + err_msg = "Cannot create a GUI FigureManager outside the main thread" + with pytest.warns(UserWarning, match=warn_msg): + with pytest.raises(RuntimeError, match=err_msg): + plt.gcf() + + worker = threading.Thread(target=create_figure) + worker.start() + worker.join() diff --git a/src/_macosx.m b/src/_macosx.m index 70babd39c09c..76d7e94de60f 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -582,6 +582,16 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) static PyObject* FigureManager_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + if (![NSThread isMainThread]) { + PyErr_SetString( + PyExc_RuntimeError, + "Cannot create a GUI FigureManager outside the main thread " + "using the MacOS backend. Use a non-interactive " + "backend like 'agg' to make plots on worker threads." + ); + return NULL; + } + lazy_init(); Window* window = [Window alloc]; if (!window) { return NULL; } From e6b7cee6d6dcad0e51b68bc49c9cdfd147207b3f Mon Sep 17 00:00:00 2001 From: Nick Coish Date: Tue, 28 Oct 2025 14:15:33 -0400 Subject: [PATCH 160/228] BLD: update trove metadata to support py3.14 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2bace246c5f1..b2e5451818f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers=[ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Visualization", ] From 73ef2574779143dfd9d6373c0a4dcc779caa9314 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 28 Oct 2025 21:05:25 -0400 Subject: [PATCH 161/228] TST: Increase tolerances for Ghostscript 10.06 (#30624) - `test_bbox_inches_tight_raster` differs by a single pixel in the middle of the line - `test_interp_nearest_vs_none` shifts the middle of the image over one pixel, causing a line of differences --- lib/matplotlib/tests/test_bbox_tight.py | 2 +- lib/matplotlib/tests/test_image.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 431ca70bf7ea..9d430b78d5de 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -110,7 +110,7 @@ def test_bbox_inches_tight_clipping(): plt.gcf().artists.append(patch) -@image_comparison(['bbox_inches_tight_raster'], +@image_comparison(['bbox_inches_tight_raster'], tol=0.15, # For Ghostscript 10.06+. remove_text=True, savefig_kwarg={'bbox_inches': 'tight'}) def test_bbox_inches_tight_raster(): """Test rasterization with tight_layout""" diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 5c4622c5bb29..6e988bc86db9 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -36,7 +36,7 @@ def test_alpha_interp(): axr.imshow(img, interpolation="bilinear") -@image_comparison(['interp_nearest_vs_none'], +@image_comparison(['interp_nearest_vs_none'], tol=3.7, # For Ghostscript 10.06+. extensions=['pdf', 'svg'], remove_text=True) def test_interp_nearest_vs_none(): """Test the effect of "nearest" and "none" interpolation""" From cd3685fc75a5191c565e22827b8878f2eb31ad23 Mon Sep 17 00:00:00 2001 From: ZPyrolink <73246085+ZPyrolink@users.noreply.github.com> Date: Thu, 30 Oct 2025 04:02:37 +0100 Subject: [PATCH 162/228] Create RCKeyType (#30316) * Create RCKeyType * Create RCGroupKeyType * Rename types for consistent naming * Replace str with RcKeyType on RcParams * Replace str with RcKeyType or RcGroupKeyType on matplotlib * Accord pyplot and matplotlib type hints * Correct RcParams dict key type * Correct mypy errors --- lib/matplotlib/__init__.pyi | 19 +- lib/matplotlib/_mathtext.py | 2 +- lib/matplotlib/pyplot.py | 6 +- lib/matplotlib/typing.py | 387 ++++++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/__init__.pyi b/lib/matplotlib/__init__.pyi index b20ee184149d..321c5a4b90b2 100644 --- a/lib/matplotlib/__init__.pyi +++ b/lib/matplotlib/__init__.pyi @@ -39,6 +39,7 @@ import contextlib from packaging.version import Version from matplotlib._api import MatplotlibDeprecationWarning +from matplotlib.typing import RcKeyType, RcGroupKeyType from typing import Any, Literal, NamedTuple, overload from matplotlib.typing import LogLevel @@ -69,18 +70,18 @@ def get_cachedir() -> str: ... def get_data_path() -> str: ... def matplotlib_fname() -> str: ... -class RcParams(dict[str, Any]): +class RcParams(dict[RcKeyType, Any]): validate: dict[str, Callable] def __init__(self, *args, **kwargs) -> None: ... - def _set(self, key: str, val: Any) -> None: ... - def _get(self, key: str) -> Any: ... + def _set(self, key: RcKeyType, val: Any) -> None: ... + def _get(self, key: RcKeyType) -> Any: ... def _update_raw(self, other_params: dict | RcParams) -> None: ... def _ensure_has_backend(self) -> None: ... - def __setitem__(self, key: str, val: Any) -> None: ... - def __getitem__(self, key: str) -> Any: ... - def __iter__(self) -> Generator[str, None, None]: ... + def __setitem__(self, key: RcKeyType, val: Any) -> None: ... + def __getitem__(self, key: RcKeyType) -> Any: ... + def __iter__(self) -> Generator[RcKeyType, None, None]: ... def __len__(self) -> int: ... def find_all(self, pattern: str) -> RcParams: ... def copy(self) -> RcParams: ... @@ -95,9 +96,9 @@ def rc_params_from_file( rcParamsDefault: RcParams rcParams: RcParams rcParamsOrig: RcParams -defaultParams: dict[str, Any] +defaultParams: dict[RcKeyType, Any] -def rc(group: str, **kwargs) -> None: ... +def rc(group: RcGroupKeyType, **kwargs) -> None: ... def rcdefaults() -> None: ... def rc_file_defaults() -> None: ... def rc_file( @@ -105,7 +106,7 @@ def rc_file( ) -> None: ... @contextlib.contextmanager def rc_context( - rc: dict[str, Any] | None = ..., fname: str | Path | os.PathLike | None = ... + rc: dict[RcKeyType, Any] | None = ..., fname: str | Path | os.PathLike | None = ... ) -> Generator[None, None, None]: ... def use(backend: str, *, force: bool = ...) -> None: ... @overload diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 2258c63e1bc2..914cf7323b8b 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -570,7 +570,7 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag super().__init__(default_font_prop, load_glyph_flags) for texfont in "cal rm tt it bf sf bfit".split(): - prop = mpl.rcParams['mathtext.' + texfont] + prop = mpl.rcParams['mathtext.' + texfont] # type: ignore[index] font = findfont(prop) self.fontmap[texfont] = font prop = FontProperties('cmex10') diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index cecd3dc24737..25aa1a1b2821 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -146,6 +146,8 @@ MarkerType, MouseEventType, PickEventType, + RcGroupKeyType, + RcKeyType, ResizeEventType, LogLevel ) @@ -790,13 +792,13 @@ def pause(interval: float) -> None: @_copy_docstring_and_deprecators(matplotlib.rc) -def rc(group: str, **kwargs) -> None: +def rc(group: RcGroupKeyType, **kwargs) -> None: matplotlib.rc(group, **kwargs) @_copy_docstring_and_deprecators(matplotlib.rc_context) def rc_context( - rc: dict[str, Any] | None = None, + rc: dict[RcKeyType, Any] | None = None, fname: str | pathlib.Path | os.PathLike | None = None, ) -> AbstractContextManager[None]: return matplotlib.rc_context(rc, fname) diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index cedeb1ad5d5e..10bbe6334328 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -170,3 +170,390 @@ tuple[float, float] | int ) + +RcKeyType: TypeAlias = Literal[ + "agg.path.chunksize", + "animation.bitrate", + "animation.codec", + "animation.convert_args", + "animation.convert_path", + "animation.embed_limit", + "animation.ffmpeg_args", + "animation.ffmpeg_path", + "animation.frame_format", + "animation.html", + "animation.writer", + "axes.autolimit_mode", + "axes.axisbelow", + "axes.edgecolor", + "axes.facecolor", + "axes.formatter.limits", + "axes.formatter.min_exponent", + "axes.formatter.offset_threshold", + "axes.formatter.use_locale", + "axes.formatter.use_mathtext", + "axes.formatter.useoffset", + "axes.grid", + "axes.grid.axis", + "axes.grid.which", + "axes.labelcolor", + "axes.labelpad", + "axes.labelsize", + "axes.labelweight", + "axes.linewidth", + "axes.prop_cycle", + "axes.spines.bottom", + "axes.spines.left", + "axes.spines.right", + "axes.spines.top", + "axes.titlecolor", + "axes.titlelocation", + "axes.titlepad", + "axes.titlesize", + "axes.titleweight", + "axes.titley", + "axes.unicode_minus", + "axes.xmargin", + "axes.ymargin", + "axes.zmargin", + "axes3d.automargin", + "axes3d.grid", + "axes3d.mouserotationstyle", + "axes3d.trackballborder", + "axes3d.trackballsize", + "axes3d.xaxis.panecolor", + "axes3d.yaxis.panecolor", + "axes3d.zaxis.panecolor", + "backend", + "backend_fallback", + "boxplot.bootstrap", + "boxplot.boxprops.color", + "boxplot.boxprops.linestyle", + "boxplot.boxprops.linewidth", + "boxplot.capprops.color", + "boxplot.capprops.linestyle", + "boxplot.capprops.linewidth", + "boxplot.flierprops.color", + "boxplot.flierprops.linestyle", + "boxplot.flierprops.linewidth", + "boxplot.flierprops.marker", + "boxplot.flierprops.markeredgecolor", + "boxplot.flierprops.markeredgewidth", + "boxplot.flierprops.markerfacecolor", + "boxplot.flierprops.markersize", + "boxplot.meanline", + "boxplot.meanprops.color", + "boxplot.meanprops.linestyle", + "boxplot.meanprops.linewidth", + "boxplot.meanprops.marker", + "boxplot.meanprops.markeredgecolor", + "boxplot.meanprops.markerfacecolor", + "boxplot.meanprops.markersize", + "boxplot.medianprops.color", + "boxplot.medianprops.linestyle", + "boxplot.medianprops.linewidth", + "boxplot.notch", + "boxplot.patchartist", + "boxplot.showbox", + "boxplot.showcaps", + "boxplot.showfliers", + "boxplot.showmeans", + "boxplot.vertical", + "boxplot.whiskerprops.color", + "boxplot.whiskerprops.linestyle", + "boxplot.whiskerprops.linewidth", + "boxplot.whiskers", + "contour.algorithm", + "contour.corner_mask", + "contour.linewidth", + "contour.negative_linestyle", + "date.autoformatter.day", + "date.autoformatter.hour", + "date.autoformatter.microsecond", + "date.autoformatter.minute", + "date.autoformatter.month", + "date.autoformatter.second", + "date.autoformatter.year", + "date.converter", + "date.epoch", + "date.interval_multiples", + "docstring.hardcopy", + "errorbar.capsize", + "figure.autolayout", + "figure.constrained_layout.h_pad", + "figure.constrained_layout.hspace", + "figure.constrained_layout.use", + "figure.constrained_layout.w_pad", + "figure.constrained_layout.wspace", + "figure.dpi", + "figure.edgecolor", + "figure.facecolor", + "figure.figsize", + "figure.frameon", + "figure.hooks", + "figure.labelsize", + "figure.labelweight", + "figure.max_open_warning", + "figure.raise_window", + "figure.subplot.bottom", + "figure.subplot.hspace", + "figure.subplot.left", + "figure.subplot.right", + "figure.subplot.top", + "figure.subplot.wspace", + "figure.titlesize", + "figure.titleweight", + "font.cursive", + "font.family", + "font.fantasy", + "font.monospace", + "font.sans-serif", + "font.serif", + "font.size", + "font.stretch", + "font.style", + "font.variant", + "font.weight", + "grid.alpha", + "grid.color", + "grid.linestyle", + "grid.linewidth", + "hatch.color", + "hatch.linewidth", + "hist.bins", + "image.aspect", + "image.cmap", + "image.composite_image", + "image.interpolation", + "image.interpolation_stage", + "image.lut", + "image.origin", + "image.resample", + "interactive", + "keymap.back", + "keymap.copy", + "keymap.forward", + "keymap.fullscreen", + "keymap.grid", + "keymap.grid_minor", + "keymap.help", + "keymap.home", + "keymap.pan", + "keymap.quit", + "keymap.quit_all", + "keymap.save", + "keymap.xscale", + "keymap.yscale", + "keymap.zoom", + "legend.borderaxespad", + "legend.borderpad", + "legend.columnspacing", + "legend.edgecolor", + "legend.facecolor", + "legend.fancybox", + "legend.fontsize", + "legend.framealpha", + "legend.frameon", + "legend.handleheight", + "legend.handlelength", + "legend.handletextpad", + "legend.labelcolor", + "legend.labelspacing", + "legend.loc", + "legend.markerscale", + "legend.numpoints", + "legend.scatterpoints", + "legend.shadow", + "legend.title_fontsize", + "lines.antialiased", + "lines.color", + "lines.dash_capstyle", + "lines.dash_joinstyle", + "lines.dashdot_pattern", + "lines.dashed_pattern", + "lines.dotted_pattern", + "lines.linestyle", + "lines.linewidth", + "lines.marker", + "lines.markeredgecolor", + "lines.markeredgewidth", + "lines.markerfacecolor", + "lines.markersize", + "lines.scale_dashes", + "lines.solid_capstyle", + "lines.solid_joinstyle", + "macosx.window_mode", + "markers.fillstyle", + "mathtext.bf", + "mathtext.bfit", + "mathtext.cal", + "mathtext.default", + "mathtext.fallback", + "mathtext.fontset", + "mathtext.it", + "mathtext.rm", + "mathtext.sf", + "mathtext.tt", + "patch.antialiased", + "patch.edgecolor", + "patch.facecolor", + "patch.force_edgecolor", + "patch.linewidth", + "path.effects", + "path.simplify", + "path.simplify_threshold", + "path.sketch", + "path.snap", + "pcolor.shading", + "pcolormesh.snap", + "pdf.compression", + "pdf.fonttype", + "pdf.inheritcolor", + "pdf.use14corefonts", + "pgf.preamble", + "pgf.rcfonts", + "pgf.texsystem", + "polaraxes.grid", + "ps.distiller.res", + "ps.fonttype", + "ps.papersize", + "ps.useafm", + "ps.usedistiller", + "savefig.bbox", + "savefig.directory", + "savefig.dpi", + "savefig.edgecolor", + "savefig.facecolor", + "savefig.format", + "savefig.orientation", + "savefig.pad_inches", + "savefig.transparent", + "scatter.edgecolors", + "scatter.marker", + "svg.fonttype", + "svg.hashsalt", + "svg.id", + "svg.image_inline", + "text.antialiased", + "text.color", + "text.hinting", + "text.hinting_factor", + "text.kerning_factor", + "text.latex.preamble", + "text.parse_math", + "text.usetex", + "timezone", + "tk.window_focus", + "toolbar", + "webagg.address", + "webagg.open_in_browser", + "webagg.port", + "webagg.port_retries", + "xaxis.labellocation", + "xtick.alignment", + "xtick.bottom", + "xtick.color", + "xtick.direction", + "xtick.labelbottom", + "xtick.labelcolor", + "xtick.labelsize", + "xtick.labeltop", + "xtick.major.bottom", + "xtick.major.pad", + "xtick.major.size", + "xtick.major.top", + "xtick.major.width", + "xtick.minor.bottom", + "xtick.minor.ndivs", + "xtick.minor.pad", + "xtick.minor.size", + "xtick.minor.top", + "xtick.minor.visible", + "xtick.minor.width", + "xtick.top", + "yaxis.labellocation", + "ytick.alignment", + "ytick.color", + "ytick.direction", + "ytick.labelcolor", + "ytick.labelleft", + "ytick.labelright", + "ytick.labelsize", + "ytick.left", + "ytick.major.left", + "ytick.major.pad", + "ytick.major.right", + "ytick.major.size", + "ytick.major.width", + "ytick.minor.left", + "ytick.minor.ndivs", + "ytick.minor.pad", + "ytick.minor.right", + "ytick.minor.size", + "ytick.minor.visible", + "ytick.minor.width", + "ytick.right" +] + +RcGroupKeyType: TypeAlias = Literal[ + "agg", + "agg.path", + "animation", + "axes", + "axes.formatter", + "axes.grid", + "axes.spines", + "axes3d", + "axes3d.xaxis", + "axes3d.yaxis", + "axes3d.zaxis", + "boxplot", + "boxplot.boxprops", + "boxplot.capprops", + "boxplot.flierprops", + "boxplot.meanprops", + "boxplot.medianprops", + "boxplot.whiskerprops", + "contour", + "date", + "date.autoformatter", + "docstring", + "errorbar", + "figure", + "figure.constrained_layout", + "figure.subplot", + "font", + "grid", + "hatch", + "hist", + "image", + "keymap", + "legend", + "lines", + "macosx", + "markers", + "mathtext", + "patch", + "path", + "pcolor", + "pcolormesh", + "pdf", + "pgf", + "polaraxes", + "ps", + "ps.distiller", + "savefig", + "scatter", + "svg", + "text", + "tk", + "webagg", + "xaxis", + "xtick", + "xtick.major", + "xtick.minor", + "yaxis", + "ytick", + "ytick.major", + "ytick.minor" +] From 3122d36047e7833b296bb902a6a227937ebe1b50 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 29 Oct 2025 23:06:42 -0400 Subject: [PATCH 163/228] TST: Rename test_cm_stubs to be more general --- lib/matplotlib/tests/{test_cm_stubs.py => test_typing.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/matplotlib/tests/{test_cm_stubs.py => test_typing.py} (100%) diff --git a/lib/matplotlib/tests/test_cm_stubs.py b/lib/matplotlib/tests/test_typing.py similarity index 100% rename from lib/matplotlib/tests/test_cm_stubs.py rename to lib/matplotlib/tests/test_typing.py From b7018203d0660b37efa8acd5d989da6d3427c8df Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 30 Oct 2025 01:54:06 -0400 Subject: [PATCH 164/228] Add testing for rcParams Literal type hints And also add some new ones that were missing. --- lib/matplotlib/tests/test_typing.py | 19 +++++++++++++++++++ lib/matplotlib/typing.py | 18 ++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_typing.py b/lib/matplotlib/tests/test_typing.py index 2305c91a5301..c9fc8e5b162f 100644 --- a/lib/matplotlib/tests/test_typing.py +++ b/lib/matplotlib/tests/test_typing.py @@ -1,8 +1,10 @@ import re +import typing from pathlib import Path import matplotlib.pyplot as plt from matplotlib.colors import Colormap +from matplotlib.typing import RcKeyType, RcGroupKeyType def test_cm_stub_matches_runtime_colormaps(): @@ -30,3 +32,20 @@ def test_cm_stub_matches_runtime_colormaps(): ) assert runtime_cmaps == stubbed_cmaps + + +def test_rcparam_stubs(): + runtime_rc_keys = { + name for name in plt.rcParamsDefault.keys() + if not name.startswith('_') + } + + assert {*typing.get_args(RcKeyType)} == runtime_rc_keys + + runtime_rc_group_keys = set() + for name in runtime_rc_keys: + groups = name.split('.') + for i in range(1, len(groups)): + runtime_rc_group_keys.add('.'.join(groups[:i])) + + assert {*typing.get_args(RcGroupKeyType)} == runtime_rc_group_keys diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 10bbe6334328..3a9f74e2cfd1 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -217,6 +217,8 @@ "axes.ymargin", "axes.zmargin", "axes3d.automargin", + "axes3d.depthshade", + "axes3d.depthshade_minalpha", "axes3d.grid", "axes3d.mouserotationstyle", "axes3d.trackballborder", @@ -304,6 +306,7 @@ "figure.titlesize", "figure.titleweight", "font.cursive", + "font.enable_last_resort", "font.family", "font.fantasy", "font.monospace", @@ -318,6 +321,14 @@ "grid.color", "grid.linestyle", "grid.linewidth", + "grid.major.alpha", + "grid.major.color", + "grid.major.linestyle", + "grid.major.linewidth", + "grid.minor.alpha", + "grid.minor.color", + "grid.minor.linestyle", + "grid.minor.linewidth", "hatch.color", "hatch.linewidth", "hist.bins", @@ -492,7 +503,7 @@ "ytick.minor.size", "ytick.minor.visible", "ytick.minor.width", - "ytick.right" + "ytick.right", ] RcGroupKeyType: TypeAlias = Literal[ @@ -524,6 +535,8 @@ "figure.subplot", "font", "grid", + "grid.major", + "grid.minor", "hatch", "hist", "image", @@ -546,6 +559,7 @@ "scatter", "svg", "text", + "text.latex", "tk", "webagg", "xaxis", @@ -555,5 +569,5 @@ "yaxis", "ytick", "ytick.major", - "ytick.minor" + "ytick.minor", ] From 4d40111773e005fa29f832339d77160bb43075c2 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:18:13 +0100 Subject: [PATCH 165/228] FIX: Account for horizontal/vertical lines in tightbox Closes #30695. Logically reverts https://github.com/matplotlib/matplotlib/commit/cfb0c5bcc786aa3d56aba0d6b2b4ae02a38e0fe8 This formally introduces the concept of finite Bboxes through `Bbox.is_finite()`, which we define as non-zero and non-infinite box. In particular a box that is zero in one dimension and non-zero in another dimension counts as finite. This means that horizontal and vertical lines count as finite. --- lib/matplotlib/axes/_base.py | 4 +--- lib/matplotlib/axis.py | 3 +-- lib/matplotlib/tests/test_transforms.py | 10 ++++++++++ lib/matplotlib/transforms.py | 23 +++++++++++++++++++++++ lib/matplotlib/transforms.pyi | 1 + 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index e08d173744ad..f047fe1809aa 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -4632,9 +4632,7 @@ def get_tightbbox(self, renderer=None, *, call_axes_locator=True, for a in bbox_artists: bbox = a.get_tightbbox(renderer) - if (bbox is not None - and 0 < bbox.width < np.inf - and 0 < bbox.height < np.inf): + if bbox is not None and bbox._is_finite(): bb.append(bbox) return mtransforms.Bbox.union( [b for b in bb if b.width != 0 or b.height != 0]) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index ba17c634ac5c..c3b6fcac569f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1383,8 +1383,7 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): bb.y0 = (bb.y0 + bb.y1) / 2 - 0.5 bb.y1 = bb.y0 + 1.0 bboxes.append(bb) - bboxes = [b for b in bboxes - if 0 < b.width < np.inf and 0 < b.height < np.inf] + bboxes = [b for b in bboxes if b._is_finite()] if bboxes: return mtransforms.Bbox.union(bboxes) else: diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index dbff0e1ba39f..59a765107d7b 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -835,6 +835,16 @@ def assert_bbox_eq(bbox1, bbox2): assert_array_equal(bbox1.bounds, bbox2.bounds) +def test_bbox_is_finite(): + assert not Bbox([(1, 1), (1, 1)])._is_finite() + assert not Bbox([(0, 0), (np.inf, 1)])._is_finite() + assert not Bbox([(-np.inf, 0), (2, 2)])._is_finite() + assert not Bbox([(np.nan, 0), (2, 2)])._is_finite() + assert Bbox([(0, 0), (0, 2)])._is_finite() + assert Bbox([(0, 0), (2, 0)])._is_finite() + assert Bbox([(0, 0), (1, 2)])._is_finite() + + def test_bbox_frozen_copies_minpos(): bbox = mtransforms.Bbox.from_extents(0.0, 0.0, 1.0, 1.0, minpos=1.0) frozen = bbox.frozen() diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index a22a2f7db070..6ffc82edac3e 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -377,6 +377,29 @@ def extents(self): def get_points(self): raise NotImplementedError + def _is_finite(self): + """ + Return whether the bounding box is finite and not degenerate to a + single point. + + We count the box as finite if neither width nor height are infinite + and at least one direction is non-zero; i.e. a point is not finite, + but a horizontal or vertical line is. + + .. versionadded:: 3.11 + + Notes + ----- + We keep this private for now because concise naming is hard and + because we are not sure how universal the concept is. It is + currently used only for filtering bboxes to be included in + tightbbox calculation, but I'm unsure whether single points + should be included there as well. + """ + width = self.width + height = self.height + return (width > 0 or height > 0) and width < np.inf and height < np.inf + def containsx(self, x): """ Return whether *x* is in the closed (:attr:`x0`, :attr:`x1`) interval. diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 4e582904d1d8..da73a8127cc3 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -65,6 +65,7 @@ class BboxBase(TransformNode): @property def extents(self) -> tuple[float, float, float, float]: ... def get_points(self) -> np.ndarray: ... + def _is_finite(self) -> bool: ... def containsx(self, x: float) -> bool: ... def containsy(self, y: float) -> bool: ... def contains(self, x: float, y: float) -> bool: ... From 3c503dffa4197d90c7aa03c7c201b256e23cc701 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Thu, 30 Oct 2025 13:14:01 -0600 Subject: [PATCH 166/228] TST: run test that might crash in a subprocess --- lib/matplotlib/tests/test_backend_macosx.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_macosx.py b/lib/matplotlib/tests/test_backend_macosx.py index a451d16d9131..0648e43cde94 100644 --- a/lib/matplotlib/tests/test_backend_macosx.py +++ b/lib/matplotlib/tests/test_backend_macosx.py @@ -87,9 +87,7 @@ def test_save_figure_return(): extra_env={"MPLBACKEND": "macosx"}) -@pytest.mark.backend('macosx', skip_on_importerror=True) -def test_create_figure_on_worker_thread_fails(): - +def _test_create_figure_on_worker_thread_fails(): def create_figure(): warn_msg = "Matplotlib GUI outside of the main thread will likely fail." err_msg = "Cannot create a GUI FigureManager outside the main thread" @@ -100,3 +98,12 @@ def create_figure(): worker = threading.Thread(target=create_figure) worker.start() worker.join() + + +@pytest.mark.backend('macosx', skip_on_importerror=True) +def test_create_figure_on_worker_thread_fails(): + subprocess_run_helper( + _test_create_figure_on_worker_thread_fails, + timeout=_test_timeout, + extra_env={"MPLBACKEND": "macosx"} + ) From 78617c1dbb68d6303dc6df98f2f5e50b75b83966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Thu, 30 Oct 2025 20:27:57 +0100 Subject: [PATCH 167/228] Update Colorizer/ColorizingArtist to work with MultiNorm (#30511) Update Colorizer/ColorizingArtist to work with MultiNorm improved testing for colorizer+multinorm --- lib/matplotlib/cbook.py | 16 +- lib/matplotlib/cm.py | 29 --- lib/matplotlib/colorizer.py | 294 +++++++++++++++++++++++----- lib/matplotlib/streamplot.py | 4 +- lib/matplotlib/tests/test_colors.py | 189 +++++++++++++++++- lib/matplotlib/tests/test_image.py | 37 ++++ 6 files changed, 492 insertions(+), 77 deletions(-) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index b9de982c18bd..a2a9e54792d9 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -694,7 +694,21 @@ def safe_masked_invalid(x, copy=False): try: xm = np.ma.masked_where(~(np.isfinite(x)), x, copy=False) except TypeError: - return x + if len(x.dtype.descr) == 1: + # Arrays with dtype 'object' get returned here. + # For example the 'c' kwarg of scatter, which supports multiple types. + # `plt.scatter([3, 4], [2, 5], c=[(1, 0, 0), 'y'])` + return x + else: + # In case of a dtype with multiple fields + # for example image data using a MultiNorm + try: + mask = np.empty(x.shape, dtype=np.dtype('bool, '*len(x.dtype.descr))) + for dd, dm in zip(x.dtype.descr, mask.dtype.descr): + mask[dm[0]] = ~np.isfinite(x[dd[0]]) + xm = np.ma.array(x, mask=mask, copy=False) + except TypeError: + return x return xm diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 299059177a20..497f0c2debdf 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -239,32 +239,3 @@ def get_cmap(self, cmap): _multivar_colormaps = ColormapRegistry(multivar_cmaps) _bivar_colormaps = ColormapRegistry(bivar_cmaps) - - -def _ensure_cmap(cmap): - """ - Ensure that we have a `.Colormap` object. - - For internal use to preserve type stability of errors. - - Parameters - ---------- - cmap : None, str, Colormap - - - if a `Colormap`, return it - - if a string, look it up in mpl.colormaps - - if None, look up the default color map in mpl.colormaps - - Returns - ------- - Colormap - - """ - if isinstance(cmap, colors.Colormap): - return cmap - cmap_name = mpl._val_or_rc(cmap, "image.cmap") - # use check_in_list to ensure type stability of the exception raised by - # the internal usage of this (ValueError vs KeyError) - if cmap_name not in _colormaps: - _api.check_in_list(sorted(_colormaps), cmap=cmap_name) - return mpl.colormaps[cmap_name] diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index a94790979078..120e816fed45 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -24,7 +24,7 @@ import numpy as np from numpy import ma -from matplotlib import _api, colors, cbook, scale, artist +from matplotlib import _api, colors, cbook, artist, scale import matplotlib as mpl mpl._docstring.interpd.register( @@ -78,7 +78,7 @@ def _scale_norm(self, norm, vmin, vmax, A): raise ValueError( "Passing a Normalize instance simultaneously with " "vmin/vmax is not supported. Please pass vmin/vmax " - "directly to the norm when creating it.") + "as arguments to the norm object when creating it") # always resolve the autoscaling so we have concrete limits # rather than deferring to draw time. @@ -90,19 +90,7 @@ def norm(self): @norm.setter def norm(self, norm): - _api.check_isinstance((colors.Norm, str, None), norm=norm) - if norm is None: - norm = colors.Normalize() - elif isinstance(norm, str): - try: - scale_cls = scale._scale_mapping[norm] - except KeyError: - raise ValueError( - "Invalid norm str name; the following values are " - f"supported: {', '.join(scale._scale_mapping)}" - ) from None - norm = _auto_norm_from_scale(scale_cls)() - + norm = _ensure_norm(norm, n_components=self.cmap.n_variates) if norm is self.norm: # We aren't updating anything return @@ -186,7 +174,7 @@ def _pass_image_data(x, alpha=None, bytes=False, norm=True): if norm and (xx.max() > 1 or xx.min() < 0): raise ValueError("Floating point image RGB values " - "must be in the 0..1 range.") + "must be in the [0,1] range") if bytes: xx = (xx * 255).astype(np.uint8) elif xx.dtype == np.uint8: @@ -231,10 +219,13 @@ def _set_cmap(self, cmap): ---------- cmap : `.Colormap` or str or None """ - # bury import to avoid circular imports - from matplotlib import cm in_init = self._cmap is None - self._cmap = cm._ensure_cmap(cmap) + cmap_obj = _ensure_cmap(cmap, accept_multivariate=True) + if not in_init and self.norm.n_components != cmap_obj.n_variates: + raise ValueError(f"The colormap {cmap} does not support " + f"{self.norm.n_components} variates as required by " + f"the {type(self.norm)} on this Colorizer") + self._cmap = cmap_obj if not in_init: self.changed() # Things are not set up properly yet. @@ -255,25 +246,25 @@ def set_clim(self, vmin=None, vmax=None): vmin, vmax : float The limits. - The limits may also be passed as a tuple (*vmin*, *vmax*) as a - single positional argument. + For scalar data, the limits may also be passed as a + tuple (*vmin*, *vmax*) single positional argument. .. ACCEPTS: (vmin: float, vmax: float) """ - # If the norm's limits are updated self.changed() will be called - # through the callbacks attached to the norm, this causes an inconsistent - # state, to prevent this blocked context manager is used - if vmax is None: - try: - vmin, vmax = vmin - except (TypeError, ValueError): - pass + if self.norm.n_components == 1: + if vmax is None: + try: + vmin, vmax = vmin + except (TypeError, ValueError): + pass orig_vmin_vmax = self.norm.vmin, self.norm.vmax # Blocked context manager prevents callbacks from being triggered # until both vmin and vmax are updated with self.norm.callbacks.blocked(signal='changed'): + # Since the @vmin/vmax.setter invokes colors._sanitize_extrema() + # to sanitize the input, the input is not sanitized here if vmin is not None: self.norm.vmin = vmin if vmax is not None: @@ -476,31 +467,51 @@ def _format_cursor_data_override(self, data): # Note if cm.ScalarMappable is depreciated, this functionality should be # implemented as format_cursor_data() on ColorizingArtist. - n = self.cmap.N - if np.ma.getmask(data): + if np.ma.getmask(data) or data is None: + # NOTE: for multivariate data, if *any* of the fields are masked, + # "[]" is returned here return "[]" - normed = self.norm(data) + + if isinstance(self.norm, colors.MultiNorm): + norms = self.norm.norms + if isinstance(self.cmap, colors.BivarColormap): + n_s = (self.cmap.N, self.cmap.M) + else: # colors.MultivarColormap + n_s = [part.N for part in self.cmap] + else: # colors.Colormap + norms = [self.norm] + data = [data] + n_s = [self.cmap.N] + + os = [f"{d:-#.{self._sig_digits_from_norm(no, d, n)}g}" + for no, d, n in zip(norms, data, n_s)] + return f"[{', '.join(os)}]" + + @staticmethod + def _sig_digits_from_norm(norm, data, n): + # Determines the number of significant digits + # to use for a number given a norm, and n, where n is the + # number of colors in the colormap. + normed = norm(data) if np.isfinite(normed): - if isinstance(self.norm, colors.BoundaryNorm): + if isinstance(norm, colors.BoundaryNorm): # not an invertible normalization mapping - cur_idx = np.argmin(np.abs(self.norm.boundaries - data)) + cur_idx = np.argmin(np.abs(norm.boundaries - data)) neigh_idx = max(0, cur_idx - 1) # use max diff to prevent delta == 0 - delta = np.diff( - self.norm.boundaries[neigh_idx:cur_idx + 2] - ).max() - elif self.norm.vmin == self.norm.vmax: + delta = np.diff(norm.boundaries[neigh_idx:cur_idx + 2]).max() + elif norm.vmin == norm.vmax: # singular norms, use delta of 10% of only value - delta = np.abs(self.norm.vmin * .1) + delta = np.abs(norm.vmin * .1) else: # Midpoints of neighboring color intervals. - neighbors = self.norm.inverse( - (int(normed * n) + np.array([0, 1])) / n) + neighbors = norm.inverse((int(normed * n) + np.array([0, 1])) / n) delta = abs(neighbors - data).max() + g_sig_digits = cbook._g_sig_digits(data, delta) else: g_sig_digits = 3 # Consistent with default below. - return f"[{data:-#.{g_sig_digits}g}]" + return g_sig_digits class _ScalarMappable(_ColorizerInterface): @@ -563,11 +574,19 @@ def set_array(self, A): self._A = None return + A = _ensure_multivariate_data(A, self.norm.n_components) + A = cbook.safe_masked_invalid(A, copy=True) if not np.can_cast(A.dtype, float, "same_kind"): - raise TypeError(f"Image data of dtype {A.dtype} cannot be " - "converted to float") + if A.dtype.fields is None: + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to float") + else: + for key in A.dtype.fields: + if not np.can_cast(A[key].dtype, float, "same_kind"): + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to a sequence of floats") self._A = A if not self.norm.scaled(): self._colorizer.autoscale_None(A) @@ -615,6 +634,15 @@ def _get_colorizer(cmap, norm, colorizer): cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` The Colormap instance or registered colormap name used to map scalar data to colors.""", + multi_cmap_doc="""\ +cmap : str, `~matplotlib.colors.Colormap`, `~matplotlib.colors.BivarColormap`\ + or `~matplotlib.colors.MultivarColormap`, default: :rc:`image.cmap` + The Colormap instance or registered colormap name used to map + data values to colors. + + Multivariate data is only accepted if a multivariate colormap + (`~matplotlib.colors.BivarColormap` or `~matplotlib.colors.MultivarColormap`) + is used.""", norm_doc="""\ norm : str or `~matplotlib.colors.Normalize`, optional The normalization method used to scale scalar data to the [0, 1] range @@ -629,6 +657,21 @@ def _get_colorizer(cmap, norm, colorizer): list of available scales, call `matplotlib.scale.get_scale_names()`. In that case, a suitable `.Normalize` subclass is dynamically generated and instantiated.""", + multi_norm_doc="""\ +norm : str, `~matplotlib.colors.Normalize` or list, optional + The normalization method used to scale data to the [0, 1] range + before mapping to colors using *cmap*. By default, a linear scaling is + used, mapping the lowest value to 0 and the highest to 1. + This can be one of the following: + - An instance of `.Normalize` or one of its subclasses + (see :ref:`colormapnorms`). + - A scale name, i.e. one of "linear", "log", "symlog", "logit", etc. For a + list of available scales, call `matplotlib.scale.get_scale_names()`. + In this case, a suitable `.Normalize` subclass is dynamically generated + and instantiated. + - A list of scale names or `.Normalize` objects matching the number of + variates in the colormap, for use with `~matplotlib.colors.BivarColormap` + or `~matplotlib.colors.MultivarColormap`, i.e. ``["linear", "log"]``.""", vmin_vmax_doc="""\ vmin, vmax : float, optional When using scalar data and no explicit *norm*, *vmin* and *vmax* define @@ -636,6 +679,17 @@ def _get_colorizer(cmap, norm, colorizer): the complete value range of the supplied data. It is an error to use *vmin*/*vmax* when a *norm* instance is given (but using a `str` *norm* name together with *vmin*/*vmax* is acceptable).""", + multi_vmin_vmax_doc="""\ +vmin, vmax : float or list, optional + When using scalar data and no explicit *norm*, *vmin* and *vmax* define + the data range that the colormap covers. By default, the colormap covers + the complete value range of the supplied data. It is an error to use + *vmin*/*vmax* when a *norm* instance is given (but using a `str` *norm* + name together with *vmin*/*vmax* is acceptable). + + A list of values (vmin or vmax) can be used to define independent limits + for each variate when using a `~matplotlib.colors.BivarColormap` or + `~matplotlib.colors.MultivarColormap`.""", ) @@ -701,3 +755,155 @@ def _auto_norm_from_scale(scale_cls): norm = colors.make_norm_from_scale(scale_cls)( colors.Normalize)() return type(norm) + + +def _ensure_norm(norm, n_components=1): + if n_components == 1: + _api.check_isinstance((colors.Norm, str, None), norm=norm) + if norm is None: + norm = colors.Normalize() + elif isinstance(norm, str): + scale_cls = _api.check_getitem(scale._scale_mapping, norm=norm) + return _auto_norm_from_scale(scale_cls)() + return norm + elif n_components > 1: + if not np.iterable(norm): + _api.check_isinstance((colors.MultiNorm, None, tuple), norm=norm) + if norm is None: + norm = colors.MultiNorm(['linear']*n_components) + else: # iterable, i.e. multiple strings or Normalize objects + norm = colors.MultiNorm(norm) + if isinstance(norm, colors.MultiNorm) and norm.n_components == n_components: + return norm + raise ValueError( + f"Invalid norm for multivariate colormap with {n_components} inputs") + else: # n_components == 0 + raise ValueError( + "Invalid cmap. A colorizer object must have a cmap with `n_variates` >= 1") + + +def _ensure_cmap(cmap, accept_multivariate=False): + """ + Ensure that we have a `.Colormap` object. + + For internal use to preserve type stability of errors. + + Parameters + ---------- + cmap : None, str, Colormap + + - if a `~matplotlib.colors.Colormap`, + `~matplotlib.colors.MultivarColormap` or + `~matplotlib.colors.BivarColormap`, + return it + - if a string, look it up in three corresponding databases + when not found: raise an error based on the expected shape + - if None, look up the default color map in mpl.colormaps + accept_multivariate : bool, default False + - if False, accept only Colormap, string in mpl.colormaps or None + + Returns + ------- + Colormap + + """ + if accept_multivariate: + types = (colors.Colormap, colors.BivarColormap, colors.MultivarColormap) + mappings = (mpl.colormaps, mpl.multivar_colormaps, mpl.bivar_colormaps) + else: + types = (colors.Colormap, ) + mappings = (mpl.colormaps, ) + + if isinstance(cmap, types): + return cmap + + cmap_name = mpl._val_or_rc(cmap, "image.cmap") + + for mapping in mappings: + if cmap_name in mapping: + return mapping[cmap_name] + + # this error message is a variant of _api.check_in_list but gives + # additional hints as to how to access multivariate colormaps + + raise ValueError(f"{cmap!r} is not a valid value for cmap" + "; supported values for scalar colormaps are " + f"{', '.join(map(repr, sorted(mpl.colormaps)))}\n" + "See `matplotlib.bivar_colormaps()` and" + " `matplotlib.multivar_colormaps()` for" + " bivariate and multivariate colormaps") + + +def _ensure_multivariate_data(data, n_components): + """ + Ensure that the data has dtype with n_components. + Input data of shape (n_components, n, m) is converted to an array of shape + (n, m) with data type np.dtype(f'{data.dtype}, ' * n_components) + Complex data is returned as a view with dtype np.dtype('float64, float64') + or np.dtype('float32, float32') + If n_components is 1 and data is not of type np.ndarray (i.e. PIL.Image), + the data is returned unchanged. + If data is None, the function returns None + + Parameters + ---------- + n_components : int + Number of variates in the data. + data : np.ndarray, PIL.Image or None + + Returns + ------- + np.ndarray, PIL.Image or None + """ + + if isinstance(data, np.ndarray): + if len(data.dtype.descr) == n_components: + # pass scalar data + # and already formatted data + return data + elif data.dtype in [np.complex64, np.complex128]: + if n_components != 2: + raise ValueError("Invalid data entry for multivariate data. " + "Complex numbers are incompatible with " + f"{n_components} variates.") + + # pass complex data + if data.dtype == np.complex128: + dt = np.dtype('float64, float64') + else: + dt = np.dtype('float32, float32') + + reconstructed = np.ma.array(np.ma.getdata(data).view(dt)) + if np.ma.is_masked(data): + for descriptor in dt.descr: + reconstructed[descriptor[0]][data.mask] = np.ma.masked + return reconstructed + + if n_components > 1 and len(data) == n_components: + # convert data from shape (n_components, n, m) + # to (n, m) with a new dtype + data = [np.ma.array(part, copy=False) for part in data] + dt = np.dtype(', '.join([f'{part.dtype}' for part in data])) + fields = [descriptor[0] for descriptor in dt.descr] + reconstructed = np.ma.empty(data[0].shape, dtype=dt) + for i, f in enumerate(fields): + if data[i].shape != reconstructed.shape: + raise ValueError("For multivariate data all variates must have same " + f"shape, not {data[0].shape} and {data[i].shape}") + reconstructed[f] = data[i] + if np.ma.is_masked(data[i]): + reconstructed[f][data[i].mask] = np.ma.masked + return reconstructed + + if n_components == 1: + # PIL.Image gets passed here + return data + + elif n_components == 2: + raise ValueError("Invalid data entry for multivariate data. The data" + " must contain complex numbers, or have a first dimension 2," + " or be of a dtype with 2 fields") + else: + raise ValueError("Invalid data entry for multivariate data. The shape" + f" of the data must have a first dimension {n_components}" + f" or be of a dtype with {n_components} fields") diff --git a/lib/matplotlib/streamplot.py b/lib/matplotlib/streamplot.py index ece8bebf8192..725fff7b23fd 100644 --- a/lib/matplotlib/streamplot.py +++ b/lib/matplotlib/streamplot.py @@ -6,7 +6,7 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, cm, patches +from matplotlib import _api, colorizer, patches import matplotlib.colors as mcolors import matplotlib.collections as mcollections import matplotlib.lines as mlines @@ -228,7 +228,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, if use_multicolor_lines: if norm is None: norm = mcolors.Normalize(color.min(), color.max()) - cmap = cm._ensure_cmap(cmap) + cmap = colorizer._ensure_cmap(cmap) streamlines = [] arrows = [] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 89299b73cf0c..4af0c84261b8 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1425,7 +1425,7 @@ def test_scalarmappable_nan_to_rgba(bytes): # Out-of-range fail x[1, 0, 0] = 42 - with pytest.raises(ValueError, match='0..1 range'): + with pytest.raises(ValueError, match=r'\[0,1\] range'): sm.to_rgba(x[..., :3], bytes=bytes) @@ -1864,6 +1864,7 @@ def autoscale_None(self, A): def scaled(self): return True + @property def n_components(self): return 1 @@ -2056,3 +2057,189 @@ def test_mult_norm_call_types(): with pytest.raises(ValueError, match="but got 5, data) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert np.all(mdata["f0"].mask[:2] == 0) + assert np.all(mdata["f0"].mask[2:] == 1) + assert np.all(mdata["f1"].mask[:2] == 0) + assert np.all(mdata["f1"].mask[2:] == 1) + + # test tuple of data + data = [0, 1] + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == () + + # test wrong input size + data = [[0, 1]] + with pytest.raises(ValueError, match="must contain complex numbers"): + mcolorizer._ensure_multivariate_data(data, 2) + data = [[0, 1]] + with pytest.raises(ValueError, match="have a first dimension 3"): + mcolorizer._ensure_multivariate_data(data, 3) + + # test input of ints as list of lists + data = [[0, 0, 0], [1, 1, 1]] + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (3,) + assert mdata.dtype.fields['f0'][0] == np.int64 + assert mdata.dtype.fields['f1'][0] == np.int64 + + # test input of floats, ints as tuple of lists + data = ([0.0, 0.0], [1, 1]) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (2,) + assert mdata.dtype.fields['f0'][0] == np.float64 + assert mdata.dtype.fields['f1'][0] == np.int64 + + # test input of array of floats + data = np.array([[0.0, 0, 0], [1, 1, 1]]) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (3,) + assert mdata.dtype.fields['f0'][0] == np.float64 + assert mdata.dtype.fields['f1'][0] == np.float64 + + # test more input dims + data = np.zeros((3, 4, 5, 6)) + mdata = mcolorizer._ensure_multivariate_data(data, 3) + assert mdata.shape == (4, 5, 6) + + +def test_colorizer_multinorm_implicit(): + ca = mcolorizer.Colorizer('BiOrangeBlue') + ca.vmin = (0, 0) + ca.vmax = (1, 1) + + # test call with two single values + data = [0.1, 0.2] + res = (0.10009765625, 0.1510859375, 0.20166015625, 1.0) + assert_array_almost_equal(ca.to_rgba(data), res) + + # test call with two 1d arrays + data = [[0.1, 0.2], [0.3, 0.4]] + res = [[0.10009766, 0.19998877, 0.29931641, 1.], + [0.20166016, 0.30098633, 0.40087891, 1.]] + assert_array_almost_equal(ca.to_rgba(data), res) + + # test call with two 2d arrays + data = [np.linspace(0, 1, 12).reshape(3, 4), + np.linspace(1, 0, 12).reshape(3, 4)] + res = np.array([[[0.00244141, 0.50048437, 0.99853516, 1.], + [0.09228516, 0.50048437, 0.90869141, 1.], + [0.18212891, 0.50048437, 0.81884766, 1.], + [0.27197266, 0.50048437, 0.72900391, 1.]], + [[0.36572266, 0.50048437, 0.63525391, 1.], + [0.45556641, 0.50048438, 0.54541016, 1.], + [0.54541016, 0.50048438, 0.45556641, 1.], + [0.63525391, 0.50048437, 0.36572266, 1.]], + [[0.72900391, 0.50048437, 0.27197266, 1.], + [0.81884766, 0.50048437, 0.18212891, 1.], + [0.90869141, 0.50048437, 0.09228516, 1.], + [0.99853516, 0.50048437, 0.00244141, 1.]]]) + assert_array_almost_equal(ca.to_rgba(data), res) + + with pytest.raises(ValueError, match=("This MultiNorm has 2 components, " + "but got a sequence with 3 elements")): + ca.to_rgba([0.1, 0.2, 0.3]) + with pytest.raises(ValueError, match=("This MultiNorm has 2 components, " + "but got a sequence with 1 elements")): + ca.to_rgba([[0.1]]) + + # test multivariate + ca = mcolorizer.Colorizer('3VarAddA') + ca.vmin = (-0.1, -0.2, -0.3) + ca.vmax = (0.1, 0.2, 0.3) + + data = [0.1, 0.1, 0.1] + res = (0.712612, 0.896847, 0.954494, 1.0) + assert_array_almost_equal(ca.to_rgba(data), res) + + +def test_colorizer_multinorm_explicit(): + + with pytest.raises(ValueError, match="MultiNorm must be assigned"): + ca = mcolorizer.Colorizer('BiOrangeBlue', 'linear') + + with pytest.raises(TypeError, + match=("'norm' must be an instance of matplotlib.colors.Norm" + ", str or None, not a list")): + ca = mcolorizer.Colorizer('viridis', ['linear', 'linear']) + + with pytest.raises(ValueError, + match=("Invalid norm for multivariate colormap with 2 inputs")): + ca = mcolorizer.Colorizer('BiOrangeBlue', ['linear', 'linear', 'log']) + + # valid explicit construction + ca = mcolorizer.Colorizer('BiOrangeBlue', [mcolors.Normalize(), 'log']) + ca.vmin = (0, 0.01) + ca.vmax = (1, 1) + + # test call with two single values + data = [0.1, 0.2] + res = (0.100098, 0.375492, 0.650879, 1.) + assert_array_almost_equal(ca.to_rgba(data), res) + + +def test_invalid_cmap_n_components_zero(): + class CustomColormap(mcolors.Colormap): + def __init__(self): + super().__init__("custom") + self.n_variates = 0 + + with pytest.raises(ValueError, match='`n_variates` >= 1'): + ca = mcolorizer.Colorizer(CustomColormap()) + + +def test_colorizer_bivar_cmap(): + ca = mcolorizer.Colorizer('BiOrangeBlue', [mcolors.Normalize(), 'log']) + + with pytest.raises(ValueError, match='The colormap viridis'): + ca.cmap = 'viridis' + + cartist = mcolorizer.ColorizingArtist(ca) + cartist.set_array(np.zeros((2, 4, 4))) + + with pytest.raises(ValueError, match='Invalid data entry for multivariate'): + cartist.set_array(np.zeros((3, 4, 4))) + + dt = np.dtype([('x', 'f4'), ('', 'object')]) + with pytest.raises(TypeError, match='converted to a sequence of floats'): + cartist.set_array(np.zeros((2, 4, 4), dtype=dt)) + + with pytest.raises(ValueError, match='all variates must have same shape'): + cartist.set_array((np.zeros(3), np.zeros(4))) + + # ensure masked value is propagated from input + a = np.arange(3) + cartist.set_array((a, np.ma.masked_where(a > 1, a))) + assert np.all(cartist.get_array()['f0'].mask == np.array([0, 0, 0], dtype=bool)) + assert np.all(cartist.get_array()['f1'].mask == np.array([0, 0, 1], dtype=bool)) + + # test clearing data + cartist.set_array(None) + cartist.get_array() is None + + +def test_colorizer_multivar_cmap(): + ca = mcolorizer.Colorizer('3VarAddA', [mcolors.Normalize(), + mcolors.Normalize(), + 'log']) + cartist = mcolorizer.ColorizingArtist(ca) + cartist.set_array(np.zeros((3, 5, 5))) + with pytest.raises(ValueError, match='Complex numbers are incompatible with'): + cartist.set_array(np.zeros((5, 5), dtype='complex128')) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 6e988bc86db9..9b598fbf7193 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -452,6 +452,43 @@ def test_format_cursor_data(data, text): assert im.format_cursor_data(im.get_cursor_data(event)) == text +@pytest.mark.parametrize( + "data, text", [ + ([[[10001, 10000]], [[0, 0]]], "[10001.000, 0.000]"), + ([[[.123, .987]], [[0.1, 0]]], "[0.123, 0.100]"), + ([[[np.nan, 1, 2]], [[0, 0, 0]]], "[]"), + ]) +def test_format_cursor_data_multinorm(data, text): + from matplotlib.backend_bases import MouseEvent + fig, ax = plt.subplots() + cmap_bivar = mpl.bivar_colormaps['BiOrangeBlue'] + cmap_multivar = mpl.multivar_colormaps['2VarAddA'] + + # This is a test for ColorizingArtist._format_cursor_data_override() + # with data with multiple channels. + # It includes a workaround so that we can test this functionality + # before the MultiVar/BiVariate colormaps and MultiNorm are exposed + # via the top-level methods (ax.imshow()) + # i.e. we here set the hidden variables _cmap and _norm + # and use set_array() on the ColorizingArtist rather than the _ImageBase + # but this workaround should be replaced by: + # `ax.imshow(data, cmap=cmap_bivar, vmin=(0,0), vmax=(1,1))` + # once the functionality is available. + # see https://github.com/matplotlib/matplotlib/issues/14168 + im = ax.imshow([[0, 1]]) + im.colorizer._cmap = cmap_bivar + im.colorizer._norm = colors.MultiNorm([im.norm, im.norm]) + mpl.colorizer.ColorizingArtist.set_array(im, data) + + xdisp, ydisp = ax.transData.transform([0, 0]) + event = MouseEvent('motion_notify_event', fig.canvas, xdisp, ydisp) + assert im.format_cursor_data(im.get_cursor_data(event)) == text + + im.colorizer._cmap = cmap_multivar + event = MouseEvent('motion_notify_event', fig.canvas, xdisp, ydisp) + assert im.format_cursor_data(im.get_cursor_data(event)) == text + + @image_comparison(['image_clip'], style='mpl20') def test_image_clip(): d = [[1, 2], [3, 4]] From 76f99f4acd7b445767c49161d811edd4d59e9103 Mon Sep 17 00:00:00 2001 From: Hannah Date: Thu, 30 Oct 2025 18:25:08 -0400 Subject: [PATCH 168/228] doc: make external link explicit --- doc/devel/development_setup.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/devel/development_setup.rst b/doc/devel/development_setup.rst index 45b95e48e7ff..5be8500428a0 100644 --- a/doc/devel/development_setup.rst +++ b/doc/devel/development_setup.rst @@ -119,8 +119,8 @@ code, as described in :ref:`development-workflow`. * `GitHub-Contributing to a Project `_ * `GitHub Skills `_ - * :ref:`using-git` - * :ref:`git-resources` + * :external+scipy:ref:`using-git` + * :external+scipy:ref:`git-resources` * `Installing git `_ * `Managing remote repositories `_ From 51cb731ab5d6168d442a29c21beec7ab070642b2 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 23:20:28 +1000 Subject: [PATCH 169/228] Explicitly set black rectangle color --- lib/matplotlib/backends/_backend_tk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index df9e6027b88b..42782b2f00e1 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -769,7 +769,7 @@ def draw_rubberband(self, event, x0, y0, x1, y1): y1 = height - y1 self.canvas._rubberband_rect_black = ( self.canvas._tkcanvas.create_rectangle( - x0, y0, x1, y1)) + x0, y0, x1, y1, outline='black')) self.canvas._rubberband_rect_white = ( self.canvas._tkcanvas.create_rectangle( x0, y0, x1, y1, outline='white', dash=(3, 3))) From 9f122cff310d68c8f0273ab29110cc108523c433 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 17:39:23 +1000 Subject: [PATCH 170/228] Make WebAgg rubberband consistent with Qt backend Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/backends/web_backend/js/mpl.js | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index f2bfc43bd0e4..7745cbcf1e98 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -325,7 +325,6 @@ mpl.figure.prototype._init_canvas = function () { canvas_div.appendChild(rubberband_canvas); this.rubberband_context = rubberband_canvas.getContext('2d'); - this.rubberband_context.strokeStyle = '#000000'; this._resize_canvas = function (width, height, forward) { if (forward) { @@ -469,19 +468,38 @@ mpl.figure.prototype.handle_rubberband = function (fig, msg) { y0 = Math.floor(y0) + 0.5; x1 = Math.floor(x1) + 0.5; y1 = Math.floor(y1) + 0.5; - var min_x = Math.min(x0, x1); - var min_y = Math.min(y0, y1); - var width = Math.abs(x1 - x0); - var height = Math.abs(y1 - y0); - fig.rubberband_context.clearRect( + var ctx = fig.rubberband_context; + ctx.clearRect( 0, 0, fig.canvas.width / fig.ratio, fig.canvas.height / fig.ratio ); - fig.rubberband_context.strokeRect(min_x, min_y, width, height); + var drawRubberband = function () { + // Draw the lines from x0, y0 towards x1, y1 so that the + // dashes don't "jump" when moving the zoom box. + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x0, y1); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y0); + ctx.moveTo(x0, y1); + ctx.lineTo(x1, y1); + ctx.moveTo(x1, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + }; + + fig.rubberband_context.lineWidth = 1; + fig.rubberband_context.setLineDash([3]); + fig.rubberband_context.lineDashOffset = 0; + fig.rubberband_context.strokeStyle = '#000000'; + drawRubberband(); + fig.rubberband_context.strokeStyle = '#ffffff'; + fig.rubberband_context.lineDashOffset = 3; + drawRubberband(); }; mpl.figure.prototype.handle_figure_label = function (fig, msg) { From e4ef10311c11985944feb3dff273bb8ddc528250 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 17:43:10 +1000 Subject: [PATCH 171/228] What's new rst --- doc/release/next_whats_new/zoom_boxes.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 doc/release/next_whats_new/zoom_boxes.rst diff --git a/doc/release/next_whats_new/zoom_boxes.rst b/doc/release/next_whats_new/zoom_boxes.rst new file mode 100644 index 000000000000..8cc0cc1645a3 --- /dev/null +++ b/doc/release/next_whats_new/zoom_boxes.rst @@ -0,0 +1,4 @@ +Consistent zoom boxes +--------------------- + +Zooming now has a consistent dashed box style across all backends. From 28780cb39cc79ad69f32ac9709277c011f0ede54 Mon Sep 17 00:00:00 2001 From: LangQi99 <2032771946@qq.com> Date: Sat, 1 Nov 2025 14:07:47 +0800 Subject: [PATCH 172/228] fix: Qt5Agg support darkmode icon by using svg (#30565) * fix: Qt5Agg support darkmode icon * fix(qt5agg): scale SVG toolbar icons to toolbar size and safely end QPainter to avoid crash * Regenerate Qt5 tool icons when device pixel ratio changes * Remove defensive check for Qt5 tool SVG icon existence * refactor(backend_qt): Use QIconEngine for on-demand icon rendering * Enhance SVG icon rendering support for multiple Qt versions * refactor(backend_qt): Improve code quality based on review feedback * Move QtSvg import to qt_compat * Implement Qt toolmanager svg icon * Simplify dark mode and large image detection * refactor(backend_qt): Improve the clarity of the code's logic. * refactor(backend_qt): simplify icon DPR handling in _IconEngine * refactor(backend_qt): enhance icon loading logic --- lib/matplotlib/backends/backend_qt.py | 141 ++++++++++++++++++++++---- lib/matplotlib/backends/qt_compat.py | 10 +- 2 files changed, 128 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index e0a6f23cc001..974ebabf8a16 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -14,7 +14,7 @@ import matplotlib.backends.qt_editor.figureoptions as figureoptions from . import qt_compat from .qt_compat import ( - QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted) + QtCore, QtGui, QtWidgets, QtSvg, __version__, QT_API, _to_int, _isdeleted) # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name @@ -683,6 +683,120 @@ def set_window_title(self, title): self.window.setWindowTitle(title) +class _IconEngine(QtGui.QIconEngine): + """ + Custom QIconEngine that automatically handles DPI scaling for tools icons. + + This engine provides icons on-demand with proper scaling based on the current + device pixel ratio, eliminating the need for manual refresh when DPI changes. + """ + + def __init__(self, image_path, toolbar=None): + super().__init__() + self.image_path = image_path + self.toolbar = toolbar + + def _is_dark_mode(self): + return self.toolbar.palette().color(self.toolbar.backgroundRole()).value() < 128 + + def paint(self, painter, rect, mode, state): + """Paint the icon at the requested size and state.""" + pixmap = self.pixmap(rect.size(), mode, state) + if not pixmap.isNull(): + painter.drawPixmap(rect, pixmap) + + def pixmap(self, size, mode, state): + """Generate a pixmap for the requested size, mode, and state.""" + if size.width() <= 0 or size.height() <= 0: + return QtGui.QPixmap() + + # Try SVG first, then fall back to PNG + svg_path = self.image_path.with_suffix('.svg') + if svg_path.exists(): + pixmap = self._create_pixmap_from_svg(svg_path, size) + if not pixmap.isNull(): + return pixmap + return self._create_pixmap_from_png(self.image_path, size) + + def _devicePixelRatio(self): + """Return the current device pixel ratio for the toolbar, defaulting to 1.""" + return (self.toolbar.devicePixelRatioF() or 1) if self.toolbar else 1 + + def _create_pixmap_from_svg(self, svg_path, size): + """Create a pixmap from SVG with proper scaling and dark mode support.""" + QSvgRenderer = getattr(QtSvg, "QSvgRenderer", None) + if QSvgRenderer is None: + return QtGui.QPixmap() + + svg_content = svg_path.read_bytes() + + if self._is_dark_mode(): + svg_content = svg_content.replace(b'fill:black;', b'fill:white;') + svg_content = svg_content.replace(b'stroke:black;', b'stroke:white;') + + renderer = QSvgRenderer(QtCore.QByteArray(svg_content)) + if not renderer.isValid(): + return QtGui.QPixmap() + + dpr = self._devicePixelRatio() + scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr)) + pixmap = QtGui.QPixmap(scaled_size) + pixmap.setDevicePixelRatio(dpr) + pixmap.fill(QtCore.Qt.GlobalColor.transparent) + + painter = QtGui.QPainter() + try: + painter.begin(pixmap) + renderer.render(painter, QtCore.QRectF(0, 0, size.width(), size.height())) + finally: + if painter.isActive(): + painter.end() + + return pixmap + + def _create_pixmap_from_png(self, base_path, size): + """ + Create a pixmap from PNG with scaling and dark mode support. + + Prefer to use the *_large.png with the same name; otherwise, use base_path. + """ + large_path = base_path.with_name(base_path.stem + '_large.png') + source_pixmap = QtGui.QPixmap() + for candidate in (large_path, base_path): + if not candidate.exists(): + continue + candidate_pixmap = QtGui.QPixmap(str(candidate)) + if not candidate_pixmap.isNull(): + source_pixmap = candidate_pixmap + break + if source_pixmap.isNull(): + return source_pixmap + + dpr = self._devicePixelRatio() + + # Scale to requested size + scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr)) + scaled_pixmap = source_pixmap.scaled( + scaled_size, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + scaled_pixmap.setDevicePixelRatio(dpr) + + if self._is_dark_mode(): + # On some platforms (e.g., macOS with Qt5 in dark mode), this may + # incorrectly return a black color instead of a light one. + # See issue #27590 for details. + icon_color = self.toolbar.palette().color(self.toolbar.foregroundRole()) + mask = scaled_pixmap.createMaskFromColor( + QtGui.QColor('black'), + QtCore.Qt.MaskMode.MaskOutColor) + scaled_pixmap.fill(icon_color) + scaled_pixmap.setMask(mask) + + return scaled_pixmap + + class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): toolitems = [*NavigationToolbar2.toolitems] toolitems.insert( @@ -740,25 +854,16 @@ def _icon(self, name): """ Construct a `.QIcon` from an image file *name*, including the extension and relative to Matplotlib's "images" data directory. + + Uses _IconEngine for automatic DPI scaling. """ - # use a high-resolution icon with suffix '_large' if available - # note: user-provided icons may not have '_large' versions + # Get the image path path_regular = cbook._get_data_path('images', name) - path_large = path_regular.with_name( - path_regular.name.replace('.png', '_large.png')) - filename = str(path_large if path_large.exists() else path_regular) - - pm = QtGui.QPixmap(filename) - pm.setDevicePixelRatio( - self.devicePixelRatioF() or 1) # rarely, devicePixelRatioF=0 - if self.palette().color(self.backgroundRole()).value() < 128: - icon_color = self.palette().color(self.foregroundRole()) - mask = pm.createMaskFromColor( - QtGui.QColor('black'), - QtCore.Qt.MaskMode.MaskOutColor) - pm.fill(icon_color) - pm.setMask(mask) - return QtGui.QIcon(pm) + + # Create icon using our custom engine for automatic DPI handling + engine = _IconEngine(path_regular, self) + return QtGui.QIcon(engine) + def edit_parameters(self): axes = self.canvas.figure.get_axes() diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index b57a98b1138a..8f666c734b06 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -64,11 +64,11 @@ def _setup_pyqt5plus(): - global QtCore, QtGui, QtWidgets, __version__ + global QtCore, QtGui, QtWidgets, QtSvg, __version__ global _isdeleted, _to_int if QT_API == QT_API_PYQT6: - from PyQt6 import QtCore, QtGui, QtWidgets, sip + from PyQt6 import QtCore, QtGui, QtWidgets, sip, QtSvg __version__ = QtCore.PYQT_VERSION_STR QtCore.Signal = QtCore.pyqtSignal QtCore.Slot = QtCore.pyqtSlot @@ -76,7 +76,7 @@ def _setup_pyqt5plus(): _isdeleted = sip.isdeleted _to_int = operator.attrgetter('value') elif QT_API == QT_API_PYSIDE6: - from PySide6 import QtCore, QtGui, QtWidgets, __version__ + from PySide6 import QtCore, QtGui, QtWidgets, QtSvg, __version__ import shiboken6 def _isdeleted(obj): return not shiboken6.isValid(obj) if parse_version(__version__) >= parse_version('6.4'): @@ -84,7 +84,7 @@ def _isdeleted(obj): return not shiboken6.isValid(obj) else: _to_int = int elif QT_API == QT_API_PYQT5: - from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5 import QtCore, QtGui, QtWidgets, QtSvg import sip __version__ = QtCore.PYQT_VERSION_STR QtCore.Signal = QtCore.pyqtSignal @@ -93,7 +93,7 @@ def _isdeleted(obj): return not shiboken6.isValid(obj) _isdeleted = sip.isdeleted _to_int = int elif QT_API == QT_API_PYSIDE2: - from PySide2 import QtCore, QtGui, QtWidgets, __version__ + from PySide2 import QtCore, QtGui, QtWidgets, QtSvg, __version__ try: from PySide2 import shiboken2 except ImportError: From 677a2ea0ee1ed58565e3f0403f0b0dbcebe33f2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:47:04 +0100 Subject: [PATCH 173/228] Bump the actions group with 3 updates (#30690) Bumps the actions group with 3 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/download-artifact](https://github.com/actions/download-artifact) and [github/codeql-action](https://github.com/github/codeql-action). Updates `actions/upload-artifact` from 4.6.2 to 5.0.0 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4) Updates `actions/download-artifact` from 5.0.0 to 6.0.0 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/634f93cb2916e3fdff6788551b99b062d0335ce0...018cc2cf5baa6db3ef3c5f8a56943fffe632ef53) Updates `github/codeql-action` from 4.30.9 to 4.31.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/16140ae1a102900babc80a33c44059580f687047...4e94bd11f71e507f7f87df81788dff88d1dacbfb) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: github/codeql-action dependency-version: 4.31.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cibuildwheel.yml | 8 ++++---- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/tests.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index b6dccc4f6c89..44b2beec38b9 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -72,7 +72,7 @@ jobs: run: twine check dist/* - name: Upload sdist result - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: cibw-sdist path: dist/*.tar.gz @@ -137,7 +137,7 @@ jobs: steps: - name: Download sdist - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: cibw-sdist path: dist/ @@ -187,7 +187,7 @@ jobs: CIBW_ENABLE: pypy if: matrix.cibw_archs != 'aarch64' && matrix.os != 'windows-latest' && matrix.os != 'windows-11-arm' - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: cibw-wheels-${{ runner.os }}-${{ matrix.cibw_archs }} path: ./wheelhouse/*.whl @@ -205,7 +205,7 @@ jobs: contents: read steps: - name: Download packages - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: pattern: cibw-* path: dist diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 96115add7bd5..72d84e46e077 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: languages: ${{ matrix.language }} @@ -43,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1419687b58d7..f25b5e4a03dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -394,7 +394,7 @@ jobs: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }}" token: ${{ secrets.CODECOV_TOKEN }} - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: failure() with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }} result images" From 419eb3e265edd2c80412d26a4c650fad22b3f6d1 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:49:16 +0100 Subject: [PATCH 174/228] FIX: Gracefully handle numpy arrays as input to check_in_list() (#30714) Closes #30706 --- lib/matplotlib/_api/__init__.py | 16 +++++++++++++++- lib/matplotlib/tests/test_api.py | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py index 39496cfb0e82..216cc885ea24 100644 --- a/lib/matplotlib/_api/__init__.py +++ b/lib/matplotlib/_api/__init__.py @@ -116,6 +116,9 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): ---------- values : iterable Sequence of values to check on. + + Note: All values must support == comparisons. + This means in particular the entries must not be numpy arrays. _print_supported_values : bool, default: True Whether to print *values* when raising ValueError. **kwargs : dict @@ -133,7 +136,18 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): if not kwargs: raise TypeError("No argument to check!") for key, val in kwargs.items(): - if val not in values: + try: + exists = val in values + except ValueError: + # `in` internally uses `val == values[i]`. There are some objects + # that do not support == to arbitrary other objects, in particular + # numpy arrays. + # Since such objects are not allowed in values, we can gracefully + # handle the case that val (typically provided by users) is of such + # type and directly state it's not in the list instead of letting + # the individual `val == values[i]` ValueError surface. + exists = False + if not exists: msg = f"{val!r} is not a valid value for {key}" if _print_supported_values: msg += f"; supported values are {', '.join(map(repr, values))}" diff --git a/lib/matplotlib/tests/test_api.py b/lib/matplotlib/tests/test_api.py index 58e7986bfce6..4d0241264ddb 100644 --- a/lib/matplotlib/tests/test_api.py +++ b/lib/matplotlib/tests/test_api.py @@ -150,3 +150,8 @@ def f() -> None: def test_empty_check_in_list() -> None: with pytest.raises(TypeError, match="No argument to check!"): _api.check_in_list(["a"]) + + +def test_check_in_list_numpy() -> None: + with pytest.raises(ValueError, match=r"array\(5\) is not a valid value"): + _api.check_in_list(['a', 'b'], value=np.array(5)) From 6e7d8c7f3deb376f142c96736ec92cdf6f7f6348 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:19:45 +0000 Subject: [PATCH 175/228] Add file extension to whatsnew entry --- .../{colormap_with_alpha => colormap_with_alpha.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/release/next_whats_new/{colormap_with_alpha => colormap_with_alpha.rst} (100%) diff --git a/doc/release/next_whats_new/colormap_with_alpha b/doc/release/next_whats_new/colormap_with_alpha.rst similarity index 100% rename from doc/release/next_whats_new/colormap_with_alpha rename to doc/release/next_whats_new/colormap_with_alpha.rst From 1ab3332e4e724e8a0c84d1ff1bfdc14b49bb60e8 Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Sun, 9 Nov 2025 14:48:30 -0800 Subject: [PATCH 176/228] DOC: Correct grammatical issues especially on a/an usage (#30736) * DOC: Correct grammatical issues especially a/an usage * DOC: Revise the print message --- lib/matplotlib/axes/_axes.py | 2 +- lib/matplotlib/mlab.py | 2 +- lib/matplotlib/tests/test_axes.py | 2 +- src/_image_resample.h | 2 +- src/tri/_tri.h | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 8548d16d43de..778c72aaedf9 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5271,7 +5271,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, if not marker_obj.is_filled(): if orig_edgecolor is not None: _api.warn_external( - f"You passed a edgecolor/edgecolors ({orig_edgecolor!r}) " + f"You passed an edgecolor/edgecolors ({orig_edgecolor!r}) " f"for an unfilled marker ({marker!r}). Matplotlib is " "ignoring the edgecolor in favor of the facecolor. This " "behavior may change in the future." diff --git a/lib/matplotlib/mlab.py b/lib/matplotlib/mlab.py index c28774125df0..a694308384c1 100644 --- a/lib/matplotlib/mlab.py +++ b/lib/matplotlib/mlab.py @@ -353,7 +353,7 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, # the sampling frequency, if desired. Scale everything, except the DC # component and the NFFT/2 component: - # if we have a even number of frequencies, don't scale NFFT/2 + # if we have an even number of frequencies, don't scale NFFT/2 if not NFFT % 2: slc = slice(1, -1, None) # if we have an odd number, just don't scale DC diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 7307951595cb..b5c965d0eb4d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9104,7 +9104,7 @@ def test_patch_bounds(): # PR 19078 @mpl.style.context('default') def test_warn_ignored_scatter_kwargs(): with pytest.warns(UserWarning, - match=r"You passed a edgecolor/edgecolors"): + match=r"You passed an edgecolor/edgecolors"): plt.scatter([0], [0], marker="+", s=500, facecolor="r", edgecolor="b") diff --git a/src/_image_resample.h b/src/_image_resample.h index 7e6c32c6bf64..6b325c8aa14b 100644 --- a/src/_image_resample.h +++ b/src/_image_resample.h @@ -496,7 +496,7 @@ typedef enum { } interpolation_e; -// T is rgba if and only if it has an T::r field. +// T is rgba if and only if it has a T::r field. template struct is_grayscale : std::true_type {}; template struct is_grayscale> : std::false_type {}; template constexpr bool is_grayscale_v = is_grayscale::value; diff --git a/src/tri/_tri.h b/src/tri/_tri.h index 2319650b367b..994b1f43c556 100644 --- a/src/tri/_tri.h +++ b/src/tri/_tri.h @@ -75,7 +75,7 @@ namespace py = pybind11; -/* An edge of a triangle consisting of an triangle index in the range 0 to +/* An edge of a triangle consisting of a triangle index in the range 0 to * ntri-1 and an edge index in the range 0 to 2. Edge i goes from the * triangle's point i to point (i+1)%3. */ struct TriEdge final From ce4789afe179faff1c717f09d6d9b994ca439b94 Mon Sep 17 00:00:00 2001 From: Duncan Macleod Date: Tue, 11 Nov 2025 12:55:25 +0000 Subject: [PATCH 177/228] Add :code-caption: option to plot directive Closes https://github.com/matplotlib/matplotlib/issues/30740 --- lib/matplotlib/sphinxext/plot_directive.py | 13 ++++++++++-- lib/matplotlib/tests/test_sphinxext.py | 24 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index b5f10d851182..7b46b3145e2b 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -84,6 +84,11 @@ figure. This overwrites the caption given in the content, when the plot is generated from a file. +``:code-caption:`` : str + If specified, the option's argument will be used as a caption for the + code block (when ``:include-source:`` is used). This is added as the + ``:caption:`` option to the ``.. code-block::`` directive. + Additionally, this directive supports all the options of the `image directive `_, except for ``:target:`` (since plot will add its own target). These include @@ -281,6 +286,7 @@ class PlotDirective(Directive): 'context': _option_context, 'nofigs': directives.flag, 'caption': directives.unchanged, + 'code-caption': directives.unchanged, } def run(self): @@ -952,8 +958,11 @@ def run(arguments, content, options, state_machine, state, lineno): if is_doctest: lines = ['', *code_piece.splitlines()] else: - lines = ['.. code-block:: python', '', - *textwrap.indent(code_piece, ' ').splitlines()] + lines = ['.. code-block:: python'] + if 'code-caption' in options: + code_caption = options['code-caption'].replace('\n', ' ') + lines.append(f' :caption: {code_caption}') + lines.extend(['', *textwrap.indent(code_piece, ' ').splitlines()]) source_code = "\n".join(lines) else: source_code = "" diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index ede3166a2e1b..c6f4e13c74c2 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -205,6 +205,30 @@ def test_plot_html_show_source_link_custom_basename(tmp_path): assert 'custom-name.py' in html_content +def test_plot_html_code_caption(tmp_path): + # Test that :code-caption: option adds caption to code block + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" +.. plot:: + :include-source: + :code-caption: Example plotting code + + import matplotlib.pyplot as plt + plt.plot([1, 2, 3], [1, 4, 9]) +""") + html_dir = tmp_path / '_build' / 'html' + build_sphinx_html(tmp_path, doctree_dir, html_dir) + + # Check that the HTML contains the code caption + html_content = (html_dir / 'index.html').read_text(encoding='utf-8') + assert 'Example plotting code' in html_content + # Verify the caption is associated with the code block + # (appears in a caption element) + assert '

Date: Thu, 13 Nov 2025 13:28:49 -0500 Subject: [PATCH 178/228] FIX: when creating a canvas from a Figure use original dpi When we upscale the DPI for high-dpi screens we stash the original dpi and then set the figure dpi to the scaled version. If the same Figure instance is repeatedly passed to a Canvas that support hi-dpi it would go into a loop where the scaled DPI is treated as the original dpi and infinitely increases. By grabbing `fig._original_dpi` (which we stash for exactly this reason) we can avoid this loop. closes #26380 --- lib/matplotlib/backend_bases.py | 2 +- lib/matplotlib/figure.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index fc7d651a6eb4..137611ab6703 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1763,7 +1763,7 @@ def __init__(self, figure=None): self.toolbar = None # NavigationToolbar2 will set me self._is_idle_drawing = False # We don't want to scale up the figure DPI more than once. - figure._original_dpi = figure.dpi + figure._original_dpi = getattr(figure, '_original_dpi', figure.dpi) self._device_pixel_ratio = 1 super().__init__() # Typically the GUI widget init (if any). diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index eba873cdc221..6a4032f7c20a 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3003,12 +3003,10 @@ def _set_base_canvas(self): This is used upon initialization of the Figure, but also to reset the canvas when decoupling from pyplot. """ - # check if we have changed the DPI due to hi-dpi screens - orig_dpi = getattr(self, '_original_dpi', self._dpi) FigureCanvasBase(self) # Set self.canvas as a side-effect - # put it back to what it was - if orig_dpi != self._dpi: - self.dpi = orig_dpi + # undo any high-dpi scaling + if self._dpi != self._original_dpi: + self.dpi = self._original_dpi def set_canvas(self, canvas): """ From 973c99ebc4a739ec790e16182075b917599fac5a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 13 Nov 2025 23:50:01 -0500 Subject: [PATCH 179/228] FIX: correctly restore the size of unpickled figures with hi-dpi The issue is that we reset the dpi but did not re-compute any of the bounding boxes derived from the dpi leading to the initial redraw events to use the wrong physical size on the screen. Closes #27224 --- lib/matplotlib/figure.py | 5 +++-- lib/matplotlib/tests/test_figure.py | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 6a4032f7c20a..4cd7fd01a995 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3321,8 +3321,9 @@ def __setstate__(self, state): self.__dict__ = state # re-initialise some of the unstored state information - FigureCanvasBase(self) # Set self.canvas. - + self._set_base_canvas() + # force the bounding boxes to respect current dpi + self.dpi_scale_trans.clear().scale(self._dpi) if restore_to_pylab: # lazy import to avoid circularity import matplotlib.pyplot as plt diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 5f0e68648966..e666a3b99f7f 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1688,6 +1688,9 @@ def test_unpickle_with_device_pixel_ratio(): assert fig.dpi == 42*7 fig2 = pickle.loads(pickle.dumps(fig)) assert fig2.dpi == 42 + assert all( + [orig / 7 == restore for orig, restore in zip(fig.bbox.max, fig2.bbox.max)] + ) def test_gridspec_no_mutate_input(): From a00d606d592bcf8d335f4f3ac2768882d3a49e7b Mon Sep 17 00:00:00 2001 From: Steve Nicholson Date: Fri, 14 Nov 2025 18:19:42 -0800 Subject: [PATCH 180/228] Grammar corrections in User guide FAQ (#30665) * Grammar corrections - Use "its" (the possessive form of "it") instead of "it's" (the contraction of "it is"). - Change plurality of verb to match compound subject joined by "or". The verb should agree with the part of the subject closest to it. See https://editorsmanual.com/articles/compound-subject-singular-or-plural/. * Grammar changes in FAQ Restructure sentence to avoid subject/pronoun/verb disagreement. * Update doc/users/faq.rst Co-authored-by: Elliott Sales de Andrade --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Co-authored-by: Elliott Sales de Andrade --- doc/users/faq.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/users/faq.rst b/doc/users/faq.rst index d13625ec9907..e7edcc50f551 100644 --- a/doc/users/faq.rst +++ b/doc/users/faq.rst @@ -77,8 +77,8 @@ empty if it was rendered pure white (there may be artists present, but they could be outside the drawing area or transparent)? For the purpose here, we define empty as: "The figure does not contain any -artists except it's background patch." The exception for the background is -necessary, because by default every figure contains a `.Rectangle` as it's +artists except its background patch." The exception for the background is +necessary, because by default every figure contains a `.Rectangle` as its background patch. This definition could be checked via:: def is_empty(figure): @@ -91,8 +91,8 @@ background patch. This definition could be checked via:: We've decided not to include this as a figure method because this is only one way of defining empty, and checking the above is only rarely necessary. -Usually the user or program handling the figure know if they have added -something to the figure. +Whether or not something has been added to the figure is usually defined +within the context of the program. The only reliable way to check whether a figure would render empty is to actually perform such a rendering and inspect the result. From a7cc885aee84fef63dd5c322bff89f4e2d8ab845 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:20:05 +0100 Subject: [PATCH 181/228] Update mpl-sphinx-theme in environment.yml Update mpl-sphinx-theme in environment.yml We still explicitly list pydata-sphinx-theme so that it and its dependencies is fetched from conda-forge and not pip. Also pikepdf is now available on conda-forge --- environment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index bc89131a6742..418062d0d237 100644 --- a/environment.yml +++ b/environment.yml @@ -37,7 +37,7 @@ dependencies: - ipywidgets - numpydoc>=1.0 - packaging>=20 - - pydata-sphinx-theme~=0.15.0 + - pydata-sphinx-theme=0.16.1 # required by mpl-sphinx-theme=3.10 - pyyaml - sphinx>=3.0.0,!=6.1.2 - sphinx-copybutton @@ -46,12 +46,12 @@ dependencies: - sphinx-design - sphinx-tags>=0.4.0 - pystemmer + - pikepdf - pip - pip: - - mpl-sphinx-theme~=3.8.0 + - mpl-sphinx-theme~=3.10.0 - sphinxcontrib-svg2pdfconverter>=1.1.0 - sphinxcontrib-video>=0.2.1 - - pikepdf # testing - black<26 - coverage From e99d98b2f2d47fcfec8ef428c18d95162f36e495 Mon Sep 17 00:00:00 2001 From: Edge-Seven <143301646+Edge-Seven@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:33:32 +0700 Subject: [PATCH 182/228] Fix typos in some files (#30759) Co-authored-by: khanhkhanhlele --- extern/agg24-svn/include/ctrl/agg_gamma_spline.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/agg24-svn/include/ctrl/agg_gamma_spline.h b/extern/agg24-svn/include/ctrl/agg_gamma_spline.h index 4f21710d9f29..052f972e85c1 100644 --- a/extern/agg24-svn/include/ctrl/agg_gamma_spline.h +++ b/extern/agg24-svn/include/ctrl/agg_gamma_spline.h @@ -56,7 +56,7 @@ namespace agg // bounding rectangle. Function values() calculates the curve by these // 4 values. After calling it one can get the gamma-array with call gamma(). // Class also supports the vertex source interface, i.e rewind() and - // vertex(). It's made for convinience and used in class gamma_ctrl. + // vertex(). It's made for convenience and used in class gamma_ctrl. // Before calling rewind/vertex one must set the bounding box // box() using pixel coordinates. //------------------------------------------------------------------------ From 2630be91455cbb91dff6940ab367f29da59c5cec Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:00:27 +0100 Subject: [PATCH 183/228] DOC: Clarify restrictions on GenAI usage - Use bullet points to explicitly separate the areas comments/discussion and code. - Be very explicit that contributions require adding personal value. - Explicitly state that we can reject low-value AI content. I've added this, so that we can friendly but firmly discard contributions with a sentence like "This contributions looks purely AI generated and does not comply with our [policy on AI usage](link)" --- doc/devel/contribute.rst | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/doc/devel/contribute.rst b/doc/devel/contribute.rst index e2291e3255e6..fe81e625421e 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -188,13 +188,20 @@ If you have developed an extension to Matplotlib, please consider adding it to o Restrictions on Generative AI Usage =================================== -We expect authentic engagement in our community. Be wary of posting output -from Large Language Models or similar generative AI as comments on GitHub or -our discourse server, as such comments tend to be formulaic and low content. -If you use generative AI tools as an aid in developing code or documentation -changes, ensure that you fully understand the proposed changes and can explain -why they are the correct approach and an improvement to the current state. - +We expect authentic engagement in our community. + +- Do not post output from Large Language Models or similar generative AI as + comments on GitHub or our discourse server, as such comments tend to be + formulaic and low content. +- If you use generative AI tools as an aid in developing code or documentation + changes, ensure that you fully understand the proposed changes and can + explain why they are the correct approach. + +Make sure you have added value based on your personal competency to your +contributions. Just taking some input, feeding it to an AI and posting the +result is not of value to the project. To preserve precious core developer +capacity, we reserve the right to rigorously reject seemingly AI generated +low-value contributions. .. _new_contributors: From 596bc2c1107545919d25586476f86abee2be5bb1 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:02:53 +0100 Subject: [PATCH 184/228] DOC: Add example how to align tick labels Closes #21566. --- galleries/examples/ticks/align_ticklabels.py | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 galleries/examples/ticks/align_ticklabels.py diff --git a/galleries/examples/ticks/align_ticklabels.py b/galleries/examples/ticks/align_ticklabels.py new file mode 100644 index 000000000000..ec36e0db4d07 --- /dev/null +++ b/galleries/examples/ticks/align_ticklabels.py @@ -0,0 +1,32 @@ +""" +================= +Align tick labels +================= + +By default, tick labels are aligned towards the axis. This means the set of +*y* tick labels appear right-aligned. Because the alignment reference point +is on the axis, left-aligned tick labels would overlap the plotting area. +To achieve a good-looking left-alignment, you have to additionally increase +the padding. +""" +import matplotlib.pyplot as plt + +population = { + "Sydney": 5.2, + "Mexico City": 8.8, + "São Paulo": 12.2, + "Istanbul": 15.9, + "Lagos": 15.9, + "Shanghai": 21.9, +} + +fig, ax = plt.subplots(layout="constrained") +ax.barh(population.keys(), population.values()) +ax.set_xlabel('Population (in millions)') + +# left-align all ticklabels +for ticklabel in ax.get_yticklabels(): + ticklabel.set_horizontalalignment("left") + +# increase padding +ax.tick_params("y", pad=70) From a626f710e9236cbe1c67ba3fc3b36efe42248c5c Mon Sep 17 00:00:00 2001 From: Hannah Date: Tue, 28 Oct 2025 17:44:44 -0400 Subject: [PATCH 185/228] adds admonition about AI usage/link to policy to dev landing page --- doc/devel/index.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/devel/index.rst b/doc/devel/index.rst index 7591359ec811..fdeb08d3b202 100644 --- a/doc/devel/index.rst +++ b/doc/devel/index.rst @@ -189,8 +189,13 @@ and managing a development environment and workflow: Policies and guidelines ======================= -These policies and guidelines help us maintain consistency in the various types -of maintenance work. If you are writing code or documentation, following these policies +.. admonition:: AI Usage + + AI may be used responsibly as a supportive tool, but we expect authentic + contributions. For guidance, see our :ref:`AI policy `. + +These policies and guidelines help us maintain consistency in the various types of +maintenance work. If you are writing code or documentation, following these policies helps maintainers more easily review your work. If you are helping triage, community manage, or release manage, these guidelines describe how our current process works. From 00b862f0b1c450ffd8c9ba0a98b02a62376a80e2 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:06:11 +0100 Subject: [PATCH 186/228] DOC: Fix documentation error of hexbin Closes #30764. --- lib/matplotlib/axes/_axes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 778c72aaedf9..812f7dd02947 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5420,8 +5420,9 @@ def hexbin(self, x, y, C=None, gridsize=100, bins=None, - If *None*, no binning is applied; the color of each hexagon directly corresponds to its count value. - If 'log', use a logarithmic scale for the colormap. - Internally, :math:`log_{10}(i+1)` is used to determine the + Internally, :math:`log_{10}(i)` is used to determine the hexagon color. This is equivalent to ``norm=LogNorm()``. + Note that 0 counts are thus marked with the "bad" color. - If an integer, divide the counts in the specified number of bins, and color the hexagons accordingly. - If a sequence of values, the values of the lower bound of From 50aee836e46693466263b8ba003e686b187d1cbf Mon Sep 17 00:00:00 2001 From: Ruth Comer Date: Thu, 20 Nov 2025 16:17:40 +0000 Subject: [PATCH 187/228] FIX: figureoptions updates title string only --- lib/matplotlib/backends/qt_editor/figureoptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index 9d31fa9ced2c..9c57b7c4e968 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -194,7 +194,7 @@ def apply_callback(data): raise ValueError("Unexpected field") title = general.pop(0) - axes.set_title(title) + axes.title.set_text(title) generate_legend = general.pop() for i, (name, axis) in enumerate(axis_map.items()): From a97c66521391732226a7e30f39711567bacd7e91 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:17:13 +0000 Subject: [PATCH 188/228] ENH: introduce PieContainer and pie_label method --- doc/api/axes_api.rst | 1 + doc/api/pyplot_summary.rst | 1 + doc/release/next_whats_new/pie_label.rst | 28 +++ galleries/examples/misc/svg_filter_pie.py | 6 +- .../pie_and_donut_labels.py | 38 ++-- .../pie_and_polar_charts/pie_label.py | 100 +++++++++ lib/matplotlib/axes/_axes.py | 203 +++++++++++++----- lib/matplotlib/axes/_axes.pyi | 20 +- lib/matplotlib/container.py | 72 +++++++ lib/matplotlib/container.pyi | 22 +- lib/matplotlib/pyplot.py | 27 ++- lib/matplotlib/tests/test_axes.py | 52 +++++ lib/matplotlib/tests/test_container.py | 23 ++ tools/boilerplate.py | 1 + 14 files changed, 506 insertions(+), 88 deletions(-) create mode 100644 doc/release/next_whats_new/pie_label.rst create mode 100644 galleries/examples/pie_and_polar_charts/pie_label.py diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index b742ce9b7a55..f5af8744a2bc 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -73,6 +73,7 @@ Basic Axes.eventplot Axes.pie + Axes.pie_label Axes.stackplot diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index c4a860fd2590..97d9c576cc86 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -64,6 +64,7 @@ Basic stem eventplot pie + pie_label stackplot broken_barh vlines diff --git a/doc/release/next_whats_new/pie_label.rst b/doc/release/next_whats_new/pie_label.rst new file mode 100644 index 000000000000..6dc9a3f619c2 --- /dev/null +++ b/doc/release/next_whats_new/pie_label.rst @@ -0,0 +1,28 @@ +Adding labels to pie chart wedges +--------------------------------- + +The new `~.Axes.pie_label` method adds a label to each wedge in a pie chart created with +`~.Axes.pie`. It can take + +* a list of strings, similar to the existing *labels* parameter of `~.Axes.pie` +* a format string similar to the existing *autopct* parameter of `~.Axes.pie` except + that it uses the `str.format` method and it can handle absolute values as well as + fractions/percentages + +For more examples, see :doc:`/gallery/pie_and_polar_charts/pie_label`. + +.. plot:: + :include-source: true + :alt: A pie chart with three labels on each wedge, showing a food type, number, and fraction associated with the wedge. + + import matplotlib.pyplot as plt + + data = [36, 24, 8, 12] + labels = ['spam', 'eggs', 'bacon', 'sausage'] + + fig, ax = plt.subplots() + pie = ax.pie(data) + + ax.pie_label(pie, labels, distance=1.1) + ax.pie_label(pie, '{frac:.1%}', distance=0.7) + ax.pie_label(pie, '{absval:d}', distance=0.4) diff --git a/galleries/examples/misc/svg_filter_pie.py b/galleries/examples/misc/svg_filter_pie.py index b19867be9a2f..f8ccc5bcb22b 100644 --- a/galleries/examples/misc/svg_filter_pie.py +++ b/galleries/examples/misc/svg_filter_pie.py @@ -28,16 +28,16 @@ # We want to draw the shadow for each pie, but we will not use "shadow" # option as it doesn't save the references to the shadow patches. -pies = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') +pie = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') -for w in pies[0]: +for w in pie.wedges: # set the id with the label. w.set_gid(w.get_label()) # we don't want to draw the edge of the pie w.set_edgecolor("none") -for w in pies[0]: +for w in pie.wedges: # create shadow patch s = Shadow(w, -0.01, -0.01) s.set_gid(w.get_gid() + "_shadow") diff --git a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py index 13e3019bc7ba..78e884128d1e 100644 --- a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py +++ b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py @@ -6,7 +6,8 @@ Welcome to the Matplotlib bakery. We will create a pie and a donut chart through the `pie method ` and show how to label them with a `legend ` -as well as with `annotations `. +as well as with the `pie_label method ` and +`annotations `. """ # %% @@ -15,12 +16,14 @@ # Now it's time for the pie. Starting with a pie recipe, we create the data # and a list of labels from it. # -# We can provide a function to the ``autopct`` argument, which will expand -# automatic percentage labeling by showing absolute values; we calculate -# the latter back from relative data and the known sum of all values. +# We then create the pie and store the returned `~matplotlib.container.PieContainer` +# object for later. # -# We then create the pie and store the returned objects for later. The first -# returned element of the returned tuple is a list of the wedges. Those are +# We can provide the `~matplotlib.container.PieContainer` and a format string to +# the `~matplotlib.axes.Axes.pie_label` method to automatically label each +# ingredient's wedge with its weight in grams and percentages. +# +# The `~.PieContainer` has a list of patches as one of its attributes. Those are # `matplotlib.patches.Wedge` patches, which can directly be used as the handles # for a legend. We can use the legend's ``bbox_to_anchor`` argument to position # the legend outside of the pie. Here we use the axes coordinates ``(1, 0, 0.5, @@ -31,32 +34,26 @@ import matplotlib.pyplot as plt import numpy as np -fig, ax = plt.subplots(figsize=(6, 3), subplot_kw=dict(aspect="equal")) +fig, ax = plt.subplots(figsize=(6, 3)) recipe = ["375 g flour", "75 g sugar", "250 g butter", "300 g berries"] -data = [float(x.split()[0]) for x in recipe] +data = [int(x.split()[0]) for x in recipe] ingredients = [x.split()[-1] for x in recipe] +pie = ax.pie(data) -def func(pct, allvals): - absolute = int(np.round(pct/100.*np.sum(allvals))) - return f"{pct:.1f}%\n({absolute:d} g)" - +ax.pie_label(pie, '{frac:.1%}\n({absval:d}g)', + textprops=dict(color="w", size=8, weight="bold")) -wedges, texts, autotexts = ax.pie(data, autopct=lambda pct: func(pct, data), - textprops=dict(color="w")) - -ax.legend(wedges, ingredients, +ax.legend(pie.wedges, ingredients, title="Ingredients", loc="center left", bbox_to_anchor=(1, 0, 0.5, 1)) -plt.setp(autotexts, size=8, weight="bold") - ax.set_title("Matplotlib bakery: A pie") plt.show() @@ -97,13 +94,13 @@ def func(pct, allvals): data = [225, 90, 50, 60, 100, 5] -wedges, texts = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) +pie = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) bbox_props = dict(boxstyle="square,pad=0.3", fc="w", ec="k", lw=0.72) kw = dict(arrowprops=dict(arrowstyle="-"), bbox=bbox_props, zorder=0, va="center") -for i, p in enumerate(wedges): +for i, p in enumerate(pie.wedges): ang = (p.theta2 - p.theta1)/2. + p.theta1 y = np.sin(np.deg2rad(ang)) x = np.cos(np.deg2rad(ang)) @@ -131,6 +128,7 @@ def func(pct, allvals): # in this example: # # - `matplotlib.axes.Axes.pie` / `matplotlib.pyplot.pie` +# - `matplotlib.axes.Axes.pie_label` / `matplotlib.pyplot.pie_label` # - `matplotlib.axes.Axes.legend` / `matplotlib.pyplot.legend` # # .. tags:: diff --git a/galleries/examples/pie_and_polar_charts/pie_label.py b/galleries/examples/pie_and_polar_charts/pie_label.py new file mode 100644 index 000000000000..d7f690bd6f85 --- /dev/null +++ b/galleries/examples/pie_and_polar_charts/pie_label.py @@ -0,0 +1,100 @@ +""" +=================== +Labeling pie charts +=================== + +This example illustrates some features of the `~matplotlib.axes.Axes.pie_label` +method, which adds labels to an existing pie chart created with +`~matplotlib.axes.Axes.pie`. +""" + +# %% +# The simplest option is to provide a list of strings to label each slice of the pie. + +import matplotlib.pyplot as plt + +data = [36, 24, 8, 12] +labels = ['spam', 'eggs', 'bacon', 'sausage'] + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels) + +# %% +# +# If you want the labels outside the pie, set a *distance* greater than 1. +# This is the distance from the center of the pie as a fraction of its radius. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, distance=1.1) + +# %% +# +# You can also rotate the labels so they are oriented away from the pie center. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, rotate=True) + +# %% +# +# Instead of explicit labels, pass a format string to label slices with their values... + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{absval:.1f}') + +# %% +# +# ...or with their percentages... + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{frac:.1%}') + +# %% +# +# ...or both. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{absval:d}\n{frac:.1%}') + +# %% +# +# Font styling can be configured by passing a dictionary to the *textprops* parameter. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, textprops={'fontsize': 'large', 'color': 'white'}) + +# %% +# +# `~matplotlib.axes.Axes.pie_label` can be called repeatedly to add multiple sets +# of labels. + +# sphinx_gallery_thumbnail_number = -1 + +fig, ax = plt.subplots() +pie = ax.pie(data) + +ax.pie_label(pie, labels, distance=1.1) +ax.pie_label(pie, '{frac:.1%}', distance=0.7) +ax.pie_label(pie, '{absval:d}', distance=0.4) + +plt.show() + +# %% +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.axes.Axes.pie` / `matplotlib.pyplot.pie` +# - `matplotlib.axes.Axes.pie_label` / `matplotlib.pyplot.pie_label` +# +# .. tags:: +# +# plot-type: pie +# level: beginner diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 8548d16d43de..ae12579ab172 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -36,7 +36,9 @@ from matplotlib.axes._base import ( _AxesBase, _TransformedBoundsLocator, _process_plot_format) from matplotlib.axes._secondary_axes import SecondaryAxis -from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.container import ( + BarContainer, ErrorbarContainer, PieContainer, StemContainer) +from matplotlib.text import Text from matplotlib.transforms import _ScaledRotation _log = logging.getLogger(__name__) @@ -3594,7 +3596,7 @@ def pie(self, x, explode=None, labels=None, colors=None, keywords, properties passed to *wedgeprops* take precedence. textprops : dict, default: None - Dict of arguments to pass to the text objects. + Dict of arguments to pass to the `.Text` objects. center : (float, float), default: (0, 0) The coordinates of the center of the chart. @@ -3615,15 +3617,11 @@ def pie(self, x, explode=None, labels=None, colors=None, Returns ------- - patches : list - A sequence of `matplotlib.patches.Wedge` instances + `.PieContainer` + Container with all the wedge patches and any associated text objects. - texts : list - A list of the label `.Text` instances. - - autotexts : list - A list of `.Text` instances for the numeric labels. This will only - be returned if the parameter *autopct* is not *None*. + .. versionchanged:: 3.11 + Previously the wedges and texts were returned in a tuple. Notes ----- @@ -3633,9 +3631,7 @@ def pie(self, x, explode=None, labels=None, colors=None, The Axes aspect ratio can be controlled with `.Axes.set_aspect`. """ self.set_aspect('equal') - # The use of float32 is "historical", but can't be changed without - # regenerating the test baselines. - x = np.asarray(x, np.float32) + x = np.asarray(x) if x.ndim > 1: raise ValueError("x must be 1D") @@ -3651,9 +3647,11 @@ def pie(self, x, explode=None, labels=None, colors=None, raise ValueError('All wedge sizes are zero') if normalize: - x = x / sx + fracs = x / sx elif sx > 1: raise ValueError('Cannot plot an unnormalized pie with sum(x) > 1') + else: + fracs = x if labels is None: labels = [''] * len(x) if explode is None: @@ -3681,21 +3679,17 @@ def get_next_color(): if wedgeprops is None: wedgeprops = {} - if textprops is None: - textprops = {} - texts = [] slices = [] - autotexts = [] - for frac, label, expl in zip(x, labels, explode): - x, y = center + for frac, label, expl in zip(fracs, labels, explode): + x_pos, y_pos = center theta2 = (theta1 + frac) if counterclock else (theta1 - frac) thetam = 2 * np.pi * 0.5 * (theta1 + theta2) - x += expl * math.cos(thetam) - y += expl * math.sin(thetam) + x_pos += expl * math.cos(thetam) + y_pos += expl * math.sin(thetam) - w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2), + w = mpatches.Wedge((x_pos, y_pos), radius, 360. * min(theta1, theta2), 360. * max(theta1, theta2), facecolor=get_next_color(), hatch=next(hatch_cycle), @@ -3713,28 +3707,28 @@ def get_next_color(): shadow_dict.update(shadow) self.add_patch(mpatches.Shadow(w, **shadow_dict)) - if labeldistance is not None: - xt = x + labeldistance * radius * math.cos(thetam) - yt = y + labeldistance * radius * math.sin(thetam) - label_alignment_h = 'left' if xt > 0 else 'right' - label_alignment_v = 'center' - label_rotation = 'horizontal' - if rotatelabels: - label_alignment_v = 'bottom' if yt > 0 else 'top' - label_rotation = (np.rad2deg(thetam) - + (0 if xt > 0 else 180)) - t = self.text(xt, yt, label, - clip_on=False, - horizontalalignment=label_alignment_h, - verticalalignment=label_alignment_v, - rotation=label_rotation, - size=mpl.rcParams['xtick.labelsize']) - t.set(**textprops) - texts.append(t) - - if autopct is not None: - xt = x + pctdistance * radius * math.cos(thetam) - yt = y + pctdistance * radius * math.sin(thetam) + theta1 = theta2 + + pc = PieContainer(slices, x, normalize) + + if labeldistance is None: + # Insert an empty list of texts for backwards compatibility of the + # return value. + pc.add_texts([]) + else: + # Add labels to the wedges. + labels_textprops = { + 'fontsize': mpl.rcParams['xtick.labelsize'], + **cbook.normalize_kwargs(textprops or {}, Text) + } + self.pie_label(pc, labels, distance=labeldistance, + alignment='outer', rotate=rotatelabels, + textprops=labels_textprops) + + if autopct is not None: + # Add automatic percentage labels to wedges + auto_labels = [] + for frac in fracs: if isinstance(autopct, str): s = autopct % (100. * frac) elif callable(autopct): @@ -3742,17 +3736,15 @@ def get_next_color(): else: raise TypeError( 'autopct must be callable or a format string') - if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): + if textprops is not None and mpl._val_or_rc(textprops.get("usetex"), + "text.usetex"): # escape % (i.e. \%) if it is not already escaped s = re.sub(r"([^\\])%", r"\1\\%", s) - t = self.text(xt, yt, s, - clip_on=False, - horizontalalignment='center', - verticalalignment='center') - t.set(**textprops) - autotexts.append(t) + auto_labels.append(s) - theta1 = theta2 + self.pie_label(pc, auto_labels, distance=pctdistance, + alignment='center', + textprops=textprops) if frame: self._request_autoscale_view() @@ -3761,10 +3753,107 @@ def get_next_color(): xlim=(-1.25 + center[0], 1.25 + center[0]), ylim=(-1.25 + center[1], 1.25 + center[1])) - if autopct is None: - return slices, texts - else: - return slices, texts, autotexts + return pc + + def pie_label(self, container, /, labels, *, distance=0.6, + textprops=None, rotate=False, alignment='auto'): + """ + Label a pie chart. + + .. versionadded:: 3.11 + + Adds labels to wedges in the given `.PieContainer`. + + Parameters + ---------- + container : `.PieContainer` + Container with all the wedges, likely returned from `.pie`. + + labels : str or list of str + A sequence of strings providing the labels for each wedge, or a format + string with ``absval`` and/or ``frac`` placeholders. For example, to label + each wedge with its value and the percentage in brackets:: + + wedge_labels="{absval:d} ({frac:.0%})" + + distance : float, default: 0.6 + The radial position of the labels, relative to the pie radius. Values > 1 + are outside the wedge and values < 1 are inside the wedge. + + textprops : dict, default: None + Dict of arguments to pass to the `.Text` objects. + + rotate : bool, default: False + Rotate each label to the angle of the corresponding slice if true. + + alignment : {'center', 'outer', 'auto'}, default: 'auto' + Controls the horizontal alignment of the text objects relative to their + nominal position. + + - 'center': The labels are centered on their points. + - 'outer': Labels are aligned away from the center of the pie, i.e., labels + on the left side of the pie are right-aligned and labels on the right + side are left-aligned. + - 'auto': Translates to 'outer' if *distance* > 1 (so that the labels do not + overlap the wedges) and 'center' if *distance* < 1. + + If *rotate* is True, the vertical alignment is also affected in an + analogous way. + + - 'center': The labels are centered on their points. + - 'outer': Labels are aligned away from the center of the pie, i.e., labels + on the top half of the pie are bottom-aligned and labels on the bottom + half are top-aligned. + + Returns + ------- + list + A list of the label `.Text` instances. + """ + _api.check_in_list(['center', 'outer', 'auto'], alignment=alignment) + if alignment == 'auto': + alignment = 'outer' if distance > 1 else 'center' + + if textprops is None: + textprops = {} + + if isinstance(labels, str): + # Assume we have a format string + labels = [labels.format(absval=val, frac=frac) for val, frac in + zip(container.values, container.fracs)] + if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): + # escape % (i.e. \%) if it is not already escaped + labels = [re.sub(r"([^\\])%", r"\1\\%", s) for s in labels] + elif (nw := len(container.wedges)) != (nl := len(labels)): + raise ValueError( + f'The number of labels ({nl}) must match the number of wedges ({nw})') + + texts = [] + + for wedge, label in zip(container.wedges, labels): + thetam = 2 * np.pi * 0.5 * (wedge.theta1 + wedge.theta2) / 360 + xt = wedge.center[0] + distance * wedge.r * math.cos(thetam) + yt = wedge.center[1] + distance * wedge.r * math.sin(thetam) + if alignment == 'outer': + label_alignment_h = 'left' if xt > 0 else 'right' + else: + label_alignment_h = 'center' + label_alignment_v = 'center' + label_rotation = 'horizontal' + if rotate: + if alignment == 'outer': + label_alignment_v = 'bottom' if yt > 0 else 'top' + label_rotation = (np.rad2deg(thetam) + (0 if xt > 0 else 180)) + t = self.text(xt, yt, label, clip_on=False, rotation=label_rotation, + horizontalalignment=label_alignment_h, + verticalalignment=label_alignment_v) + t.set(**textprops) + texts.append(t) + + container.add_texts(texts) + + return texts + @staticmethod def _errorevery_to_mask(x, errorevery): diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 69d251aa21f7..09587ab753a3 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -13,7 +13,8 @@ from matplotlib.collections import ( ) from matplotlib.colorizer import Colorizer from matplotlib.colors import Colormap, Normalize -from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.container import ( + BarContainer, PieContainer, ErrorbarContainer, StemContainer) from matplotlib.contour import ContourSet, QuadContourSet from matplotlib.image import AxesImage, PcolorImage from matplotlib.inset import InsetIndicator @@ -21,7 +22,7 @@ from matplotlib.legend import Legend from matplotlib.legend_handler import HandlerBase from matplotlib.lines import Line2D, AxLine from matplotlib.mlab import GaussianKDE -from matplotlib.patches import Rectangle, FancyArrow, Polygon, StepPatch, Wedge +from matplotlib.patches import Rectangle, FancyArrow, Polygon, StepPatch from matplotlib.quiver import Quiver, QuiverKey, Barbs from matplotlib.text import Annotation, Text from matplotlib.transforms import Transform @@ -324,9 +325,18 @@ class Axes(_AxesBase): normalize: bool = ..., hatch: str | Sequence[str] | None = ..., data=..., - ) -> tuple[list[Wedge], list[Text]] | tuple[ - list[Wedge], list[Text], list[Text] - ]: ... + ) -> PieContainer: ... + def pie_label( + self, + container: PieContainer, + /, + labels: str | Sequence[str], + *, + distance: float = ..., + textprops: dict | None = ..., + rotate: bool = ..., + alignment: str = ..., + ) -> list[Text]: ... def errorbar( self, x: float | ArrayLike, diff --git a/lib/matplotlib/container.py b/lib/matplotlib/container.py index fcf2e6016db9..96b14cfd26f7 100644 --- a/lib/matplotlib/container.py +++ b/lib/matplotlib/container.py @@ -148,6 +148,78 @@ def __init__(self, lines, has_xerr=False, has_yerr=False, **kwargs): super().__init__(lines, **kwargs) +class PieContainer: + """ + Container for the artists of pie charts (e.g. created by `.Axes.pie`). + + .. versionadded:: 3.11 + + .. warning:: + The class name ``PieContainer`` name is provisional and may change in future + to reflect development of its functionality. + + You can access the wedge patches and further parameters by the attributes. + + Attributes + ---------- + wedges : list of `~matplotlib.patches.Wedge` + The artists of the pie wedges. + + values : `numpy.ndarray` + The data that the pie is based on. + + fracs : `numpy.ndarray` + The fraction of the pie that each wedge represents. + + texts : list of list of `~matplotlib.text.Text` + The artists of any labels on the pie wedges. Each inner list has one + text label per wedge. + + """ + def __init__(self, wedges, values, normalize): + self.wedges = wedges + self._texts = [] + self._values = values + self._normalize = normalize + + @property + def texts(self): + # Only return non-empty sublists. An empty sublist may have been added + # for backwards compatibility of the Axes.pie return value (see __getitem__). + return [t_list for t_list in self._texts if t_list] + + @property + def values(self): + result = self._values.copy() + result.flags.writeable = False + return result + + @property + def fracs(self): + if self._normalize: + result = self._values / self._values.sum() + else: + result = self._values + + result.flags.writeable = False + return result + + def add_texts(self, texts): + """Add a list of `~matplotlib.text.Text` objects to the container.""" + self._texts.append(texts) + + def remove(self): + """Remove all wedges and texts from the axes""" + for artist_list in self.wedges, self._texts: + for artist in cbook.flatten(artist_list): + artist.remove() + + def __getitem__(self, key): + # needed to support unpacking into a tuple for backward compatibility of the + # Axes.pie return value + return (self.wedges, *self._texts)[key] + + class StemContainer(Container): """ Container for the artists created in a :meth:`.Axes.stem` plot. diff --git a/lib/matplotlib/container.pyi b/lib/matplotlib/container.pyi index ff11830c544c..772801b16d6d 100644 --- a/lib/matplotlib/container.pyi +++ b/lib/matplotlib/container.pyi @@ -1,11 +1,13 @@ from matplotlib.artist import Artist from matplotlib.lines import Line2D from matplotlib.collections import LineCollection -from matplotlib.patches import Rectangle +from matplotlib.patches import Rectangle, Wedge +from matplotlib.text import Text from collections.abc import Callable from typing import Any, Literal from numpy.typing import ArrayLike +from numpy import ndarray class Container(tuple): def __new__(cls, *args, **kwargs): ... @@ -51,6 +53,24 @@ class ErrorbarContainer(Container): **kwargs ) -> None: ... +class PieContainer(Container): + wedges: list[Wedge] + def __init__( + self, + wedges: list[Wedge], + values: ndarray, + normalize: bool, + ) -> None: ... + @property + def texts(self) -> list[list[Text]]: ... + @property + def values(self) -> ndarray: ... + @property + def fracs(self) -> ndarray: ... + def add_texts(self, + texts: list[Text], + ) -> None: ... + class StemContainer(Container): markerline: Line2D stemlines: LineCollection diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index cecd3dc24737..77c9a51352b2 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -126,13 +126,14 @@ from matplotlib.container import ( BarContainer, ErrorbarContainer, + PieContainer, StemContainer, ) from matplotlib.figure import SubFigure from matplotlib.legend import Legend from matplotlib.mlab import GaussianKDE from matplotlib.image import AxesImage, FigureImage - from matplotlib.patches import FancyArrow, StepPatch, Wedge + from matplotlib.patches import FancyArrow, StepPatch from matplotlib.quiver import Barbs, Quiver, QuiverKey from matplotlib.scale import ScaleBase from matplotlib.typing import ( @@ -3955,7 +3956,7 @@ def pie( normalize: bool = True, hatch: str | Sequence[str] | None = None, data=None, -) -> tuple[list[Wedge], list[Text]] | tuple[list[Wedge], list[Text], list[Text]]: +) -> PieContainer: return gca().pie( x, explode=explode, @@ -3979,6 +3980,28 @@ def pie( ) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.pie_label) +def pie_label( + container: PieContainer, + /, + labels: str | Sequence[str], + *, + distance: float = 0.6, + textprops: dict | None = None, + rotate: bool = False, + alignment: str = "auto", +) -> list[Text]: + return gca().pie_label( + container, + labels, + distance=distance, + textprops=textprops, + rotate=rotate, + alignment=alignment, + ) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.plot) def plot( diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 7307951595cb..03942319e0b9 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8,6 +8,7 @@ import io from itertools import product import platform +import re import sys from types import SimpleNamespace @@ -6608,6 +6609,57 @@ def test_pie_hatch_multi(fig_test, fig_ref): [w.set_hatch(hp) for w, hp in zip(wedges, hatch)] +def test_pie_label_formatter(): + fig, ax = plt.subplots() + pie = ax.pie([2, 3]) + + texts = ax.pie_label(pie, '{absval:03d}') + assert texts[0].get_text() == '002' + assert texts[1].get_text() == '003' + + texts = ax.pie_label(pie, '{frac:.1%}') + assert texts[0].get_text() == '40.0%' + assert texts[1].get_text() == '60.0%' + + +@pytest.mark.parametrize('distance', [0.6, 1.1]) +@pytest.mark.parametrize('rotate', [False, True]) +def test_pie_label_auto_align(distance, rotate): + fig, ax = plt.subplots() + pie = ax.pie([1, 1], startangle=45) + + texts = ax.pie_label( + pie, ['spam', 'eggs'], distance=distance, rotate=rotate, alignment='auto') + + if distance < 1: + for text in texts: + # labels within the pie should be centered + assert text.get_horizontalalignment() == 'center' + assert text.get_verticalalignment() == 'center' + + else: + # labels outside the pie should be aligned away from it + h_expected = ['right', 'left'] + v_expected = ['bottom', 'top'] + for text, h_align, v_align in zip(texts, h_expected, v_expected): + assert text.get_horizontalalignment() == h_align + if rotate: + assert text.get_verticalalignment() == v_align + else: + assert text.get_verticalalignment() == 'center' + + +def test_pie_label_fail(): + sizes = 15, 30, 45, 10 + labels = 'Frogs', 'Hogs' + fig, ax = plt.subplots() + pie = ax.pie(sizes) + + match = re.escape("The number of labels (2) must match the number of wedges (4)") + with pytest.raises(ValueError, match=match): + ax.pie_label(pie, labels) + + @image_comparison(['set_get_ticklabels.png'], tol=0 if platform.machine() == 'x86_64' else 0.025) def test_set_get_ticklabels(): diff --git a/lib/matplotlib/tests/test_container.py b/lib/matplotlib/tests/test_container.py index 6998101dd755..b7dfe1196685 100644 --- a/lib/matplotlib/tests/test_container.py +++ b/lib/matplotlib/tests/test_container.py @@ -53,3 +53,26 @@ def test_barcontainer_position_centers__bottoms__tops(): assert_array_equal(container.position_centers, pos) assert_array_equal(container.bottoms, bottoms) assert_array_equal(container.tops, bottoms + heights) + + +def test_piecontainer_remove(): + fig, ax = plt.subplots() + pie = ax.pie([2, 3], labels=['foo', 'bar'], autopct="%1.0f%%") + ax.pie_label(pie, ['baz', 'qux']) + assert len(ax.patches) == 2 + assert len(ax.texts) == 6 + + pie.remove() + assert not ax.patches + assert not ax.texts + + +def test_piecontainer_unpack_backcompat(): + fig, ax = plt.subplots() + wedges, texts, autotexts = ax.pie( + [2, 3], labels=['foo', 'bar'], autopct="%1.0f%%", labeldistance=None) + + assert len(wedges) == 2 + assert isinstance(texts, list) + assert not texts + assert len(autotexts) == 2 diff --git a/tools/boilerplate.py b/tools/boilerplate.py index a617d12c7072..0a1a26c7cb76 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -263,6 +263,7 @@ def boilerplate_gen(): 'pcolormesh', 'phase_spectrum', 'pie', + 'pie_label', 'plot', 'psd', 'quiver', From b94feab6595921bc3bfdfc71c25dfc8b0900f524 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:37:58 +0100 Subject: [PATCH 189/228] Merge pull request #30776 from timhoffm/mnt-table MNT: Declare table() to be not further developed --- lib/matplotlib/table.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/matplotlib/table.py b/lib/matplotlib/table.py index 370ce9fe922f..91dddba6c31f 100644 --- a/lib/matplotlib/table.py +++ b/lib/matplotlib/table.py @@ -240,6 +240,13 @@ class Table(Artist): """ A table of cells. + .. note:: + + ``table()`` has some fundamental design limitations and will not be + developed further. If you need more functionality, consider + `blume `__. + + The table consists of a grid of cells, which are indexed by (row, column). For a simple table, you'll have a full grid of cells with indices from @@ -658,6 +665,12 @@ def table(ax, """ Add a table to an `~.axes.Axes`. + .. note:: + + ``table()`` has some fundamental design limitations and will not be + developed further. If you need more functionality, consider + `blume `__. + At least one of *cellText* or *cellColours* must be specified. These parameters must be 2D lists, in which the outer lists define the rows and the inner list define the column values per row. Each row must have the From 9280b47cd39123989ad0f304cea6af314d1df71e Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:22:15 +0100 Subject: [PATCH 190/228] Merge pull request #30783 from timhoffm/doc-make-keyword-only DOC: Add example usage to make_keyword_only() --- lib/matplotlib/_api/deprecation.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/matplotlib/_api/deprecation.py b/lib/matplotlib/_api/deprecation.py index 65a754bbb43d..ce346e02e83d 100644 --- a/lib/matplotlib/_api/deprecation.py +++ b/lib/matplotlib/_api/deprecation.py @@ -419,6 +419,23 @@ def make_keyword_only(since, name, func=None): When used on a method that has a pyplot wrapper, this should be the outermost decorator, so that :file:`boilerplate.py` can access the original signature. + + Examples + -------- + Assume we want to only allow *dataset* and *positions* as positional + parameters on the method :: + + def violinplot(self, dataset, positions=None, vert=None, ...) + + Introduce the deprecation by adding the decorator :: + + @_api.make_keyword_only("3.10", "vert") + def violinplot(self, dataset, positions=None, vert=None, ...) + + When the deprecation expires, switch to :: + + def violinplot(self, dataset, positions=None, *, vert=None, ...) + """ decorator = functools.partial(make_keyword_only, since, name) From 9c0f5cdb6b296c14b69b341cda4a3656fc57cead Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Mon, 24 Nov 2025 12:32:57 -0500 Subject: [PATCH 191/228] correct statemnet in quiver docstring --- lib/matplotlib/quiver.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index df693c57d272..9ffcec5117d9 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -490,11 +490,8 @@ class Quiver(mcollections.PolyCollection): """ Specialized PolyCollection for arrows. - The only API method is set_UVC(), which can be used - to change the size, orientation, and color of the - arrows; their locations are fixed when the class is - instantiated. Possibly this method will be useful - in animations. + Use set_UVC to change the size, orientation, and color of the + arrows; their locations can be set using set_offsets(). Much of the work in this class is done in the draw() method so that as much information as possible is available From 76327b5011e6663601869296f0032665084470bd Mon Sep 17 00:00:00 2001 From: hannah Date: Mon, 24 Nov 2025 16:42:25 -0500 Subject: [PATCH 192/228] github: added explicit do not merge label to label check (#29494) * added explicit do not merge label to block merge check * changed label name --- .github/workflows/do_not_merge.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/do_not_merge.yml b/.github/workflows/do_not_merge.yml index d8664df9ba9a..0c263623942b 100644 --- a/.github/workflows/do_not_merge.yml +++ b/.github/workflows/do_not_merge.yml @@ -15,7 +15,8 @@ jobs: env: has_tag: >- ${{contains(github.event.pull_request.labels.*.name, 'status: needs comment/discussion') || - contains(github.event.pull_request.labels.*.name, 'status: waiting for other PR')}} + contains(github.event.pull_request.labels.*.name, 'status: waiting for other PR') || + contains(github.event.pull_request.labels.*.name, 'DO NOT MERGE') }} steps: - name: Check for label if: ${{'true' == env.has_tag}} @@ -23,6 +24,7 @@ jobs: echo "This PR cannot be merged because it has one of the following labels: " echo "* status: needs comment/discussion" echo "* status: waiting for other PR" + echo "* DO NOT MERGE" exit 1 - name: Allow merging if: ${{'false' == env.has_tag}} From fd32a5dfea0c34de9d80aaf80c03a6695ebd8499 Mon Sep 17 00:00:00 2001 From: Raphael Quast Date: Tue, 25 Nov 2025 12:38:27 +0100 Subject: [PATCH 193/228] Fix typo in key-mapping for "f11" --- lib/matplotlib/backends/backend_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 974ebabf8a16..0b0240c90310 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -55,7 +55,7 @@ ("Key_F8", "f8"), ("Key_F9", "f9"), ("Key_F10", "f10"), - ("Key_F10", "f11"), + ("Key_F11", "f11"), ("Key_F12", "f12"), ("Key_Super_L", "super"), ("Key_Super_R", "super"), From fac0b89826d211f7b9ef29bdfe327711f24d066c Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:34:52 +0100 Subject: [PATCH 194/228] DOC: Reintroduce glossary (#30782) * DOC: Reintroduce glossary Closes #28889. * Apply suggestions from code review Co-authored-by: Elliott Sales de Andrade * Apply suggestions from code review Co-authored-by: hannah --------- Co-authored-by: Elliott Sales de Andrade Co-authored-by: hannah --- doc/users/glossary.rst | 44 ++++++++++++++++++++++++++++++++++++++++++ doc/users/index.rst | 1 + 2 files changed, 45 insertions(+) create mode 100644 doc/users/glossary.rst diff --git a/doc/users/glossary.rst b/doc/users/glossary.rst new file mode 100644 index 000000000000..8a2a3fd96bd1 --- /dev/null +++ b/doc/users/glossary.rst @@ -0,0 +1,44 @@ +======== +Glossary +======== + +.. Note for glossary authors: + The glossary is primarily intended for Matplotlib's own concepts and + terminology, e.g. figure, artist, backend, etc. We don't want to list + general terms like "GUI framework", "event loop" or similar. + The glossary should contain a short definition of the term, aiming at + a high-level understanding. Use links to redirect to more comprehensive + explanations and API reference when possible. + +This glossary defines concepts and terminology specific to Matplotlib. + +.. glossary:: + + Figure + The outermost container for a Matplotlib graphic. Think of this as the + canvas to draw on. + + This is implemented in the class `.Figure`. For more details see + :ref:`figure-intro`. + + Axes + This is a container for what is often colloquially called a plot/chart/graph. + It's a data area with :term:`Axis`\ es, i.e. coordinate directions, + and includes data artists like lines, bars etc. as well as + decorations like title, axis labels, legend. + + Since most "plotting operations" are realized as methods on `~.axes.Axes` + this is the object users will mostly interact with. + + Note: The term *Axes* was taken over from MATLAB. Think of this as + a container spanned by the *x*- and *y*-axis, including decoration + and data. + + Axis + A direction with a scale. The scale defines the mapping from + data coordinates to screen coordinates. The Axis also includes + the ticks and axis label. + + Artist + The base class for all graphical element that can be drawn. + Examples are Lines, Rectangles, Text, Ticks, Legend, Axes, ... diff --git a/doc/users/index.rst b/doc/users/index.rst index 2991e7d2b324..733f176e556c 100644 --- a/doc/users/index.rst +++ b/doc/users/index.rst @@ -103,3 +103,4 @@ Using Matplotlib getting_started/index ../install/index + glossary From 7a7a38882b57e8a358944c494b0ed368f3aa6ad8 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Wed, 26 Nov 2025 23:55:45 +0530 Subject: [PATCH 195/228] Add legend support for PatchCollection (#30756) * Add support for PatchCollection legends and update documentation * use HandlerPolyCollection directly since PatchCollection and PolyCollection have identical APIs, we can register PatchCollection to use the existing handler without creating a new class. --- .../next_whats_new/patchcollection_legend.rst | 22 ++++++++ lib/matplotlib/legend.py | 3 +- lib/matplotlib/tests/test_legend.py | 52 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 doc/release/next_whats_new/patchcollection_legend.rst diff --git a/doc/release/next_whats_new/patchcollection_legend.rst b/doc/release/next_whats_new/patchcollection_legend.rst new file mode 100644 index 000000000000..58574e9e6757 --- /dev/null +++ b/doc/release/next_whats_new/patchcollection_legend.rst @@ -0,0 +1,22 @@ +``PatchCollection`` legends now supported +------------------------------------------ +`.PatchCollection` instances now properly display in legends when given a label. +Previously, labels on `~.PatchCollection` objects were ignored by the legend +system, requiring users to create manual legend entries. + +.. plot:: + :include-source: true + :alt: The legend entry displays a rectangle matching the visual properties (colors, line styles, line widths) of the first patch in the collection. + + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.collections import PatchCollection + + fig, ax = plt.subplots() + patches = [mpatches.Circle((0, 0), 0.1), mpatches.Rectangle((0.5, 0.5), 0.2, 0.3)] + pc = PatchCollection(patches, facecolor='blue', edgecolor='black', label='My patches') + ax.add_collection(pc) + ax.legend() # Now displays the label "My patches" + plt.show() + +This fix resolves :ghissue:`23998`. diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 119a27181c80..8564c18c5118 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -37,7 +37,7 @@ from matplotlib.patches import (Patch, Rectangle, Shadow, FancyBboxPatch, StepPatch) from matplotlib.collections import ( - Collection, CircleCollection, LineCollection, PathCollection, + Collection, CircleCollection, LineCollection, PatchCollection, PathCollection, PolyCollection, RegularPolyCollection) from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox @@ -787,6 +787,7 @@ def draw(self, renderer): BarContainer: legend_handler.HandlerPatch( update_func=legend_handler.update_from_first_child), tuple: legend_handler.HandlerTuple(), + PatchCollection: legend_handler.HandlerPolyCollection(), PathCollection: legend_handler.HandlerPathCollection(), PolyCollection: legend_handler.HandlerPolyCollection() } diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 61fae63a298e..a99192c4d571 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1667,3 +1667,55 @@ def test_boxplot_legend_labels(): bp4 = axs[3].boxplot(data, label='box A') assert bp4['medians'][0].get_label() == 'box A' assert all(x.get_label().startswith("_") for x in bp4['medians'][1:]) + + +def test_patchcollection_legend(): + # Test that PatchCollection labels show up in legend and preserve visual + # properties (issue #23998) + fig, ax = plt.subplots() + + pc = mcollections.PatchCollection( + [mpatches.Circle((0, 0), 1), mpatches.Circle((2, 0), 1)], + label="patch collection", + facecolor='red', + edgecolor='blue', + linewidths=3, + linestyle='--', + ) + ax.add_collection(pc) + ax.autoscale_view() + + leg = ax.legend() + + # Check that the legend contains our label + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "patch collection" + + # Check that the legend handle exists and has correct visual properties + assert len(leg.legend_handles) == 1 + legend_patch = leg.legend_handles[0] + assert mpl.colors.same_color(legend_patch.get_facecolor(), + pc.get_facecolor()[0]) + assert mpl.colors.same_color(legend_patch.get_edgecolor(), + pc.get_edgecolor()[0]) + assert legend_patch.get_linewidth() == pc.get_linewidths()[0] + assert legend_patch.get_linestyle() == pc.get_linestyles()[0] + + +def test_patchcollection_legend_empty(): + # Test that empty PatchCollection doesn't crash + fig, ax = plt.subplots() + + # Create an empty PatchCollection + pc = mcollections.PatchCollection([], label="empty collection") + ax.add_collection(pc) + + # This should not crash + leg = ax.legend() + + # Check that the label still appears + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "empty collection" + + # The legend handle should exist + assert len(leg.legend_handles) == 1 From dcff41fa3b28bcf3b486e6f7a5b84f72d995a9f4 Mon Sep 17 00:00:00 2001 From: Miriam Date: Thu, 27 Nov 2025 11:15:50 -0600 Subject: [PATCH 196/228] Fix colorbar alignment with suptitle in compressed layout mode (#30766) * #30472 fix issue with colorbar+suptext in compressed mode * fix horizontal colorbar --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/_constrained_layout.py | 38 ++++++++-- .../test_compressed_suptitle_colorbar.png | Bin 0 -> 3532 bytes .../test_compressed_supylabel_colorbar.png | Bin 0 -> 5018 bytes .../tests/test_constrainedlayout.py | 71 ++++++++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png create mode 100644 lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index f5f23581bd9d..33ec8ef985e7 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -137,7 +137,8 @@ def do_constrained_layout(fig, h_pad, w_pad, layoutgrids[fig].update_variables() if check_no_collapsed_axes(layoutgrids, fig): reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad, - w_pad=w_pad, hspace=hspace, wspace=wspace) + w_pad=w_pad, hspace=hspace, wspace=wspace, + compress=True) else: _api.warn_external(warn_collapsed) @@ -651,7 +652,7 @@ def get_pos_and_bbox(ax, renderer): def reposition_axes(layoutgrids, fig, renderer, *, - w_pad=0, h_pad=0, hspace=0, wspace=0): + w_pad=0, h_pad=0, hspace=0, wspace=0, compress=False): """ Reposition all the Axes based on the new inner bounding box. """ @@ -662,7 +663,7 @@ def reposition_axes(layoutgrids, fig, renderer, *, bbox=bbox.transformed(trans_fig_to_subfig)) reposition_axes(layoutgrids, sfig, renderer, w_pad=w_pad, h_pad=h_pad, - wspace=wspace, hspace=hspace) + wspace=wspace, hspace=hspace, compress=compress) for ax in fig._localaxes: if ax.get_subplotspec() is None or not ax.get_in_layout(): @@ -689,10 +690,10 @@ def reposition_axes(layoutgrids, fig, renderer, *, for nn, cbax in enumerate(ax._colorbars[::-1]): if ax == cbax._colorbar_info['parents'][0]: reposition_colorbar(layoutgrids, cbax, renderer, - offset=offset) + offset=offset, compress=compress) -def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): +def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=False): """ Place the colorbar in its new place. @@ -706,6 +707,8 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): offset : array-like Offset the colorbar needs to be pushed to in order to account for multiple colorbars. + compress : bool + Whether we're in compressed layout mode. """ parents = cbax._colorbar_info['parents'] @@ -724,6 +727,31 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): aspect = cbax._colorbar_info['aspect'] shrink = cbax._colorbar_info['shrink'] + # For colorbars with a single parent in compressed layout, + # use the actual visual size of the parent axis after apply_aspect() + # has been called. This ensures colorbars align with their parent axes. + # This fix is specific to single-parent colorbars where alignment is critical. + if compress and len(parents) == 1: + from matplotlib.transforms import Bbox + # Get the actual parent position after apply_aspect() + parent_ax = parents[0] + actual_pos = parent_ax.get_position(original=False) + # Transform to figure coordinates + actual_pos_fig = actual_pos.transformed(fig.transSubfigure - fig.transFigure) + + if location in ('left', 'right'): + # For vertical colorbars, use the actual parent bbox height + # for colorbar sizing + # Keep the pb x-coordinates but use actual y-coordinates + pb = Bbox.from_extents(pb.x0, actual_pos_fig.y0, + pb.x1, actual_pos_fig.y1) + elif location in ('top', 'bottom'): + # For horizontal colorbars, use the actual parent bbox width + # for colorbar sizing + # Keep the pb y-coordinates but use actual x-coordinates + pb = Bbox.from_extents(actual_pos_fig.x0, pb.y0, + actual_pos_fig.x1, pb.y1) + cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) # Colorbar gets put at extreme edge of outer bbox of the subplotspec diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png new file mode 100644 index 0000000000000000000000000000000000000000..582872077ee33149bf4f07167993460826991146 GIT binary patch literal 3532 zcma)9c{rQd8jq+_Rnn>IhQw0S#a5~sOOo1Y(Jj`78@nP=5=$(hHKo;xrHfldOUn!` ziq_W9?FyqQYEVn08Wp68PAq9?;(nn|@AN)%pXdH@lJEP@d*1E*-rsx9rQD4CdS5x;rofWFdRM>ryuMWfb|bFJZu=KA7Er?WOCrZfy2765CS1G z+RVTp;=3Asd{nSOii4&zU}{68gJ(1dq=c0Wsb7)hp@3go9Bq!Aj=Rle&~dwtlGeZ9 zmuqCQ!`rGhKTaX+e5a(^H`%3lu1_%oIO%uX937eZ87#H1cu5g|DV6tZZExHO&)wez)^ zxG*JbbVnt37Ck!!{nR&av2!I~45z>Com(02te=bSv}c?REQ*0Ly6Vo8EVyE?xAn_J z{sR_K-iJIBcNI-65J&u39gCEWpckUIWFbYg5dwdP6Wz4rai9Fi{6f6xKAGCDqXxmp{iuh&CL)!KK$oBeC$t!JvSBt`dZZmO9a@pz;5Aj zlw1VbwE=82siC{w3lW52S}HCRyj}ZF?1wd2Zi5J6xg$k$cV(?Yx(+&a%`d3&xTem} z?|LuZra)u@7MGd#hbDx19O7V7;h}BQF)pI341;2eV7JIO^~-A3m>Q)MgVC3Z+-ZYt zUYPQEHVs-gO6c{LfIc;*An-@(u%c5XG$n>1`;QgAupI%`pnP~*AnYLB$ES#?qF{WVe7h*)x5S!gLCw@ zLgLyjLJ`(N`;6O!zyIK8gqaRueuYXF{s3QdN!Tu;1{5nU80@Y*TG<{ z#Qt#Ytun{{=9d-oD3}w%N;#Xu6gl6bXBCUSklbo|nfFHE4@<=1{pv;T)6pDKVa3!= zhI(6T(o#CLspXb(Om8&r3t64;%S}mu%U$+BkughCp1spccmqTg!Z~}(?IEvc7^0E} z*tQCxPe#UbjEf`6!BemO2KF1*$%!E+5S!EMS=H)h!6RbY;S zz44KJ@4Pf$9fg9(dg)pN{t@lUwXLNy&xLOP zR9>!hQ-;RrcFF9b7Yc{N9pYv(?g!O`QS07N;p+Q^{|FJj3zG`HkxM17h{pzz)cBk- z1D~|v^54Rdh3BKomWMa)A2S&+buF%GDmBa2&=oeiBK+9?)$Zmj17Myt|jm!{vnX{al!h!Lo%*gP4KPE9u{?rBO& z|JWh}WFJ6ze^BK`Hl@Z=^rOe>7Y{(RBpOUoLtGX1!5}|FHRL`I$%V941Y83HfZkuRHs+t zm?YJlgj|SbrE|$k{|!T9oM29OEq~OAxDWwcfk94Y`q(~pmI$C8sICM!-DeOsT z2)@5ZJa;OJ7h@V!8FNuba9ha|;%A&DqXZ0!%tUk;R9Zt4PB;nn+0YUa=P?%UPE8tZ zY;GPVjV2kq3ur0FJ>bYu;nsu+IhYrOH<@z(*2Hp*B;j zdhz&}fuqUuANX z{{Db>*eJ5$I~T?`7%r=_)OJcV7+!J-r>mjOm9==O&w3;!@5PMufOuvNNI#m-aadC9 z&gv$FWqgAtjoqJr<4idV zzPtKkNHcH#OMiL6JiA;yVE-23lmlYXUMa0-srBXgj#_}%Hp$uC0MMWfGskD`oZGbl z3wV=e8prZPcq3<_l1=Jc z1(Qen(eG>5CLNr}D+Mdgb-vwg96PdsPNfTlNi*4LFVnV}-Pa#W#@RQGv}at6r-hBa zzB$djAgc`)PT3(AX%Ll1Cf+8>$sfGlG>zOpJGWoq7z6(upj|q><2fl0PW_(8^@rI> z2#2n^%F-`jEmQ99?%tGwZg#=?bZvle+}N@o#;}jQS)V@X78VtaJ6(0XS}d;g{|I)cNqRRI2s0Rjv%@nB8{-I%Hah zgeaTFrb`m)plO}o+vTH{lc40zz{}PJ7xKO}#Gu{JmIbr3Ng^e(s5n&2pBh?u^Jl=2 zaCk0@eeIJB@pcQcx^y(jw=#EdrG&-V8?T4y7G4{e_AQD#<}jspgq@Jo zul`F5m27+$@l1`>_z9Y80aAbX*rNo&pF(ekC)IsP8|zB5*sr4n!7=wuvWMzO9MzvthGi)D{8W zfKcS02DKxx0ZGLRl%5O7?#Yq-*|qdGyrV@$aXSPb56 z6Z^h6K56Epy3|brUiJv5h(D>Nj2p@R=akRNw}CDdv?W(rlSLN19x_5vv|wNQ7l9wH z0Y4n-TmA$ew^90=%fb&*e|MYsUjipDt;pK+iJW?P#+JaH3FK(&VpC=HkMsWqR_K(O literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png new file mode 100644 index 0000000000000000000000000000000000000000..9754ac57ad65e824047351ef2b962acfc03a17b9 GIT binary patch literal 5018 zcmbtY3p7;w+a8yL979J`MAC7|rEyCnm!jMfMHtCt+~v*;xtx)5iA1?3A|aQV%n%7P zDCSIvsUsw&aT{bBgfS+AZ*+3fSDo+sum4(muf6tOd%gSjKELOE-sjzkXUvU&yrR4S z003xWe8Lg{U{hqBH+i^NPlR9&k*o_8dJ+t^3h;o2L4w@@ry7xVkHAD!YT+RFzfLRa8_oL7s6Ll|0D><} zP8>gXDUV9D@E?0FjQ?nR((;_@Ft^=SF1Vh3_B3p3)P|q zr|(ANTqJkx+J%1ywBkYX$KA1>JIW|3POG&akgZU4=vymzDc#@%%a(5jCC)rDxvxMd zNt(~VpAd3?*cr)K8AUbD{;EqF=0G5<9uXLX*M4@ANXvdzbRO&2kB(48IGz;&JBons z%-OCvm-SQT8X0#2*?}#5?7vFsVB zfhHV*V9vh19*=Sqvpb2~D-mpgnt6gVl7t0Kz!bN-BEVxiE9KoxIZ5IlY^}$Ete96( z5OIS~hyZCCRdh_D3)onNUZPlcfKFGOt9QKL=vis`?k3XC0-KuTTJ1db}!tTr!^Me=e)x~o>Sy}Q-doX50Iagj?2Oz<*rUNP`2F7$} z9u?e~H-*_z`v-=y8=f6wtxP;mkp0KO_|~)(C!zfS9sB|?X6&2Ae232O=q2R>0(p9R z8q1jNHcCr2bo$U$_bE52WYVZtVBxl}P2L-?pz$T;+%C8J3p4RJmny;pAc|cbsWTqF zmm9EpZ!_1cdeY;(FlWs&$iL*c0fNf%uSTgOM5R2bdP=EmtP;laN&}{XG#{ZYGddXk zcs|9b*XL~OPeA%t6kpo6vJI_bg!KHwjNm0tD0-%!C_2aULM0H6(dG_+HED2`YOk3u z48So~nZH~F1NcOufSvWBtCoN82)O-{l6rW{R;er>MjD)dXcP2CK54XUg@aF=mGX

M!t?QL$MiOF_;c7|TfM&cZhqDWU+j(o zQg){Wz=hU(LUp(1k$2?!I`8@`sb$wVmVzlMzi(_;td;)X3h>jwTk(0?Mc}@qPiP!m(vU(4YpD1H z!27ajFBiH90_Js&6<{S>iS?y8;V$}BS*=ig)hGG(UBRk0LAt)81>$3BZBQ>Lw=>t)ZF>p>b1yO3|9aRT zg$3a`&u`-UQ)T)LM*tA;;-6yQP}&VFUSiGgGcz;eYimuEE*EFRqFc%MF;-efIBoC{ zbw+!Cuu|AYx{DKCd%{S5T~!W|^ePtV?~%;Igs3JhD6~H- zCx&WalxS z<3k6Y8{}0Ew(GmDbl#YuNt`FflU<6ALN~mEQP|zQyytoW>oV2vgFAPRx&uefE7lk| z!ksa@q`~?;BMDJqt)i+=T}6K+9>t@vlA1B-2?(Ia+=R?J6N0- zqJG!BaY%~Gw+j3mhr?BcwFuRnzNHxFb9Ka+@AU*=i-<*ruCj0X1+Fj@TzzH?yAC97}g>Yx-+`F|df+KGeCRKCHLOHSz z0TNC+Qz3_Q+k@+efFli}fP_}Lk-hWUsT`N#XTEgmPT1PXxhW# zCI{E3l6r{7&b|#VQ(}@S-+u~9e_`FRCrzDFPCpxD)<-#dYt|ZvjhOvqXXzP0)48Ae znjdb)hVNVK3j~Cb*3ABb4qdz}Z@sj0co1bPcQpP$tcfZPmp;hX>L8-nHj(5X6C`q8R~YD698REYx3caKLJ; z6B)*7ucoeJ88*>YRg28BPmsOoT}eCk984M7G@F|S;og7S)-Wq^=~7{RCYGe|PmA%$8Pl)e0;UFZoc!FTO7DqY0u1>I{=f= z^0w9Ya5_1-e$US9INe;1xoto8;kIQQTYUI@SL$f=+e$nD_2J;Yvx{c;4l2+}{B>}; zT#1jhaS?9`S<<lyMOV0c zb;pxvH5#8%?)popl{O~YMVc8V5lUBm@6g`08?N1#2eNS6EY0hb>Y+#50aKj;R!Slo((*zKl}6>DmPH{Jd()Qe?e^yOmCRQ7s$ad! zGy#*(_0UqZ?=XHAsvu(W#zQ)1M|+#e1g3aYI>_A8WW2e~9L~s%#?Ss*iwxyKau`de zzjogb`fV~z^W8650s0+!xuFugit7iGbp^p|Jux)RJY7-nj2cI0GkWM$4B4JDE1Gq0 z;%sfffyi06bdCdr?*&7c>n*%7qq!~fG-wpnWxEH6^TwgE2(8%kFmpqeC$!V79W+m% zm%J-D%ZDkA$)vI?WGn{5n`xXWQrJ(!>`j=bk4q zk!e|9oi9;JSt_P7I|7O`JcbT6Bm})bH4s}h*!J;(H&mdTuf-f|IpXI0LrYh6ZcQ7WYq!Wi7{6!2fNXZM=<5&o$6x4-9 zMk-v#>34T`Hw@Kyf1WMKPqd%K6=`wyvBjeIqcuTOdpC&ww~v7xAcWLoFv+fg7hdW# z7;}9$Ify$be=1ZUIi(ATC!~h^{l869gb(1`SgiiDU15#8nTRF| Date: Sat, 29 Nov 2025 12:11:02 +0000 Subject: [PATCH 197/228] DOC: don't index or unpack the return value of pie (#30799) --- galleries/examples/misc/demo_agg_filter.py | 8 ++++---- galleries/examples/pie_and_polar_charts/bar_of_pie.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/galleries/examples/misc/demo_agg_filter.py b/galleries/examples/misc/demo_agg_filter.py index 278fd998dd78..c736013e9718 100644 --- a/galleries/examples/misc/demo_agg_filter.py +++ b/galleries/examples/misc/demo_agg_filter.py @@ -269,19 +269,19 @@ def drop_shadow_patches(ax): def light_filter_pie(ax): fracs = [15, 30, 45, 10] explode = (0.1, 0.2, 0.1, 0.1) - pies = ax.pie(fracs, explode=explode) + pie = ax.pie(fracs, explode=explode) light_filter = LightFilter(9) - for p in pies[0]: + for p in pie.wedges: p.set_agg_filter(light_filter) p.set_rasterized(True) # to support mixed-mode renderers p.set(ec="none", lw=2) gauss = DropShadowFilter(9, offsets=(3, -4), alpha=0.7) - shadow = FilteredArtistList(pies[0], gauss) + shadow = FilteredArtistList(pie.wedges, gauss) ax.add_artist(shadow) - shadow.set_zorder(pies[0][0].get_zorder() - 0.1) + shadow.set_zorder(pie.wedges[0].get_zorder() - 0.1) if __name__ == "__main__": diff --git a/galleries/examples/pie_and_polar_charts/bar_of_pie.py b/galleries/examples/pie_and_polar_charts/bar_of_pie.py index 6f18b964cef7..7c703976db2e 100644 --- a/galleries/examples/pie_and_polar_charts/bar_of_pie.py +++ b/galleries/examples/pie_and_polar_charts/bar_of_pie.py @@ -25,8 +25,8 @@ explode = [0.1, 0, 0] # rotate so that first wedge is split by the x-axis angle = -180 * overall_ratios[0] -wedges, *_ = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, - labels=labels, explode=explode) +pie = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, + labels=labels, explode=explode) # bar chart parameters age_ratios = [.33, .54, .07, .06] @@ -47,8 +47,8 @@ ax2.set_xlim(- 2.5 * width, 2.5 * width) # use ConnectionPatch to draw lines between the two plots -theta1, theta2 = wedges[0].theta1, wedges[0].theta2 -center, r = wedges[0].center, wedges[0].r +theta1, theta2 = pie.wedges[0].theta1, pie.wedges[0].theta2 +center, r = pie.wedges[0].center, pie.wedges[0].r bar_height = sum(age_ratios) # draw top connecting line From 7bf7e47891ac0f83049d77fb13d0a2b619b93e9b Mon Sep 17 00:00:00 2001 From: Manit Roy Date: Sat, 29 Nov 2025 18:56:11 +0530 Subject: [PATCH 198/228] Add legend.linewidth parameter to control legend box edge linewidth (#30780) * Added legend.linewidth parameter to control legend box edge linewidth * Added legend.linewidth parameter to control legend box edge linewidth * linting issue and mypy subtest issue fixed * suggestions implemented * tests added * suggestion taken and .rst file renamed * Fix linting error --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- .../next_whats_new/legend_line_width.rst | 21 +++++++++++++ lib/matplotlib/legend.py | 10 ++++++ lib/matplotlib/legend.pyi | 1 + lib/matplotlib/mpl-data/matplotlibrc | 1 + lib/matplotlib/rcsetup.py | 2 ++ lib/matplotlib/tests/test_legend.py | 31 +++++++++++++++++++ 6 files changed, 66 insertions(+) create mode 100644 doc/release/next_whats_new/legend_line_width.rst diff --git a/doc/release/next_whats_new/legend_line_width.rst b/doc/release/next_whats_new/legend_line_width.rst new file mode 100644 index 000000000000..d8cfd57640a8 --- /dev/null +++ b/doc/release/next_whats_new/legend_line_width.rst @@ -0,0 +1,21 @@ +``legend.linewidth`` rcParam and parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A new rcParam ``legend.linewidth`` has been added to control the line width of +the legend's box edges. When set to ``None`` (the default), it inherits the +value from ``patch.linewidth``. This allows for independent control of the +legend frame line width without affecting other elements. + +The `.Legend` constructor also accepts a new *linewidth* parameter to set the +legend frame line width directly, overriding the rcParam value. + +.. plot:: + :include-source: true + :alt: A line plot with a legend showing a thick border around the legend box. + + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + ax.legend(linewidth=2.0) # Thick legend box edge + plt.show() diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 8564c18c5118..8f43f89de5f9 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -196,6 +196,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas): The legend's background patch edge color. If ``"inherit"``, use :rc:`axes.edgecolor`. +linewidth : float or None, default: :rc:`legend.linewidth` + The legend's background patch edge linewidth. + If ``None``, use :rc:`patch.linewidth`. + + .. versionadded:: 3.11 + mode : {"expand", None} If *mode* is set to ``"expand"`` the legend will be horizontally expanded to fill the Axes area (or *bbox_to_anchor* if defines @@ -385,6 +391,7 @@ def __init__( framealpha=None, # set frame alpha edgecolor=None, # frame patch edgecolor facecolor=None, # frame patch facecolor + linewidth=None, # frame patch linewidth bbox_to_anchor=None, # bbox to which the legend will be anchored bbox_transform=None, # transform for the bbox @@ -526,9 +533,12 @@ def __init__( fancybox = mpl._val_or_rc(fancybox, "legend.fancybox") + linewidth = mpl._val_or_rc(linewidth, "legend.linewidth") + self.legendPatch = FancyBboxPatch( xy=(0, 0), width=1, height=1, facecolor=facecolor, edgecolor=edgecolor, + linewidth=linewidth, # If shadow is used, default to alpha=1 (#8943). alpha=(framealpha if framealpha is not None else 1 if shadow diff --git a/lib/matplotlib/legend.pyi b/lib/matplotlib/legend.pyi index c03471fc54d1..e17738c76161 100644 --- a/lib/matplotlib/legend.pyi +++ b/lib/matplotlib/legend.pyi @@ -85,6 +85,7 @@ class Legend(Artist): framealpha: float | None = ..., edgecolor: Literal["inherit"] | ColorType | None = ..., facecolor: Literal["inherit"] | ColorType | None = ..., + linewidth: float | None = ..., bbox_to_anchor: BboxBase | tuple[float, float] | tuple[float, float, float, float] diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 83e567a414c9..b1eed6abfd3c 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -562,6 +562,7 @@ #legend.framealpha: 0.8 # legend patch transparency #legend.facecolor: inherit # inherit from axes.facecolor; or color spec #legend.edgecolor: 0.8 # background patch boundary color +#legend.linewidth: None # line width of the legend frame, None means inherit from patch.linewidth #legend.fancybox: True # if True, use a rounded box for the # legend background, else a rectangle #legend.shadow: False # if True, give background a shadow effect diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 80d25659888e..a088274b3439 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1184,6 +1184,8 @@ def _convert_validator_spec(key, conv): "legend.frameon": validate_bool, # alpha value of the legend frame "legend.framealpha": validate_float_or_None, + # linewidth of legend frame + "legend.linewidth": validate_float_or_None, ## the following dimensions are in fraction of the font size "legend.borderpad": validate_float, # units are fontsize diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index a99192c4d571..5f83b25b90a5 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1669,6 +1669,37 @@ def test_boxplot_legend_labels(): assert all(x.get_label().startswith("_") for x in bp4['medians'][1:]) +def test_legend_linewidth(): + """Test legend.linewidth parameter and rcParam.""" + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + + # Test direct parameter + leg = ax.legend(linewidth=2.5) + assert leg.legendPatch.get_linewidth() == 2.5 + + # Test rcParam + with mpl.rc_context({'legend.linewidth': 3.0}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend() + assert leg.legendPatch.get_linewidth() == 3.0 + + # Test None default (should inherit from patch.linewidth) + with mpl.rc_context({'legend.linewidth': None, 'patch.linewidth': 1.5}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend() + assert leg.legendPatch.get_linewidth() == 1.5 + + # Test that direct parameter overrides rcParam + with mpl.rc_context({'legend.linewidth': 1.0}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend(linewidth=4.0) + assert leg.legendPatch.get_linewidth() == 4.0 + + def test_patchcollection_legend(): # Test that PatchCollection labels show up in legend and preserve visual # properties (issue #23998) From 7ebaad84841cf3529dd8d4b5bad106c073b6ae76 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 4 Dec 2025 21:46:57 +0530 Subject: [PATCH 199/228] Fix axis3d to include offset text in tight bounding box calculation (#30760) * Fix axis3d to include offset text in tight bounding box calculation * Fix test tolerance for platform compatibility The previous test used an overly strict tolerance (rtol=1e-10) which could fail due to floating-point precision differences across platforms and backends. Simplified the test to directly check that the tight bbox contains the offset text bbox with a reasonable tolerance (1e-6). * Make offset_text test conditional on visibility The test now only checks that offset_text is included in the tight bbox when it's actually visible and has content. This handles cases where different backends or configurations may not show offset text. Added helpful error messages to aid debugging if assertions fail. * Add check for offset text content before including in bbox Match the pattern used for label (line 716) by checking that offsetText has actual content before including it in the bounding box calculation. This prevents including empty offset text in the bbox. * Add figure cleanup to offset text test Explicitly close the figure after the test completes to ensure proper cleanup and avoid potential interference with other tests. * Revert figure cleanup in test Remove explicit plt.close(fig) call as matplotlib's test framework handles cleanup automatically. The explicit close was causing test failures across multiple platforms. --- lib/mpl_toolkits/mplot3d/axis3d.py | 2 + lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 4da5031b990c..fdd22b717f67 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -708,6 +708,8 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) other = [] + if self.offsetText.get_visible() and self.offsetText.get_text(): + other.append(self.offsetText.get_window_extent(renderer)) if self.line.get_visible(): other.append(self.line.get_window_extent(renderer)) if (self.label.get_visible() and not for_layout_only and diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 9fcd19b23f57..546659d05177 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2733,3 +2733,50 @@ def test_axes3d_set_aspect_deperecated_params(): with pytest.raises(ValueError, match="adjustable"): ax.set_aspect('equal', adjustable='invalid_value') + + +def test_axis_get_tightbbox_includes_offset_text(): + # Test that axis.get_tightbbox includes the offset_text + # Regression test for issue #30744 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + + # Create data with high precision values that trigger offset text + Z = np.array([[0.1, 0.100000001], [0.100000000001, 0.100000000]]) + ny, nx = Z.shape + x = np.arange(nx) + y = np.arange(ny) + X, Y = np.meshgrid(x, y) + + ax.plot_surface(X, Y, Z) + + # Force a draw to ensure offset text is created and positioned + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + + # Get the z-axis (which should have the offset text) + zaxis = ax.zaxis + + # Check that offset text is visible and has content + # The offset text may not be visible on all backends/configurations, + # so we only test the inclusion when it's actually present + if (zaxis.offsetText.get_visible() and + zaxis.offsetText.get_text()): + offset_bbox = zaxis.offsetText.get_window_extent(renderer) + + # Get the tight bbox - this should include the offset text + bbox = zaxis.get_tightbbox(renderer) + assert bbox is not None + assert offset_bbox is not None + + # The tight bbox should fully contain the offset text bbox + # Check that offset_bbox is within bbox bounds (with small tolerance for + # floating point errors) + assert bbox.x0 <= offset_bbox.x0 + 1e-6, \ + f"bbox.x0 ({bbox.x0}) should be <= offset_bbox.x0 ({offset_bbox.x0})" + assert bbox.y0 <= offset_bbox.y0 + 1e-6, \ + f"bbox.y0 ({bbox.y0}) should be <= offset_bbox.y0 ({offset_bbox.y0})" + assert bbox.x1 >= offset_bbox.x1 - 1e-6, \ + f"bbox.x1 ({bbox.x1}) should be >= offset_bbox.x1 ({offset_bbox.x1})" + assert bbox.y1 >= offset_bbox.y1 - 1e-6, \ + f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})" From a164f1e81eb2e608c55e866d1f20fb1c6240330e Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:15:54 +0000 Subject: [PATCH 200/228] Fix rstcheck failures --- doc/api/toolkits/mplot3d/faq.rst | 8 ++++---- doc/api/toolkits/mplot3d/view_angles.rst | 9 ++++----- doc/devel/contribute.rst | 9 +++------ doc/devel/development_setup.rst | 6 +++--- doc/install/dependencies.rst | 4 ++-- doc/install/index.rst | 2 +- 6 files changed, 17 insertions(+), 21 deletions(-) diff --git a/doc/api/toolkits/mplot3d/faq.rst b/doc/api/toolkits/mplot3d/faq.rst index e9ba804648e0..20fe81e574fe 100644 --- a/doc/api/toolkits/mplot3d/faq.rst +++ b/doc/api/toolkits/mplot3d/faq.rst @@ -6,8 +6,7 @@ mplot3d FAQ How is mplot3d different from Mayavi? ===================================== -`Mayavi `_ -is a very powerful and featureful 3D graphing library. For advanced +Mayavi_ is a very powerful and featureful 3D graphing library. For advanced 3D scenes and excellent rendering capabilities, it is highly recommended to use Mayavi. @@ -37,8 +36,7 @@ rendered properly in matplotlib's 2D rendering engine. This problem will likely not be solved until OpenGL support is added to all of the backends (patches are greatly welcomed). Until then, if you need complex -3D scenes, we recommend using -`MayaVi `_. +3D scenes, we recommend using Mayavi_. I don't like how the 3D plot is laid out, how do I change that? @@ -49,3 +47,5 @@ Work is being done to eliminate this issue. For matplotlib v1.1.0, there is a semi-official manner to modify these parameters. See the note in the :mod:`.mplot3d.axis3d` section of the mplot3d API documentation for more information. + +.. _Mayavi: https://docs.enthought.com/mayavi/mayavi/ diff --git a/doc/api/toolkits/mplot3d/view_angles.rst b/doc/api/toolkits/mplot3d/view_angles.rst index 75b24ba9c7b0..e4200cd2d0e4 100644 --- a/doc/api/toolkits/mplot3d/view_angles.rst +++ b/doc/api/toolkits/mplot3d/view_angles.rst @@ -11,8 +11,7 @@ The position of the viewport "camera" in a 3D plot is defined by three angles: *elevation*, *azimuth*, and *roll*. From the resulting position, it always points towards the center of the plot box volume. The angle direction is a common convention, and is shared with -`PyVista `_ and -`MATLAB `_. +`PyVista `_ and MATLAB_. Note that a positive roll angle rotates the viewing plane clockwise, so the 3d axes will appear to rotate counter-clockwise. @@ -51,8 +50,7 @@ can be specified by setting :rc:`axes3d.mouserotationstyle`, see :doc:`/users/explain/customizing`. Prior to v3.10, the 2D mouse position corresponded directly -to azimuth and elevation; this is also how it is done -in `MATLAB `_. +to azimuth and elevation; this is also how it is done in MATLAB_. To keep it this way, set ``mouserotationstyle: azel``. This approach works fine for spherical coordinate plots, where the *z* axis is special; however, it leads to a kind of 'gimbal lock' when looking down the *z* axis: @@ -131,7 +129,7 @@ Henriksen et al. [Henriksen2002]_ provide an overview. In summary: You can try out one of the various mouse rotation styles using: -.. code:: +.. code-block:: python import matplotlib as mpl mpl.rcParams['axes3d.mouserotationstyle'] = 'trackball' # 'azel', 'trackball', 'sphere', or 'arcball' @@ -188,6 +186,7 @@ the arcball to the border occurs at 45°, set the border width to The border is a circular arc, wrapped around the arcball sphere cylindrically (like a doughnut), joined smoothly to the sphere, much like Bell's hyperbola. +.. _MATLAB: https://www.mathworks.com/help/matlab/ref/view.html .. [Shoemake1992] Ken Shoemake, "ARCBALL: A user interface for specifying three-dimensional rotation using a mouse", in Proceedings of Graphics diff --git a/doc/devel/contribute.rst b/doc/devel/contribute.rst index fe81e625421e..3667d6e20a5d 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -245,11 +245,11 @@ process works, technical questions about the code, what makes for good documentation or a blog post, how to get involved in community work, or get a "pre-review" on your PR. -To join, please go to our public community_ channel, and ask to be added to +To join, please go to our public `community gitter`_ channel, and ask to be added to ``#incubator``. One of our core developers will see your message and will add you. .. _gitter: https://gitter.im/matplotlib/matplotlib -.. _community: https://gitter.im/matplotlib/community +.. _community gitter: https://gitter.im/matplotlib/community .. _good_first_issues: @@ -313,10 +313,7 @@ active contributors, many of whom felt just like you when they started out and are happy to welcome you and support you as you get to know how we work, and where things are. You can reach out on any of our :ref:`communication-channels`. For development questions we recommend reaching out on our development gitter_ -chat room and for community questions reach out at community_. - -.. _gitter: https://gitter.im/matplotlib/matplotlib -.. _community: https://gitter.im/matplotlib/community +chat room and for community questions reach out at `community gitter`_. .. _managing_issues_prs: diff --git a/doc/devel/development_setup.rst b/doc/devel/development_setup.rst index 5be8500428a0..4e452fb3bfe7 100644 --- a/doc/devel/development_setup.rst +++ b/doc/devel/development_setup.rst @@ -38,7 +38,7 @@ Set up development environment ============================== You can either work locally on your machine, or online in -`GitHub Codespaces `_, a cloud-based in-browser development +`GitHub Codespaces`_, a cloud-based in-browser development environment. @@ -219,7 +219,7 @@ need to be installed when working in codespaces. Create GitHub Codespace :octicon:`codespaces` --------------------------------------------- -`GitHub Codespaces `_ is a cloud-based +`GitHub Codespaces`_ is a cloud-based in-browser development environment that comes with the appropriate setup to contribute to Matplotlib. @@ -260,7 +260,7 @@ Use the "Extensions" icon in the activity bar to install the "Live Server" extension. Locate the ``doc/build/html`` folder in the Explorer, right click the file you want to open and select "Open with Live Server." -.. _`github-codespaces`: https://docs.github.com/codespaces +.. _Github Codespaces: https://docs.github.com/codespaces .. _development-install: diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index e19d8e79faf2..11317669817f 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -234,7 +234,7 @@ means that the dependencies must be explicitly installed, either by :ref:`creati - `setuptools_scm `_ (>= 7). Used to update the reported ``mpl.__version__`` based on the current git commit. Also a runtime dependency for editable installs. -- `NumPy `_ (>= 1.22). Also a runtime dependency. +- NumPy_ (>= 1.22). Also a runtime dependency. .. _compile-build-dependencies: @@ -473,7 +473,7 @@ Optional The documentation can be built without Inkscape and optipng, but the build process will raise various warnings. -* `Inkscape `_ +* Inkscape_ * `optipng `_ * the font `xkcd script `_ or `Comic Neue `_ * the font "Times New Roman" diff --git a/doc/install/index.rst b/doc/install/index.rst index 4058b0549738..68ccfb8634ff 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -70,7 +70,7 @@ Python distributions Matplotlib is part of major Python distributions: -- `Anaconda `_ +- Anaconda_ - `ActiveState ActivePython `_ - `WinPython `_ From 787056b8aff1b1df4e2ff4cb498079a0c5920496 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:37:16 +0000 Subject: [PATCH 201/228] DOC: remove duplicate whatsnew heading --- doc/release/prev_whats_new/whats_new_3.10.0.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/doc/release/prev_whats_new/whats_new_3.10.0.rst b/doc/release/prev_whats_new/whats_new_3.10.0.rst index 82e67368805d..232ab6036100 100644 --- a/doc/release/prev_whats_new/whats_new_3.10.0.rst +++ b/doc/release/prev_whats_new/whats_new_3.10.0.rst @@ -62,13 +62,6 @@ colour maps version 8.0.1 (DOI: https://doi.org/10.5281/zenodo.1243862). ax[2].imshow(img, cmap="vanimo") - -Plotting and Annotation improvements -==================================== - - - - Plotting and Annotation improvements ==================================== From bfa27f034e26243c941aef0b063c49d26fac87da Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:13:09 +0000 Subject: [PATCH 202/228] Add legend.linewidth to rcParam type hint --- lib/matplotlib/typing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 3a9f74e2cfd1..d2e12c6e08d9 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -370,6 +370,7 @@ "legend.handletextpad", "legend.labelcolor", "legend.labelspacing", + "legend.linewidth", "legend.loc", "legend.markerscale", "legend.numpoints", From 5ff6b1c2f8f1a602842208264eeaa0a528e14bb0 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:33:56 +0100 Subject: [PATCH 203/228] DOC: Discouraged duplicate colormaps Closes #30796. State that the use of the colormaps gist_gray, grist_yarg, binary is discouraged. To be clear: We don't have a plan to depreacte them. Even though I've decided to remove them from the plotting and only mention them by name in the admonition to de-emphasize them in the docs and push towards the recommended alternatives. --- .../examples/color/colormap_reference.py | 21 ++++++++++++++++--- galleries/users_explain/colors/colormaps.py | 21 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/galleries/examples/color/colormap_reference.py b/galleries/examples/color/colormap_reference.py index 6f550161f2e9..2dc091d71a45 100644 --- a/galleries/examples/color/colormap_reference.py +++ b/galleries/examples/color/colormap_reference.py @@ -24,9 +24,8 @@ 'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu', 'GnBu', 'PuBu', 'YlGnBu', 'PuBuGn', 'BuGn', 'YlGn']), ('Sequential (2)', [ - 'binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', 'pink', - 'spring', 'summer', 'autumn', 'winter', 'cool', 'Wistia', - 'hot', 'afmhot', 'gist_heat', 'copper']), + 'gray', 'bone', 'pink', 'spring', 'summer', 'autumn', 'winter', + 'cool', 'Wistia', 'hot', 'afmhot', 'gist_heat', 'copper']), ('Diverging', [ 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGy', 'RdBu', 'RdYlBu', 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic', @@ -70,6 +69,22 @@ def plot_color_gradients(cmap_category, cmap_list): # %% +# +# .. admonition:: Discouraged +# +# For backward compatibility we additionally support the following colormap +# names, which are identical to other builtin colormaps. Their use is +# discouraged. Use the suggested replacement instead. +# +# ========= ================================= +# Colormap Use identical replacement instead +# ========= ================================= +# gist_gray gray +# gist_yarg gray_r +# binary gray_r +# ========= ================================= +# +# # .. _reverse-cmap: # # Reversed colormaps diff --git a/galleries/users_explain/colors/colormaps.py b/galleries/users_explain/colors/colormaps.py index 026ffc9922e2..55989c258802 100644 --- a/galleries/users_explain/colors/colormaps.py +++ b/galleries/users_explain/colors/colormaps.py @@ -161,11 +161,26 @@ def plot_color_gradients(category, cmap_list): # an excellent example of this). plot_color_gradients('Sequential (2)', - ['binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', - 'pink', 'spring', 'summer', 'autumn', 'winter', 'cool', - 'Wistia', 'hot', 'afmhot', 'gist_heat', 'copper']) + ['gray', 'bone', 'pink', 'spring', 'summer', 'autumn', + 'winter', 'cool', 'Wistia', 'hot', 'afmhot', 'gist_heat', + 'copper']) # %% +# .. admonition:: Discouraged +# +# For backward compatibility we additionally support the following colormap +# names, which are identical to other builtin colormaps. Their use is +# discouraged. Use the suggested replacement instead. +# +# ========= ================================= +# Colormap Use identical replacement instead +# ========= ================================= +# gist_gray gray +# gist_yarg gray_r +# binary gray_r +# ========= ================================= +# +# # Diverging # --------- # From 45446748cb37b654776ac234d93d3f8a5a0783ef Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:10:16 +0100 Subject: [PATCH 204/228] MNT: Fix handling of ints in rgb_to_hsv() This is a numpy 2.0 regression. --- lib/matplotlib/colors.py | 9 ++++----- lib/matplotlib/tests/test_colors.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 07cbe4a79cb0..628d9f0acf77 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3631,11 +3631,10 @@ def rgb_to_hsv(arr): f"shape {arr.shape} was found.") in_shape = arr.shape - arr = np.array( - arr, copy=False, - dtype=np.promote_types(arr.dtype, np.float32), # Don't work on ints. - ndmin=2, # In case input was 1D. - ) + # ensure numerics are done at least on float32; ints are cast as well + arr = np.asarray(arr, dtype=np.promote_types(arr.dtype, np.float32)) + if arr.ndim == 1: + arr = np.expand_dims(arr, axis=0) # ensure arr is 2D out = np.zeros_like(arr) arr_max = arr.max(-1) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 4af0c84261b8..ee6e35f580a4 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -947,6 +947,11 @@ def test_rgb_hsv_round_trip(): tt, mcolors.rgb_to_hsv(mcolors.hsv_to_rgb(tt))) +def test_rgb_to_hsv_int(): + # Test that int rgb values (still range 0-1) are processed correctly. + assert_array_equal(mcolors.rgb_to_hsv((0, 1, 0)), (1/3, 1, 1)) # green + + def test_autoscale_masked(): # Test for #2336. Previously fully masked data would trigger a ValueError. data = np.ma.masked_all((12, 20)) From 53185265d402440afaef3491e6209df10e7b8302 Mon Sep 17 00:00:00 2001 From: ruvilonix <114708614+ruvilonix@users.noreply.github.com> Date: Sun, 7 Dec 2025 07:55:12 -0500 Subject: [PATCH 205/228] A couple words were left out of a sentence --- galleries/users_explain/figure/api_interfaces.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galleries/users_explain/figure/api_interfaces.rst b/galleries/users_explain/figure/api_interfaces.rst index 981359dbee0b..8b4177431c0c 100644 --- a/galleries/users_explain/figure/api_interfaces.rst +++ b/galleries/users_explain/figure/api_interfaces.rst @@ -148,7 +148,7 @@ interfaces and how to translate from one to the other. - Axes: ``label = ax.get_xlabel()`` - pyplot: ``label = plt.xlabel()`` -- Functions that set properties like the property in pyplot and are prefixed with +- Functions that set properties are named like the property in pyplot and are prefixed with ``set_`` on the Axes. Example: - Axes: ``ax.set_xlabel("time")`` From 6ce43e3182ebe9fd3fcf10bf0f98d555c7bf5ea0 Mon Sep 17 00:00:00 2001 From: ruvilonix <114708614+ruvilonix@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:13:20 -0500 Subject: [PATCH 206/228] fix typo --- galleries/users_explain/figure/api_interfaces.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galleries/users_explain/figure/api_interfaces.rst b/galleries/users_explain/figure/api_interfaces.rst index 8b4177431c0c..c3ac06aa27ab 100644 --- a/galleries/users_explain/figure/api_interfaces.rst +++ b/galleries/users_explain/figure/api_interfaces.rst @@ -174,7 +174,7 @@ referenced by ``plt.gca()``? One simple way is to call ``subplot`` again with the same arguments. However, that quickly becomes inelegant. You can also inspect the Figure object and get its list of Axes objects, however, that can be misleading (colorbars are Axes too!). The best solution is probably to save a -handle to every Axes you create, but if you do that, why not simply create the +handle to every Axes you create, but if you do that, why not simply create all the Axes objects at the start? The first approach is to call ``plt.subplot`` again: From 7216311332d45bf5e57c7e3e59de226e28f6df0e Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:54:21 +0000 Subject: [PATCH 207/228] Update sphinx-gallery header patch --- doc/conf.py | 2 +- lib/matplotlib/tests/test_doc.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4d922a5636e1..d625038d149c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -372,7 +372,7 @@ def gallery_image_warning_filter(record): :class: sphx-glr-download-link-note :ref:`Go to the end ` - to download the full example code.{2} + to download the full example code{2} .. rst-class:: sphx-glr-example-title diff --git a/lib/matplotlib/tests/test_doc.py b/lib/matplotlib/tests/test_doc.py index 3e28fd1b8eb7..6e8ce9fd630c 100644 --- a/lib/matplotlib/tests/test_doc.py +++ b/lib/matplotlib/tests/test_doc.py @@ -9,7 +9,7 @@ def test_sphinx_gallery_example_header(): EXAMPLE_HEADER, this test will start to fail. In that case, please update the monkey-patching of EXAMPLE_HEADER in conf.py. """ - pytest.importorskip('sphinx_gallery', minversion='0.16.0') + pytest.importorskip('sphinx_gallery', minversion='0.20.0') from sphinx_gallery import gen_rst EXAMPLE_HEADER = """ @@ -25,7 +25,7 @@ def test_sphinx_gallery_example_header(): :class: sphx-glr-download-link-note :ref:`Go to the end ` - to download the full example code.{2} + to download the full example code{2} .. rst-class:: sphx-glr-example-title From c624ca46ad626ef97eabce41ed3ed386c00f91a9 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:23:07 +0000 Subject: [PATCH 208/228] Consolidate style parameter handling for plotting methods that call other plotting methods Co-authored-by: Ilakkuvaselvi Manoharan --- .../stackplot_style_sequences.rst | 26 +++++++ lib/matplotlib/_style_helpers.py | 51 ++++++++++++ lib/matplotlib/axes/_axes.py | 44 ++++------- lib/matplotlib/meson.build | 1 + lib/matplotlib/stackplot.py | 35 +++++---- lib/matplotlib/tests/test__style_helpers.py | 77 +++++++++++++++++++ lib/matplotlib/tests/test_axes.py | 55 ++++++++----- 7 files changed, 221 insertions(+), 68 deletions(-) create mode 100644 doc/release/next_whats_new/stackplot_style_sequences.rst create mode 100644 lib/matplotlib/_style_helpers.py create mode 100644 lib/matplotlib/tests/test__style_helpers.py diff --git a/doc/release/next_whats_new/stackplot_style_sequences.rst b/doc/release/next_whats_new/stackplot_style_sequences.rst new file mode 100644 index 000000000000..7d03a415c1c0 --- /dev/null +++ b/doc/release/next_whats_new/stackplot_style_sequences.rst @@ -0,0 +1,26 @@ +Stackplot styling +----------------- + +`~.Axes.stackplot` now accepts sequences for the style parameters *facecolor*, +*edgecolor*, *linestyle*, and *linewidth*, similar to how the *hatch* parameter +is already handled. + +.. plot:: + :include-source: true + :alt: A stackplot showing with two regions. The bottom region is red with black dots and a dotted black outline. The top region is blue with gray stars and a thicker dashed outline. + + import matplotlib.pyplot as plt + import numpy as np + + x = np.linspace(0, 10, 10) + y1 = 1.0 * x + y2 = 2.0 * x + 1 + + fig, ax = plt.subplots() + + ax.stackplot(x, y1, y2, + facecolor=['tab:red', 'tab:blue'], + edgecolor=['black', 'gray'], + linestyle=[':', '--'], + linewidth=[2, 3], + hatch=['.', '*']) diff --git a/lib/matplotlib/_style_helpers.py b/lib/matplotlib/_style_helpers.py new file mode 100644 index 000000000000..9b98d90593f9 --- /dev/null +++ b/lib/matplotlib/_style_helpers.py @@ -0,0 +1,51 @@ +import collections.abc +import itertools + +import numpy as np + +import matplotlib.cbook as cbook +import matplotlib.colors as mcolors +import matplotlib.lines as mlines + + +def check_non_empty(key, value): + """Raise a TypeError if an empty sequence is passed""" + if (not cbook.is_scalar_or_string(value) and + isinstance(value, collections.abc.Sized) and len(value) == 0): + raise TypeError(f'{key} must not be an empty sequence') + + +def style_generator(kw): + """ + Helper for handling style sequences (e.g. facecolor=['r', 'b', 'k']) within plotting + methods that repeatedly call other plotting methods (e.g. hist, stackplot). Remove + style keywords from the given dictionary. Return the reduced dictionary together + with a generator which provides a series of dictionaries to be used in each call to + the wrapped function. + """ + kw_iterators = {} + remaining_kw = {} + for key, value in kw.items(): + if key in ['facecolor', 'edgecolor']: + if value is None or cbook._str_lower_equal(value, 'none'): + kw_iterators[key] = itertools.repeat(value) + else: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) + + elif key in ['hatch', 'linewidth']: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(np.atleast_1d(value)) + + elif key == 'linestyle': + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mlines._get_dash_patterns(value)) + + else: + remaining_kw[key] = value + + def style_gen(): + while True: + yield {key: next(val) for key, val in kw_iterators.items()} + + return remaining_kw, style_gen() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 7fa360220664..029260b90c26 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -32,7 +32,7 @@ import matplotlib.transforms as mtransforms import matplotlib.tri as mtri import matplotlib.units as munits -from matplotlib import _api, _docstring, _preprocess_data +from matplotlib import _api, _docstring, _preprocess_data, _style_helpers from matplotlib.axes._base import ( _AxesBase, _TransformedBoundsLocator, _process_plot_format) from matplotlib.axes._secondary_axes import SecondaryAxis @@ -3194,6 +3194,10 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing **kwargs : `.Rectangle` properties + The following properties additionally accept a sequence of values + corresponding to the datasets in *heights*: + *edgecolor*, *facecolor*, *linewidth*, *linestyle*, *hatch*. + %(Rectangle:kwdoc)s Returns @@ -3320,6 +3324,8 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) + kwargs, style_gen = _style_helpers.style_generator(kwargs) + bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) bar_spacing_abs = bar_spacing * bar_width @@ -3333,15 +3339,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + for i, (hs, label, color, styles) in enumerate(zip(heights, labels, colors, + style_gen)): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) if orientation == "vertical": bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, **styles, **kwargs) else: bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, **styles, **kwargs) bar_containers.append(bc) if tick_labels is not None: @@ -7632,38 +7639,15 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, labels = [] if label is None else np.atleast_1d(np.asarray(label, str)) if histtype == "step": - ec = kwargs.get('edgecolor', colors) - else: - ec = kwargs.get('edgecolor', None) - if ec is None or cbook._str_lower_equal(ec, 'none'): - edgecolors = itertools.repeat(ec) - else: - edgecolors = itertools.cycle(mcolors.to_rgba_array(ec)) - - fc = kwargs.get('facecolor', colors) - if cbook._str_lower_equal(fc, 'none'): - facecolors = itertools.repeat(fc) - else: - facecolors = itertools.cycle(mcolors.to_rgba_array(fc)) + kwargs.setdefault('edgecolor', colors) - hatches = itertools.cycle(np.atleast_1d(kwargs.get('hatch', None))) - linewidths = itertools.cycle(np.atleast_1d(kwargs.get('linewidth', None))) - if 'linestyle' in kwargs: - linestyles = itertools.cycle(mlines._get_dash_patterns(kwargs['linestyle'])) - else: - linestyles = itertools.repeat(None) + kwargs, style_gen = _style_helpers.style_generator(kwargs) for patch, lbl in itertools.zip_longest(patches, labels): if not patch: continue p = patch[0] - kwargs.update({ - 'hatch': next(hatches), - 'linewidth': next(linewidths), - 'linestyle': next(linestyles), - 'edgecolor': next(edgecolors), - 'facecolor': next(facecolors), - }) + kwargs.update(next(style_gen)) p._internal_update(kwargs) if lbl is not None: p.set_label(lbl) diff --git a/lib/matplotlib/meson.build b/lib/matplotlib/meson.build index c4746f332bcb..c0bfdb227e2e 100644 --- a/lib/matplotlib/meson.build +++ b/lib/matplotlib/meson.build @@ -17,6 +17,7 @@ python_sources = [ '_mathtext.py', '_mathtext_data.py', '_pylab_helpers.py', + '_style_helpers.py', '_text_helpers.py', '_tight_bbox.py', '_tight_layout.py', diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index bd11558b0da9..6592260ccbf1 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -6,11 +6,10 @@ (https://stackoverflow.com/users/66549/doug) """ -import itertools import numpy as np -from matplotlib import _api +from matplotlib import cbook, collections, _api, _style_helpers __all__ = ['stackplot'] @@ -71,7 +70,14 @@ def stackplot(axes, x, *args, DATA_PARAMETER_PLACEHOLDER **kwargs - All other keyword arguments are passed to `.Axes.fill_between`. + All other keyword arguments are passed to `.Axes.fill_between`. The + following parameters additionally accept a sequence of values + corresponding to the *y* datasets: + *edgecolor(s)*, *facecolor(s)*, *linewidth(s)*, *linestyle(s)*. + + .. versionadded:: 3.11 + Allowing sequences of values in above listed `.Axes.fill_between` + parameters. Returns ------- @@ -83,15 +89,14 @@ def stackplot(axes, x, *args, y = np.vstack(args) labels = iter(labels) - if colors is not None: - colors = itertools.cycle(colors) - else: - colors = (axes._get_lines.get_next_color() for _ in y) + if colors is None: + colors = [axes._get_lines.get_next_color() for _ in y] + + kwargs['hatch'] = hatch + kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection) + kwargs.setdefault('facecolor', colors) - if hatch is None or isinstance(hatch, str): - hatch = itertools.cycle([hatch]) - else: - hatch = itertools.cycle(hatch) + kwargs, style_gen = _style_helpers.style_generator(kwargs) # Assume data passed has not been 'stacked', so stack it here. # We'll need a float buffer for the upcoming calculations. @@ -130,18 +135,14 @@ def stackplot(axes, x, *args, # Color between x = 0 and the first array. coll = axes.fill_between(x, first_line, stack[0, :], - facecolor=next(colors), - hatch=next(hatch), label=next(labels, None), - **kwargs) + **next(style_gen), **kwargs) coll.sticky_edges.y[:] = [0] r = [coll] # Color between array i-1 and array i for i in range(len(y) - 1): r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :], - facecolor=next(colors), - hatch=next(hatch), label=next(labels, None), - **kwargs)) + **next(style_gen), **kwargs)) return r diff --git a/lib/matplotlib/tests/test__style_helpers.py b/lib/matplotlib/tests/test__style_helpers.py new file mode 100644 index 000000000000..85ba1c48a33a --- /dev/null +++ b/lib/matplotlib/tests/test__style_helpers.py @@ -0,0 +1,77 @@ +import pytest + +import matplotlib.colors as mcolors +from matplotlib.lines import _get_dash_pattern +from matplotlib._style_helpers import style_generator + + +@pytest.mark.parametrize('key, value', [('facecolor', ["b", "g", "r"]), + ('edgecolor', ["b", "g", "r"]), + ('hatch', ["/", "\\", "."]), + ('linestyle', ["-", "--", ":"]), + ('linewidth', [1, 1.5, 2])]) +def test_style_generator_list(key, value): + kw = {'foo': 12, key: value} + new_kw, gen = style_generator(kw) + + assert new_kw == {'foo': 12} + + for v in value * 2: # Result should repeat + style_dict = next(gen) + assert len(style_dict) == 1 + if key.endswith('color'): + assert mcolors.same_color(v, style_dict[key]) + elif key == 'linestyle': + assert _get_dash_pattern(v) == style_dict[key] + else: + assert v == style_dict[key] + + +@pytest.mark.parametrize('key, value', [('facecolor', "b"), + ('edgecolor', "b"), + ('hatch', "/"), + ('linestyle', "-"), + ('linewidth', 1)]) +def test_style_generator_single(key, value): + kw = {'foo': 12, key: value} + new_kw, gen = style_generator(kw) + + assert new_kw == {'foo': 12} + for _ in range(2): # Result should repeat + style_dict = next(gen) + if key.endswith('color'): + assert mcolors.same_color(value, style_dict[key]) + elif key == 'linestyle': + assert _get_dash_pattern(value) == style_dict[key] + else: + assert value == style_dict[key] + + +@pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle']) +def test_style_generator_empty(key): + kw = {key: []} + with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'): + style_generator(kw) + + +def test_style_generator_sequence_type_styles(): + kw = {'facecolor': ('r', 0.5), + 'edgecolor': [0.5, 0.5, 0.5], + 'linestyle': (0, (1, 1))} + + _, gen = style_generator(kw) + for _ in range(2): # Result should repeat + style_dict = next(gen) + mcolors.same_color(kw['facecolor'], style_dict['facecolor']) + mcolors.same_color(kw['edgecolor'], style_dict['edgecolor']) + kw['linestyle'] == style_dict['linestyle'] + + +def test_style_generator_none(): + kw = {'facecolor': 'none', + 'edgecolor': 'none'} + _, gen = style_generator(kw) + for _ in range(2): # Result should repeat + style_dict = next(gen) + assert style_dict['facecolor'] == 'none' + assert style_dict['edgecolor'] == 'none' diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index fe121e12c9f1..b35de230ecf8 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2268,6 +2268,20 @@ def test_grouped_bar_return_value(): assert bc not in ax.containers +def test_grouped_bar_hatch_sequence(): + """Each dataset should receive its own hatch pattern when a sequence is passed.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4])] + hatches = ['//', 'xx', '..'] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + # Verify each dataset gets the corresponding hatch + for hatch, c in zip(hatches, containers.bar_containers): + for rect in c: + assert rect.get_hatch() == hatch + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2) @@ -3430,6 +3444,26 @@ def test_stackplot_hatching(fig_ref, fig_test): ax_ref.set_ylim(0, 70) +def test_stackplot_facecolor(): + # Test that facecolors are properly passed and take precedence over colors parameter + x = np.linspace(0, 10, 10) + y1 = 1.0 * x + y2 = 2.0 * x + 1 + + fcols = ['r', 'b'] + + fig, ax = plt.subplots() + + colls = ax.stackplot(x, y1, y2, facecolor=fcols, colors=['c', 'm']) + for coll, fcol in zip(colls, fcols): + assert mcolors.same_color(coll.get_facecolor(), fcol) + + # Plural alias should also work + colls = ax.stackplot(x, y1, y2, facecolors=fcols, colors=['c', 'm']) + for coll, fcol in zip(colls, fcols): + assert mcolors.same_color(coll.get_facecolor(), fcol) + + def test_stackplot_subfig_legend(): # Smoke test for https://github.com/matplotlib/matplotlib/issues/30158 @@ -5046,27 +5080,6 @@ def test_hist_vectorized_params(fig_test, fig_ref, kwargs): zorder=(len(xs)-i)/2) -def test_hist_sequence_type_styles(): - facecolor = ('r', 0.5) - edgecolor = [0.5, 0.5, 0.5] - linestyle = (0, (1, 1)) - - arr = np.random.uniform(size=50) - _, _, bars = plt.hist(arr, facecolor=facecolor, edgecolor=edgecolor, - linestyle=linestyle) - assert mcolors.same_color(bars[0].get_facecolor(), facecolor) - assert mcolors.same_color(bars[0].get_edgecolor(), edgecolor) - assert bars[0].get_linestyle() == linestyle - - -def test_hist_color_none(): - arr = np.random.uniform(size=50) - # No edgecolor is the default but check that it can be explicitly passed. - _, _, bars = plt.hist(arr, facecolor='none', edgecolor='none') - assert bars[0].get_facecolor(), (0, 0, 0, 0) - assert bars[0].get_edgecolor(), (0, 0, 0, 0) - - @pytest.mark.parametrize('kwargs, patch_face, patch_edge', # 'C0'(blue) stands for the first color of the # default color cycle as well as the patch.facecolor rcParam From 292b3e5d21537fa17cca7bb879d70e3f33b7c847 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:32:27 +0000 Subject: [PATCH 209/228] simplify --- lib/matplotlib/_style_helpers.py | 41 +++++++------- lib/matplotlib/axes/_axes.py | 17 +++--- lib/matplotlib/stackplot.py | 6 +- lib/matplotlib/tests/test__style_helpers.py | 61 ++++++++++----------- 4 files changed, 62 insertions(+), 63 deletions(-) diff --git a/lib/matplotlib/_style_helpers.py b/lib/matplotlib/_style_helpers.py index 9b98d90593f9..7f4203ac4863 100644 --- a/lib/matplotlib/_style_helpers.py +++ b/lib/matplotlib/_style_helpers.py @@ -1,41 +1,44 @@ import collections.abc import itertools -import numpy as np - import matplotlib.cbook as cbook import matplotlib.colors as mcolors import matplotlib.lines as mlines + def check_non_empty(key, value): """Raise a TypeError if an empty sequence is passed""" - if (not cbook.is_scalar_or_string(value) and - isinstance(value, collections.abc.Sized) and len(value) == 0): + if isinstance(value, collections.abc.Sized) and len(value) == 0: raise TypeError(f'{key} must not be an empty sequence') -def style_generator(kw): +def iterate_styles(kw): """ Helper for handling style sequences (e.g. facecolor=['r', 'b', 'k']) within plotting - methods that repeatedly call other plotting methods (e.g. hist, stackplot). Remove - style keywords from the given dictionary. Return the reduced dictionary together - with a generator which provides a series of dictionaries to be used in each call to - the wrapped function. + methods that repeatedly call other plotting methods (e.g. hist, stackplot). + + Given a dictionary of keyword parameters, yield a series of copies of the + dictionary. Style parameters expressed as sequences are replaced in the copy with + the next element of the sequence. + + Note 'color' is deliberately not handled since the calling methods have their own + handling for that. """ kw_iterators = {} remaining_kw = {} for key, value in kw.items(): - if key in ['facecolor', 'edgecolor']: - if value is None or cbook._str_lower_equal(value, 'none'): - kw_iterators[key] = itertools.repeat(value) - else: - check_non_empty(key, value) - kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) + if cbook.is_scalar_or_string(value): + # No iteration required + remaining_kw[key] = value + + elif key in ['facecolor', 'edgecolor']: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) elif key in ['hatch', 'linewidth']: check_non_empty(key, value) - kw_iterators[key] = itertools.cycle(np.atleast_1d(value)) + kw_iterators[key] = itertools.cycle(value) elif key == 'linestyle': check_non_empty(key, value) @@ -44,8 +47,6 @@ def style_generator(kw): else: remaining_kw[key] = value - def style_gen(): - while True: - yield {key: next(val) for key, val in kw_iterators.items()} - return remaining_kw, style_gen() + while True: + yield {key: next(val) for key, val in kw_iterators.items()} | remaining_kw diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 029260b90c26..0d882e65cec7 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3324,7 +3324,7 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) - kwargs, style_gen = _style_helpers.style_generator(kwargs) + kwargs_gen = _style_helpers.iterate_styles(kwargs) bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) @@ -3339,16 +3339,15 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color, styles) in enumerate(zip(heights, labels, colors, - style_gen)): + for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) if orientation == "vertical": bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **styles, **kwargs) + label=label, color=color, **next(kwargs_gen)) else: bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **styles, **kwargs) + label=label, color=color, **next(kwargs_gen)) bar_containers.append(bc) if tick_labels is not None: @@ -7641,18 +7640,18 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, if histtype == "step": kwargs.setdefault('edgecolor', colors) - kwargs, style_gen = _style_helpers.style_generator(kwargs) + kwargs_gen = _style_helpers.iterate_styles(kwargs) for patch, lbl in itertools.zip_longest(patches, labels): if not patch: continue + kw = next(kwargs_gen) p = patch[0] - kwargs.update(next(style_gen)) - p._internal_update(kwargs) + p._internal_update(kw) if lbl is not None: p.set_label(lbl) for p in patch[1:]: - p._internal_update(kwargs) + p._internal_update(kw) p.set_label('_nolegend_') if nx == 1: diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index 6592260ccbf1..822e98aac515 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -96,7 +96,7 @@ def stackplot(axes, x, *args, kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection) kwargs.setdefault('facecolor', colors) - kwargs, style_gen = _style_helpers.style_generator(kwargs) + kwargs_gen = _style_helpers.iterate_styles(kwargs) # Assume data passed has not been 'stacked', so stack it here. # We'll need a float buffer for the upcoming calculations. @@ -136,7 +136,7 @@ def stackplot(axes, x, *args, # Color between x = 0 and the first array. coll = axes.fill_between(x, first_line, stack[0, :], label=next(labels, None), - **next(style_gen), **kwargs) + **next(kwargs_gen)) coll.sticky_edges.y[:] = [0] r = [coll] @@ -144,5 +144,5 @@ def stackplot(axes, x, *args, for i in range(len(y) - 1): r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :], label=next(labels, None), - **next(style_gen), **kwargs)) + **next(kwargs_gen))) return r diff --git a/lib/matplotlib/tests/test__style_helpers.py b/lib/matplotlib/tests/test__style_helpers.py index 85ba1c48a33a..1584221685f0 100644 --- a/lib/matplotlib/tests/test__style_helpers.py +++ b/lib/matplotlib/tests/test__style_helpers.py @@ -2,7 +2,7 @@ import matplotlib.colors as mcolors from matplotlib.lines import _get_dash_pattern -from matplotlib._style_helpers import style_generator +from matplotlib._style_helpers import iterate_styles @pytest.mark.parametrize('key, value', [('facecolor', ["b", "g", "r"]), @@ -10,21 +10,20 @@ ('hatch', ["/", "\\", "."]), ('linestyle', ["-", "--", ":"]), ('linewidth', [1, 1.5, 2])]) -def test_style_generator_list(key, value): +def test_iterate_styles_list(key, value): kw = {'foo': 12, key: value} - new_kw, gen = style_generator(kw) - - assert new_kw == {'foo': 12} + gen_kw = iterate_styles(kw) for v in value * 2: # Result should repeat - style_dict = next(gen) - assert len(style_dict) == 1 + kw_dict = next(gen_kw) + assert len(kw_dict) == 2 + assert kw_dict['foo'] == 12 if key.endswith('color'): - assert mcolors.same_color(v, style_dict[key]) + assert mcolors.same_color(v, kw_dict[key]) elif key == 'linestyle': - assert _get_dash_pattern(v) == style_dict[key] + assert _get_dash_pattern(v) == kw_dict[key] else: - assert v == style_dict[key] + assert v == kw_dict[key] @pytest.mark.parametrize('key, value', [('facecolor', "b"), @@ -32,46 +31,46 @@ def test_style_generator_list(key, value): ('hatch', "/"), ('linestyle', "-"), ('linewidth', 1)]) -def test_style_generator_single(key, value): +def test_iterate_styles_single(key, value): kw = {'foo': 12, key: value} - new_kw, gen = style_generator(kw) + gen_kw = iterate_styles(kw) - assert new_kw == {'foo': 12} for _ in range(2): # Result should repeat - style_dict = next(gen) + kw_dict = next(gen_kw) + assert len(kw_dict) == 2 + assert kw_dict['foo'] == 12 if key.endswith('color'): - assert mcolors.same_color(value, style_dict[key]) - elif key == 'linestyle': - assert _get_dash_pattern(value) == style_dict[key] + assert mcolors.same_color(value, kw_dict[key]) else: - assert value == style_dict[key] + assert value == kw_dict[key] @pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle']) -def test_style_generator_empty(key): +def test_iterate_styles_empty(key): kw = {key: []} + gen_kw = iterate_styles(kw) with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'): - style_generator(kw) + next(gen_kw) -def test_style_generator_sequence_type_styles(): +def test_iterate_styles_sequence_type_styles(): kw = {'facecolor': ('r', 0.5), 'edgecolor': [0.5, 0.5, 0.5], 'linestyle': (0, (1, 1))} - _, gen = style_generator(kw) + gen_kw = iterate_styles(kw) for _ in range(2): # Result should repeat - style_dict = next(gen) - mcolors.same_color(kw['facecolor'], style_dict['facecolor']) - mcolors.same_color(kw['edgecolor'], style_dict['edgecolor']) - kw['linestyle'] == style_dict['linestyle'] + kw_dict = next(gen_kw) + mcolors.same_color(kw['facecolor'], kw_dict['facecolor']) + mcolors.same_color(kw['edgecolor'], kw_dict['edgecolor']) + kw['linestyle'] == kw_dict['linestyle'] -def test_style_generator_none(): +def test_iterate_styles_none(): kw = {'facecolor': 'none', 'edgecolor': 'none'} - _, gen = style_generator(kw) + gen_kw = iterate_styles(kw) for _ in range(2): # Result should repeat - style_dict = next(gen) - assert style_dict['facecolor'] == 'none' - assert style_dict['edgecolor'] == 'none' + kw_dict = next(gen_kw) + assert kw_dict['facecolor'] == 'none' + assert kw_dict['edgecolor'] == 'none' From 9de925ec3fe3e17b97ffb451626030dfe58434ae Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:26:25 +0000 Subject: [PATCH 210/228] Revert "simplify" This reverts commit ab393269535096b4ee683674ecb63c55e3c297bc. --- lib/matplotlib/_style_helpers.py | 41 +++++++------- lib/matplotlib/axes/_axes.py | 17 +++--- lib/matplotlib/stackplot.py | 6 +- lib/matplotlib/tests/test__style_helpers.py | 61 +++++++++++---------- 4 files changed, 63 insertions(+), 62 deletions(-) diff --git a/lib/matplotlib/_style_helpers.py b/lib/matplotlib/_style_helpers.py index 7f4203ac4863..9b98d90593f9 100644 --- a/lib/matplotlib/_style_helpers.py +++ b/lib/matplotlib/_style_helpers.py @@ -1,44 +1,41 @@ import collections.abc import itertools +import numpy as np + import matplotlib.cbook as cbook import matplotlib.colors as mcolors import matplotlib.lines as mlines - def check_non_empty(key, value): """Raise a TypeError if an empty sequence is passed""" - if isinstance(value, collections.abc.Sized) and len(value) == 0: + if (not cbook.is_scalar_or_string(value) and + isinstance(value, collections.abc.Sized) and len(value) == 0): raise TypeError(f'{key} must not be an empty sequence') -def iterate_styles(kw): +def style_generator(kw): """ Helper for handling style sequences (e.g. facecolor=['r', 'b', 'k']) within plotting - methods that repeatedly call other plotting methods (e.g. hist, stackplot). - - Given a dictionary of keyword parameters, yield a series of copies of the - dictionary. Style parameters expressed as sequences are replaced in the copy with - the next element of the sequence. - - Note 'color' is deliberately not handled since the calling methods have their own - handling for that. + methods that repeatedly call other plotting methods (e.g. hist, stackplot). Remove + style keywords from the given dictionary. Return the reduced dictionary together + with a generator which provides a series of dictionaries to be used in each call to + the wrapped function. """ kw_iterators = {} remaining_kw = {} for key, value in kw.items(): - if cbook.is_scalar_or_string(value): - # No iteration required - remaining_kw[key] = value - - elif key in ['facecolor', 'edgecolor']: - check_non_empty(key, value) - kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) + if key in ['facecolor', 'edgecolor']: + if value is None or cbook._str_lower_equal(value, 'none'): + kw_iterators[key] = itertools.repeat(value) + else: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) elif key in ['hatch', 'linewidth']: check_non_empty(key, value) - kw_iterators[key] = itertools.cycle(value) + kw_iterators[key] = itertools.cycle(np.atleast_1d(value)) elif key == 'linestyle': check_non_empty(key, value) @@ -47,6 +44,8 @@ def iterate_styles(kw): else: remaining_kw[key] = value + def style_gen(): + while True: + yield {key: next(val) for key, val in kw_iterators.items()} - while True: - yield {key: next(val) for key, val in kw_iterators.items()} | remaining_kw + return remaining_kw, style_gen() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 0d882e65cec7..029260b90c26 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3324,7 +3324,7 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) - kwargs_gen = _style_helpers.iterate_styles(kwargs) + kwargs, style_gen = _style_helpers.style_generator(kwargs) bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) @@ -3339,15 +3339,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + for i, (hs, label, color, styles) in enumerate(zip(heights, labels, colors, + style_gen)): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) if orientation == "vertical": bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **next(kwargs_gen)) + label=label, color=color, **styles, **kwargs) else: bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **next(kwargs_gen)) + label=label, color=color, **styles, **kwargs) bar_containers.append(bc) if tick_labels is not None: @@ -7640,18 +7641,18 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, if histtype == "step": kwargs.setdefault('edgecolor', colors) - kwargs_gen = _style_helpers.iterate_styles(kwargs) + kwargs, style_gen = _style_helpers.style_generator(kwargs) for patch, lbl in itertools.zip_longest(patches, labels): if not patch: continue - kw = next(kwargs_gen) p = patch[0] - p._internal_update(kw) + kwargs.update(next(style_gen)) + p._internal_update(kwargs) if lbl is not None: p.set_label(lbl) for p in patch[1:]: - p._internal_update(kw) + p._internal_update(kwargs) p.set_label('_nolegend_') if nx == 1: diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index 822e98aac515..6592260ccbf1 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -96,7 +96,7 @@ def stackplot(axes, x, *args, kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection) kwargs.setdefault('facecolor', colors) - kwargs_gen = _style_helpers.iterate_styles(kwargs) + kwargs, style_gen = _style_helpers.style_generator(kwargs) # Assume data passed has not been 'stacked', so stack it here. # We'll need a float buffer for the upcoming calculations. @@ -136,7 +136,7 @@ def stackplot(axes, x, *args, # Color between x = 0 and the first array. coll = axes.fill_between(x, first_line, stack[0, :], label=next(labels, None), - **next(kwargs_gen)) + **next(style_gen), **kwargs) coll.sticky_edges.y[:] = [0] r = [coll] @@ -144,5 +144,5 @@ def stackplot(axes, x, *args, for i in range(len(y) - 1): r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :], label=next(labels, None), - **next(kwargs_gen))) + **next(style_gen), **kwargs)) return r diff --git a/lib/matplotlib/tests/test__style_helpers.py b/lib/matplotlib/tests/test__style_helpers.py index 1584221685f0..85ba1c48a33a 100644 --- a/lib/matplotlib/tests/test__style_helpers.py +++ b/lib/matplotlib/tests/test__style_helpers.py @@ -2,7 +2,7 @@ import matplotlib.colors as mcolors from matplotlib.lines import _get_dash_pattern -from matplotlib._style_helpers import iterate_styles +from matplotlib._style_helpers import style_generator @pytest.mark.parametrize('key, value', [('facecolor', ["b", "g", "r"]), @@ -10,20 +10,21 @@ ('hatch', ["/", "\\", "."]), ('linestyle', ["-", "--", ":"]), ('linewidth', [1, 1.5, 2])]) -def test_iterate_styles_list(key, value): +def test_style_generator_list(key, value): kw = {'foo': 12, key: value} - gen_kw = iterate_styles(kw) + new_kw, gen = style_generator(kw) + + assert new_kw == {'foo': 12} for v in value * 2: # Result should repeat - kw_dict = next(gen_kw) - assert len(kw_dict) == 2 - assert kw_dict['foo'] == 12 + style_dict = next(gen) + assert len(style_dict) == 1 if key.endswith('color'): - assert mcolors.same_color(v, kw_dict[key]) + assert mcolors.same_color(v, style_dict[key]) elif key == 'linestyle': - assert _get_dash_pattern(v) == kw_dict[key] + assert _get_dash_pattern(v) == style_dict[key] else: - assert v == kw_dict[key] + assert v == style_dict[key] @pytest.mark.parametrize('key, value', [('facecolor', "b"), @@ -31,46 +32,46 @@ def test_iterate_styles_list(key, value): ('hatch', "/"), ('linestyle', "-"), ('linewidth', 1)]) -def test_iterate_styles_single(key, value): +def test_style_generator_single(key, value): kw = {'foo': 12, key: value} - gen_kw = iterate_styles(kw) + new_kw, gen = style_generator(kw) + assert new_kw == {'foo': 12} for _ in range(2): # Result should repeat - kw_dict = next(gen_kw) - assert len(kw_dict) == 2 - assert kw_dict['foo'] == 12 + style_dict = next(gen) if key.endswith('color'): - assert mcolors.same_color(value, kw_dict[key]) + assert mcolors.same_color(value, style_dict[key]) + elif key == 'linestyle': + assert _get_dash_pattern(value) == style_dict[key] else: - assert value == kw_dict[key] + assert value == style_dict[key] @pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle']) -def test_iterate_styles_empty(key): +def test_style_generator_empty(key): kw = {key: []} - gen_kw = iterate_styles(kw) with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'): - next(gen_kw) + style_generator(kw) -def test_iterate_styles_sequence_type_styles(): +def test_style_generator_sequence_type_styles(): kw = {'facecolor': ('r', 0.5), 'edgecolor': [0.5, 0.5, 0.5], 'linestyle': (0, (1, 1))} - gen_kw = iterate_styles(kw) + _, gen = style_generator(kw) for _ in range(2): # Result should repeat - kw_dict = next(gen_kw) - mcolors.same_color(kw['facecolor'], kw_dict['facecolor']) - mcolors.same_color(kw['edgecolor'], kw_dict['edgecolor']) - kw['linestyle'] == kw_dict['linestyle'] + style_dict = next(gen) + mcolors.same_color(kw['facecolor'], style_dict['facecolor']) + mcolors.same_color(kw['edgecolor'], style_dict['edgecolor']) + kw['linestyle'] == style_dict['linestyle'] -def test_iterate_styles_none(): +def test_style_generator_none(): kw = {'facecolor': 'none', 'edgecolor': 'none'} - gen_kw = iterate_styles(kw) + _, gen = style_generator(kw) for _ in range(2): # Result should repeat - kw_dict = next(gen_kw) - assert kw_dict['facecolor'] == 'none' - assert kw_dict['edgecolor'] == 'none' + style_dict = next(gen) + assert style_dict['facecolor'] == 'none' + assert style_dict['edgecolor'] == 'none' From 9d777268e7b14319f0a90cfabc85c796aa00cc42 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:50:29 +0000 Subject: [PATCH 211/228] remove plot from whatsnew --- .../stackplot_style_sequences.rst | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/doc/release/next_whats_new/stackplot_style_sequences.rst b/doc/release/next_whats_new/stackplot_style_sequences.rst index 7d03a415c1c0..209d30a15218 100644 --- a/doc/release/next_whats_new/stackplot_style_sequences.rst +++ b/doc/release/next_whats_new/stackplot_style_sequences.rst @@ -4,23 +4,3 @@ Stackplot styling `~.Axes.stackplot` now accepts sequences for the style parameters *facecolor*, *edgecolor*, *linestyle*, and *linewidth*, similar to how the *hatch* parameter is already handled. - -.. plot:: - :include-source: true - :alt: A stackplot showing with two regions. The bottom region is red with black dots and a dotted black outline. The top region is blue with gray stars and a thicker dashed outline. - - import matplotlib.pyplot as plt - import numpy as np - - x = np.linspace(0, 10, 10) - y1 = 1.0 * x - y2 = 2.0 * x + 1 - - fig, ax = plt.subplots() - - ax.stackplot(x, y1, y2, - facecolor=['tab:red', 'tab:blue'], - edgecolor=['black', 'gray'], - linestyle=[':', '--'], - linewidth=[2, 3], - hatch=['.', '*']) From 2b5ee9a1c27f17ba13b5a21c076c9f6f46212311 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:24:51 +0000 Subject: [PATCH 212/228] take hatch out of stackplot named parameters --- lib/matplotlib/stackplot.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index 6592260ccbf1..4e00ab821e74 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -15,7 +15,7 @@ def stackplot(axes, x, *args, - labels=(), colors=None, hatch=None, baseline='zero', + labels=(), colors=None, baseline='zero', **kwargs): """ Draw a stacked area plot or a streamgraph. @@ -54,18 +54,6 @@ def stackplot(axes, x, *args, If not specified, the colors from the Axes property cycle will be used. - hatch : list of str, default: None - A sequence of hatching styles. See - :doc:`/gallery/shapes_and_collections/hatch_style_reference`. - The sequence will be cycled through for filling the - stacked areas from bottom to top. - It need not be exactly the same length as the number - of provided *y*, in which case the styles will repeat from the - beginning. - - .. versionadded:: 3.9 - Support for list input - data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -73,7 +61,16 @@ def stackplot(axes, x, *args, All other keyword arguments are passed to `.Axes.fill_between`. The following parameters additionally accept a sequence of values corresponding to the *y* datasets: - *edgecolor(s)*, *facecolor(s)*, *linewidth(s)*, *linestyle(s)*. + + - *hatch* + + .. versionadded:: 3.9 + Allowing a sequence of strings for *hatch* + + - *edgecolor* + - *facecolor* + - *linewidth* + - *linestyle* .. versionadded:: 3.11 Allowing sequences of values in above listed `.Axes.fill_between` @@ -92,7 +89,6 @@ def stackplot(axes, x, *args, if colors is None: colors = [axes._get_lines.get_next_color() for _ in y] - kwargs['hatch'] = hatch kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection) kwargs.setdefault('facecolor', colors) From 55d1a5e9d66d791e8b19e335f4c3563044d66a76 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:23:35 +0000 Subject: [PATCH 213/228] Apply suggestions from timhoffm code review Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/axes/_axes.py | 12 +++++++++--- lib/matplotlib/tests/test__style_helpers.py | 8 +++++++- lib/matplotlib/tests/test_axes.py | 14 +++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 029260b90c26..6457ef5ffcf7 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3194,9 +3194,15 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing **kwargs : `.Rectangle` properties - The following properties additionally accept a sequence of values - corresponding to the datasets in *heights*: - *edgecolor*, *facecolor*, *linewidth*, *linestyle*, *hatch*. + Properties applied to all bars. The following properties additionally + accept a sequence of values corresponding to the datasets in + *heights*: + + - *edgecolor* + - *facecolor* + - *linewidth* + - *linestyle* + - *hatch* %(Rectangle:kwdoc)s diff --git a/lib/matplotlib/tests/test__style_helpers.py b/lib/matplotlib/tests/test__style_helpers.py index 85ba1c48a33a..764bd5a0c88e 100644 --- a/lib/matplotlib/tests/test__style_helpers.py +++ b/lib/matplotlib/tests/test__style_helpers.py @@ -11,6 +11,7 @@ ('linestyle', ["-", "--", ":"]), ('linewidth', [1, 1.5, 2])]) def test_style_generator_list(key, value): + """Test that style parameter lists are distributed to the generator.""" kw = {'foo': 12, key: value} new_kw, gen = style_generator(kw) @@ -33,6 +34,7 @@ def test_style_generator_list(key, value): ('linestyle', "-"), ('linewidth', 1)]) def test_style_generator_single(key, value): + """Test that single-value style parameters are distributed to the generator.""" kw = {'foo': 12, key: value} new_kw, gen = style_generator(kw) @@ -48,13 +50,17 @@ def test_style_generator_single(key, value): @pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle']) -def test_style_generator_empty(key): +def test_style_generator_raises_on_empty_style_parameter_list(key): kw = {key: []} with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'): style_generator(kw) def test_style_generator_sequence_type_styles(): + """ + Test that sequence type style values are detected as single value + and passed to a all elements of the generator. + """ kw = {'facecolor': ('r', 0.5), 'edgecolor': [0.5, 0.5, 0.5], 'linestyle': (0, (1, 1))} diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index b35de230ecf8..9770be7c802c 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3450,18 +3450,18 @@ def test_stackplot_facecolor(): y1 = 1.0 * x y2 = 2.0 * x + 1 - fcols = ['r', 'b'] + facecolors = ['r', 'b'] fig, ax = plt.subplots() - colls = ax.stackplot(x, y1, y2, facecolor=fcols, colors=['c', 'm']) - for coll, fcol in zip(colls, fcols): - assert mcolors.same_color(coll.get_facecolor(), fcol) + colls = ax.stackplot(x, y1, y2, facecolor=facecolors, colors=['c', 'm']) + for coll, fcol in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), facecolors) # Plural alias should also work - colls = ax.stackplot(x, y1, y2, facecolors=fcols, colors=['c', 'm']) - for coll, fcol in zip(colls, fcols): - assert mcolors.same_color(coll.get_facecolor(), fcol) + colls = ax.stackplot(x, y1, y2, facecolors=facecolors, colors=['c', 'm']) + for coll, fcol in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), facecolors) def test_stackplot_subfig_legend(): From ea8ac8237aa9fed6aa52c62eefb577e73eb5d5a8 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:35:58 +0000 Subject: [PATCH 214/228] fix linting and stackplot test --- lib/matplotlib/axes/_axes.py | 2 +- lib/matplotlib/tests/test_axes.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 6457ef5ffcf7..9e94f6f78f1c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3197,7 +3197,7 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing Properties applied to all bars. The following properties additionally accept a sequence of values corresponding to the datasets in *heights*: - + - *edgecolor* - *facecolor* - *linewidth* diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 9770be7c802c..02c22540728c 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3455,13 +3455,13 @@ def test_stackplot_facecolor(): fig, ax = plt.subplots() colls = ax.stackplot(x, y1, y2, facecolor=facecolors, colors=['c', 'm']) - for coll, fcol in zip(colls, facecolors): - assert mcolors.same_color(coll.get_facecolor(), facecolors) + for coll, fcolor in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), fcolor) # Plural alias should also work colls = ax.stackplot(x, y1, y2, facecolors=facecolors, colors=['c', 'm']) - for coll, fcol in zip(colls, facecolors): - assert mcolors.same_color(coll.get_facecolor(), facecolors) + for coll, fcolor in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), fcolor) def test_stackplot_subfig_legend(): From c49771d61e70060259424cf16f1f4e433ca265ea Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:45:17 +0000 Subject: [PATCH 215/228] punctuation --- lib/matplotlib/stackplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index 4e00ab821e74..7dfc33357db0 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -65,7 +65,7 @@ def stackplot(axes, x, *args, - *hatch* .. versionadded:: 3.9 - Allowing a sequence of strings for *hatch* + Allowing a sequence of strings for *hatch*. - *edgecolor* - *facecolor* From c3f16337fb85f534ac9ded54484142e4d8051c82 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:12:06 +0000 Subject: [PATCH 216/228] group versionadded info Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/stackplot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index 7dfc33357db0..25bb2f45a0c4 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -63,15 +63,14 @@ def stackplot(axes, x, *args, corresponding to the *y* datasets: - *hatch* - - .. versionadded:: 3.9 - Allowing a sequence of strings for *hatch*. - - *edgecolor* - *facecolor* - *linewidth* - *linestyle* + .. versionadded:: 3.9 + Allowing a sequence of strings for *hatch*. + .. versionadded:: 3.11 Allowing sequences of values in above listed `.Axes.fill_between` parameters. From 11f44eee092b39e20425fd9f9eca0ec5a076f6d2 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:35:14 +0000 Subject: [PATCH 217/228] remove named hatch param from pyplot.stackplot --- lib/matplotlib/pyplot.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index a20553d719db..69fb9107bcf5 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -4208,15 +4208,12 @@ def spy( # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.stackplot) -def stackplot( - x, *args, labels=(), colors=None, hatch=None, baseline="zero", data=None, **kwargs -): +def stackplot(x, *args, labels=(), colors=None, baseline="zero", data=None, **kwargs): return gca().stackplot( x, *args, labels=labels, colors=colors, - hatch=hatch, baseline=baseline, **({"data": data} if data is not None else {}), **kwargs, From fa5b30a01b211b9b1608e19c0cf216dd4ec0a781 Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 7 Dec 2025 21:35:59 +0530 Subject: [PATCH 218/228] DOC: Add parameters documentation for FFMpegFileWriter --- lib/matplotlib/animation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 56fda4ec6849..54a27547b27c 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -605,6 +605,10 @@ def _args(self): class FFMpegFileWriter(FFMpegBase, FileMovieWriter): """ File-based ffmpeg writer. + Parameters + ---------- + *args, **kwargs + All arguments are forwarded to `FileMovieWriter`. Frames are written to temporary files on disk and then stitched together at the end. From 79b6be0655a318a2518f57c66fdf0a0aafa4c99a Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 7 Dec 2025 21:53:33 +0530 Subject: [PATCH 219/228] DOC: Document FFMpegFileWriter *args, **kwargs Adds documentation explaining that arguments are forwarded to the parent MovieWriter class. Fixes #22831 --- lib/matplotlib/animation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 54a27547b27c..b6117917e8fc 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -605,6 +605,7 @@ def _args(self): class FFMpegFileWriter(FFMpegBase, FileMovieWriter): """ File-based ffmpeg writer. + Parameters ---------- *args, **kwargs From 88e62bd75d0ce91ac28f39a953595177a1068e2f Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 7 Dec 2025 22:26:19 +0530 Subject: [PATCH 220/228] DOC: Fix docstring formatting for FFMpegFileWriter - Move Parameters section to correct location - Add blank line before Parameters section - Follow NumPy docstring conventions --- lib/matplotlib/animation.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index b6117917e8fc..7aab48b14eeb 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -606,16 +606,17 @@ class FFMpegFileWriter(FFMpegBase, FileMovieWriter): """ File-based ffmpeg writer. - Parameters - ---------- - *args, **kwargs - All arguments are forwarded to `FileMovieWriter`. - Frames are written to temporary files on disk and then stitched together at the end. This effectively works as a slideshow input to ffmpeg with the fps passed as ``-framerate``, so see also `their notes on frame rates`_ for further details. + Parameters + ---------- + *args, **kwargs + All arguments are forwarded to `FileMovieWriter`. See + `FileMovieWriter` for a list of all possible parameters. + .. _their notes on frame rates: https://trac.ffmpeg.org/wiki/Slideshow#Framerates """ supported_formats = ['png', 'jpeg', 'tiff', 'raw', 'rgba'] From ed7664a7d433685a6aa540455fa0c9f70c43093c Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:50:44 +0100 Subject: [PATCH 221/228] DOC: Define the effect of rcParams["figure.raise_window"] = False Closes #20415. --- lib/matplotlib/mpl-data/matplotlibrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index b1eed6abfd3c..17705fe60347 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -600,6 +600,9 @@ # the pyplot interface before emitting a warning. # If less than one this feature is disabled. #figure.raise_window : True # Raise the GUI window to front when show() is called. + # If set to False, we currently do not take any further + # actions and whether the window appears on the front + # may depend on the GUI framework and window manager. ## The figure subplot parameters. All dimensions are a fraction of the figure width and height. #figure.subplot.left: 0.125 # the left side of the subplots of the figure From 94bf6bde0cf9f070b2969b0308d03f84d6eadbf4 Mon Sep 17 00:00:00 2001 From: Saumya Date: Mon, 8 Dec 2025 15:42:44 +0530 Subject: [PATCH 222/228] DOC: Document FFMpegFileWriter *args, **kwargs Adds documentation explaining that arguments are forwarded to the parent MovieWriter class. Fixes #22831 --- lib/matplotlib/animation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 7aab48b14eeb..73981c440957 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -611,13 +611,13 @@ class FFMpegFileWriter(FFMpegBase, FileMovieWriter): This effectively works as a slideshow input to ffmpeg with the fps passed as ``-framerate``, so see also `their notes on frame rates`_ for further details. + .. _their notes on frame rates: https://trac.ffmpeg.org/wiki/Slideshow#Framerates + Parameters ---------- *args, **kwargs All arguments are forwarded to `FileMovieWriter`. See `FileMovieWriter` for a list of all possible parameters. - - .. _their notes on frame rates: https://trac.ffmpeg.org/wiki/Slideshow#Framerates """ supported_formats = ['png', 'jpeg', 'tiff', 'raw', 'rgba'] From 54321e3a950820347d9ff9b41b50fafff6f8d172 Mon Sep 17 00:00:00 2001 From: Saumya Date: Mon, 8 Dec 2025 15:46:52 +0530 Subject: [PATCH 223/228] DOC: Document FFMpegFileWriter *args, **kwargs Adds documentation explaining that arguments are forwarded to the parent MovieWriter class. Fixes #22831 --- lib/matplotlib/animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 73981c440957..1a398c91dbef 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -612,7 +612,7 @@ class FFMpegFileWriter(FFMpegBase, FileMovieWriter): ``-framerate``, so see also `their notes on frame rates`_ for further details. .. _their notes on frame rates: https://trac.ffmpeg.org/wiki/Slideshow#Framerates - + Parameters ---------- *args, **kwargs From 08236aed74f17dbcf4de50a111b1fe00c19cf672 Mon Sep 17 00:00:00 2001 From: Husain Gadiwala <69296939+alphanoobie@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:40:42 +1030 Subject: [PATCH 224/228] Setting imshow(animated=True) still show does not show an image (#30052) --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> --- lib/matplotlib/axes/_base.py | 2 +- lib/matplotlib/tests/test_axes.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index f047fe1809aa..ecff24540690 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -3206,7 +3206,7 @@ def draw(self, renderer): if not self.get_figure(root=True).canvas.is_saving(): artists = [ a for a in artists - if not a.get_animated() or isinstance(a, mimage.AxesImage)] + if not a.get_animated()] artists = sorted(artists, key=attrgetter('zorder')) # rasterize artists with negative zorder diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index fe121e12c9f1..356a107feaf6 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -11,6 +11,7 @@ import re import sys from types import SimpleNamespace +import unittest.mock import dateutil.tz @@ -45,7 +46,6 @@ from matplotlib.testing.decorators import ( image_comparison, check_figures_equal, remove_ticks_and_titles) from matplotlib.testing._markers import needs_usetex - # Note: Some test cases are run twice: once normally and once with labeled data # These two must be defined in the same test function or need to have # different baseline images to prevent race conditions when pytest runs @@ -10018,3 +10018,20 @@ def test_pie_all_zeros(): fig, ax = plt.subplots() with pytest.raises(ValueError, match="All wedge sizes are zero"): ax.pie([0, 0], labels=["A", "B"]) + + +def test_animated_artists_not_drawn_by_default(): + fig, (ax1, ax2) = plt.subplots(ncols=2) + + imdata = np.random.random((20, 20)) + lndata = imdata[0] + + im = ax1.imshow(imdata, animated=True) + (ln,) = ax2.plot(lndata, animated=True) + + with (unittest.mock.patch.object(im, "draw", name="im.draw") as mocked_im_draw, + unittest.mock.patch.object(ln, "draw", name="ln.draw") as mocked_ln_draw): + fig.draw_without_rendering() + + mocked_im_draw.assert_not_called() + mocked_ln_draw.assert_not_called() From 2fc3ed6cae052ec340cefcec8eaf1b122c93bab8 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 11 Dec 2025 10:03:47 +0100 Subject: [PATCH 225/228] Improve error messages for mismatched s arg to scatter(). - Display array sizes in case of size mismatch. - Display a clearer, distinct error message when `s` is not real. --- lib/matplotlib/axes/_axes.py | 12 ++++++------ lib/matplotlib/tests/test_axes.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 7fa360220664..74f7bb39bcdf 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5309,12 +5309,12 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, s = (20 if mpl.rcParams['_internal.classic_mode'] else mpl.rcParams['lines.markersize'] ** 2.0) s = np.ma.ravel(s) - if (len(s) not in (1, x.size) or - (not np.issubdtype(s.dtype, np.floating) and - not np.issubdtype(s.dtype, np.integer))): - raise ValueError( - "s must be a scalar, " - "or float array-like with the same size as x and y") + if not (np.issubdtype(s.dtype, np.floating) + or np.issubdtype(s.dtype, np.integer)): + raise ValueError(f"s must be float, but has type {s.dtype}") + if len(s) not in (1, x.size): + raise ValueError(f"s (size {len(s)}) cannot be broadcast " + f"to match x and y (size {len(x)})") # get the original edgecolor the user passed before we normalize orig_edgecolor = edgecolors diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 356a107feaf6..45221bfd390d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2958,11 +2958,11 @@ def test_scatter_unfillable(self): def test_scatter_size_arg_size(self): x = np.arange(4) - with pytest.raises(ValueError, match='same size as x and y'): + with pytest.raises(ValueError, match='cannot be broadcast to match x and y'): plt.scatter(x, x, x[1:]) - with pytest.raises(ValueError, match='same size as x and y'): + with pytest.raises(ValueError, match='cannot be broadcast to match x and y'): plt.scatter(x[1:], x[1:], x) - with pytest.raises(ValueError, match='float array-like'): + with pytest.raises(ValueError, match='must be float'): plt.scatter(x, x, 'foo') def test_scatter_edgecolor_RGB(self): From 9071543f4f7a808a3c8d6cca3172ce72f05920c9 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Thu, 11 Dec 2025 15:11:55 -0600 Subject: [PATCH 226/228] Update release docs for new publish workflow, remove old publish step --- .github/workflows/cibuildwheel.yml | 29 ----------------------------- doc/devel/release_guide.rst | 15 +++++++++++++-- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 44b2beec38b9..46a7c68f062e 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -192,32 +192,3 @@ jobs: name: cibw-wheels-${{ runner.os }}-${{ matrix.cibw_archs }} path: ./wheelhouse/*.whl if-no-files-found: error - - publish: - if: github.repository == 'matplotlib/matplotlib' && github.event_name == 'push' && github.ref_type == 'tag' - name: Upload release to PyPI - needs: [build_sdist, build_wheels] - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - attestations: write - contents: read - steps: - - name: Download packages - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - pattern: cibw-* - path: dist - merge-multiple: true - - - name: Print out packages - run: ls dist - - - name: Generate artifact attestation for sdist and wheel - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-path: dist/matplotlib-* - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/doc/devel/release_guide.rst b/doc/devel/release_guide.rst index d1b5c963a295..886b60240415 100644 --- a/doc/devel/release_guide.rst +++ b/doc/devel/release_guide.rst @@ -383,12 +383,23 @@ Building binaries ================= We distribute macOS, Windows, and many Linux wheels as well as a source tarball via -PyPI. Most builders should trigger automatically once the tag is pushed to GitHub: +PyPI. * Windows, macOS and manylinux wheels are built on GitHub Actions. Builds are triggered - by the GitHub Action defined in :file:`.github/workflows/cibuildwheel.yml`, and wheels + by the GitHub Action defined in a separate + `release repository `__, and wheels will be available as artifacts of the build. Both a source tarball and the wheels will be automatically uploaded to PyPI once all of them have been built. +* To trigger the build for the ``matplotlib-release`` repository: + + * If not already created, create a release branch for the meso version (e.g. ``v3.10.x``) + * Edit the ``SOURCE_REF_TO_BUILD`` environment variable at the top of + `wheels.yml `__ + on the release branch to point to the release tag. + * Run the workflow from the release branch, with "pypi" selected for the pypi environment + using the `Workflow Dispatch trigger `__ + * This will run cibuildwheel and upload to PyPI using the Trusted Publishers GitHub Action. + * The auto-tick bot should open a pull request into the `conda-forge feedstock `__. Review and merge (if you have the power to). From 4edc215ed5b595535ee488cbd4cf039152a4b86b Mon Sep 17 00:00:00 2001 From: Niklas Mertsch <49114330+NMertsch@users.noreply.github.com> Date: Fri, 12 Dec 2025 01:09:43 +0100 Subject: [PATCH 227/228] Add type annotation for LocationEvent.modifiers (#30841) --- lib/matplotlib/backend_bases.py | 2 +- lib/matplotlib/backend_bases.pyi | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 137611ab6703..e7edb0e7448f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1262,7 +1262,7 @@ class LocationEvent(Event): xdata, ydata : float or None Data coordinates of the mouse within *inaxes*, or *None* if the mouse is not over an Axes. - modifiers : frozenset + modifiers : frozenset[str] The keyboard modifiers currently being pressed (except for KeyEvent). """ diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 7a2b28262249..a69b36093839 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -237,6 +237,7 @@ class LocationEvent(Event): inaxes: Axes | None xdata: float | None ydata: float | None + modifiers: frozenset[str] def __init__( self, name: str, From ba8d4555ece3a1adafd37df18b12e1d6fd4ee45a Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:13:11 +0000 Subject: [PATCH 228/228] Add pixi and uv install options to bug template --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a19b6d2346e3..f3595d2b7865 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -82,6 +82,8 @@ body: options: - pip - conda + - pixi + - uv - Linux package manager - from source (.tar.gz) - git checkout