`
- 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_dviread.py b/lib/matplotlib/tests/test_dviread.py
index 7b7ff151be18..33fe9bb150d2 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, _has_tex_package
import pytest
@@ -62,16 +63,85 @@ 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
+
+
+@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
diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py
index c5890a2963b3..e666a3b99f7f 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():
@@ -1690,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():
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)
diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py
index 00c223c59362..9b598fbf7193 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
@@ -37,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"""
@@ -453,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]]
@@ -1168,21 +1204,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."""
@@ -1209,8 +1230,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)
@@ -1484,9 +1504,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
@@ -1741,8 +1759,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/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 61fae63a298e..5f83b25b90a5 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -1667,3 +1667,86 @@ 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_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)
+ 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
diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py
index 55f7c33cb52e..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():
@@ -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()
diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py
index 253bfa4fa093..745db5f767b2 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()
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 ' 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()
+ renderer_cache.clear()
+
+ 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/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py
index b4db34db5a91..59a765107d7b 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
@@ -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/tests/test_cm_stubs.py b/lib/matplotlib/tests/test_typing.py
similarity index 60%
rename from lib/matplotlib/tests/test_cm_stubs.py
rename to lib/matplotlib/tests/test_typing.py
index 2305c91a5301..c9fc8e5b162f 100644
--- a/lib/matplotlib/tests/test_cm_stubs.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/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
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,
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()
diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py
index 350113c56170..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.
@@ -1418,7 +1441,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 +1449,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 +2213,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 +2439,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..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: ...
@@ -189,9 +190,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 +254,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]
diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py
index cedeb1ad5d5e..d2e12c6e08d9 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -170,3 +170,405 @@
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.depthshade",
+ "axes3d.depthshade_minalpha",
+ "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.enable_last_resort",
+ "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",
+ "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",
+ "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.linewidth",
+ "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",
+ "grid.major",
+ "grid.minor",
+ "hatch",
+ "hist",
+ "image",
+ "keymap",
+ "legend",
+ "lines",
+ "macosx",
+ "markers",
+ "mathtext",
+ "patch",
+ "path",
+ "pcolor",
+ "pcolormesh",
+ "pdf",
+ "pgf",
+ "polaraxes",
+ "ps",
+ "ps.distiller",
+ "savefig",
+ "scatter",
+ "svg",
+ "text",
+ "text.latex",
+ "tk",
+ "webagg",
+ "xaxis",
+ "xtick",
+ "xtick.major",
+ "xtick.minor",
+ "yaxis",
+ "ytick",
+ "ytick.major",
+ "ytick.minor",
+]
diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py
index 034a9b4db7a0..0410c4f03092 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
@@ -150,6 +151,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):
"""
@@ -2172,6 +2177,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).
@@ -2643,7 +2650,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 +2659,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 +2669,7 @@ def connect_default_events(self):
def _press(self, event):
"""Button press event handler."""
- self._set_cursor(True)
+ self._set_span_cursor(enabled=True)
if self._interactive and self._selection_artist.get_visible():
self._set_active_handle(event)
else:
@@ -2712,7 +2719,7 @@ def direction(self, direction):
def _release(self, event):
"""Button release event handler."""
- self._set_cursor(False)
+ self._set_span_cursor(enabled=False)
if not self._interactive:
self._selection_artist.set_visible(False)
@@ -2754,7 +2761,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(enabled=e_dist <= self.grab_range)
def _onmove(self, event):
"""Motion notify event handler."""
@@ -3145,6 +3152,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent)
"""
+class _RectangleSelectorAction(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):
@@ -3278,10 +3292,23 @@ def _press(self, event):
self._rotation_on_press = self._rotation
self._set_aspect_ratio_correction()
+ match self._get_action():
+ case _RectangleSelectorAction.ROTATE:
+ # TODO: set to a rotate cursor if possible?
+ pass
+ case _RectangleSelectorAction.MOVE:
+ self._set_cursor(backend_tools.cursors.MOVE)
+ case _RectangleSelectorAction.RESIZE:
+ # TODO: set to a resize cursor if possible?
+ pass
+ case _RectangleSelectorAction.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 +3352,20 @@ def _release(self, event):
self.update()
self._active_handle = None
self._extents_on_press = None
-
return False
+ def _get_action(self):
+ state = self._state
+ if 'rotate' in state and self._active_handle in self._corner_order:
+ return _RectangleSelectorAction.ROTATE
+ elif self._active_handle == 'C':
+ return _RectangleSelectorAction.MOVE
+ elif self._active_handle:
+ return _RectangleSelectorAction.RESIZE
+
+ return _RectangleSelectorAction.CREATE
+
+
def _onmove(self, event):
"""
Motion notify event handler.
@@ -3342,12 +3380,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 == _RectangleSelectorAction.RESIZE:
inv_tr = self._get_rotation_transform().inverted()
xdata, ydata = inv_tr.transform([xdata, ydata])
eventpress.xdata, eventpress.ydata = inv_tr.transform(
@@ -3367,7 +3403,7 @@ def _onmove(self, event):
x0, x1, y0, y1 = self._extents_on_press
# rotate an existing shape
- if rotate:
+ if action == _RectangleSelectorAction.ROTATE:
# calculate angle abc
a = (eventpress.xdata, eventpress.ydata)
b = self.center
@@ -3376,7 +3412,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 == _RectangleSelectorAction.RESIZE:
size_on_press = [x1 - x0, y1 - y0]
center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2)
@@ -3427,7 +3463,7 @@ def _onmove(self, event):
sign = np.sign(xdata - x0)
x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction
- elif move:
+ elif action == _RectangleSelectorAction.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..f74b9c7f32bf 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
@@ -335,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"]: ...
@@ -398,6 +401,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,
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
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index c56e4c6b7039..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'):
@@ -2890,8 +2884,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/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 e6d11f793b46..546659d05177 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
@@ -284,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)
@@ -2689,3 +2683,100 @@ 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))
+
+
+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')
+
+
+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})"
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$',
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",
]
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
diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt
index e386924a9b67..dd1dbf3f29fd 100644
--- a/requirements/testing/all.txt
+++ b/requirements/testing/all.txt
@@ -1,12 +1,12 @@
# pip requirements for all the CI builds
-black<24
+black<26
certifi
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
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;
}
}
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/_macosx.m b/src/_macosx.m
index 1372157bc80d..76d7e94de60f 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; // forward declaration, needed in destroy()
+
typedef struct {
PyObject_HEAD
Window* window;
@@ -580,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; }
@@ -686,6 +698,25 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name)
{
[self->window close];
self->window = NULL;
+
+ // 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,
+ 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;
}
diff --git a/src/_path.h b/src/_path.h
index c03703776760..226d60231682 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"
@@ -26,6 +26,8 @@ struct XY
double x;
double y;
+ XY() : x(0), y(0) {}
+
XY(double x_, double y_) : x(x_), y(y_)
{
}
@@ -43,7 +45,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;
@@ -311,43 +314,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)
@@ -366,7 +365,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);
}
}
@@ -389,7 +388,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));
@@ -524,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),
+ };
}
};
@@ -539,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;
}
};
@@ -551,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;
}
};
@@ -565,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,
+ };
}
};
@@ -580,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;
}
};
@@ -592,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;
}
};
}
@@ -609,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);
@@ -659,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);
@@ -691,12 +679,14 @@ 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);
+
+ return results;
}
template
@@ -956,7 +946,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 +970,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) {
@@ -1051,15 +1041,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 +1093,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 +1122,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 +1163,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 +1200,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..802189c428d3 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);
}
@@ -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);
}
@@ -252,16 +250,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();
}
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
diff --git a/tools/boilerplate.py b/tools/boilerplate.py
index 11ec15ac1c44..0a1a26c7cb76 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
@@ -256,6 +263,7 @@ def boilerplate_gen():
'pcolormesh',
'phase_spectrum',
'pie',
+ 'pie_label',
'plot',
'psd',
'quiver',
diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py
index 59d6fce55162..07b67a3e04ee 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.7": "17298696",
+ "v3.10.6": "16999430",
"v3.10.5": "16644850",
"v3.10.3": "15375714",
"v3.10.1": "14940554",
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",