`
- 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..95568d237b91 100644
--- a/lib/matplotlib/tests/test_figure.py
+++ b/lib/matplotlib/tests/test_figure.py
@@ -25,8 +25,9 @@
import matplotlib.dates as mdates
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['figure_align_labels'], extensions=['png', 'svg'],
- tol=0 if platform.machine() == 'x86_64' else 0.01)
+ tol=0.1 if platform.machine() == 'x86_64' else 0.1)
def test_align_labels():
fig = plt.figure(layout='tight')
gs = gridspec.GridSpec(3, 3)
@@ -66,9 +67,10 @@ def test_align_labels():
fig.align_labels()
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['figure_align_titles_tight.png',
'figure_align_titles_constrained.png'],
- tol=0 if platform.machine() == 'x86_64' else 0.022,
+ tol=0.3 if platform.machine() == 'x86_64' else 0.04,
style='mpl20')
def test_align_titles():
for layout in ['tight', 'constrained']:
@@ -147,8 +149,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():
@@ -322,7 +322,8 @@ def test_add_subplot_invalid():
fig.add_subplot(ax)
-@image_comparison(['figure_suptitle.png'])
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['figure_suptitle.png'], tol=0.02)
def test_suptitle():
fig, _ = plt.subplots()
fig.suptitle('hello', color='r')
@@ -1398,8 +1399,9 @@ def test_subfigure_ss():
fig.suptitle('Figure suptitle', fontsize='xx-large')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['test_subfigure_double.png'], style='mpl20',
- savefig_kwarg={'facecolor': 'teal'})
+ savefig_kwarg={'facecolor': 'teal'}, tol=0.02)
def test_subfigure_double():
# test assigning the subfigure via subplotspec
np.random.seed(19680801)
@@ -1690,6 +1692,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_font_manager.py b/lib/matplotlib/tests/test_font_manager.py
index 97ee8672b1d4..24421b8e30b3 100644
--- a/lib/matplotlib/tests/test_font_manager.py
+++ b/lib/matplotlib/tests/test_font_manager.py
@@ -15,7 +15,8 @@
from matplotlib.font_manager import (
findfont, findSystemFonts, FontEntry, FontProperties, fontManager,
json_dump, json_load, get_font, is_opentype_cff_font,
- MSUserFontDirectories, _get_fontconfig_fonts, ttfFontProperty)
+ MSUserFontDirectories, ttfFontProperty,
+ _get_fontconfig_fonts, _normalize_weight)
from matplotlib import cbook, ft2font, pyplot as plt, rc_context, figure as mfigure
from matplotlib.testing import subprocess_run_helper, subprocess_run_for_testing
@@ -407,3 +408,29 @@ def test_fontproperties_init_deprecation():
# Since this case is not covered by docs, I've refrained from jumping
# extra hoops to detect this possible API misuse.
FontProperties(family="serif-24:style=oblique:weight=bold")
+
+
+def test_normalize_weights():
+ assert _normalize_weight(300) == 300 # passthrough
+ assert _normalize_weight('ultralight') == 100
+ assert _normalize_weight('light') == 200
+ assert _normalize_weight('normal') == 400
+ assert _normalize_weight('regular') == 400
+ assert _normalize_weight('book') == 400
+ assert _normalize_weight('medium') == 500
+ assert _normalize_weight('roman') == 500
+ assert _normalize_weight('semibold') == 600
+ assert _normalize_weight('demibold') == 600
+ assert _normalize_weight('demi') == 600
+ assert _normalize_weight('bold') == 700
+ assert _normalize_weight('heavy') == 800
+ assert _normalize_weight('extra bold') == 800
+ assert _normalize_weight('black') == 900
+ with pytest.raises(KeyError):
+ _normalize_weight('invalid')
+
+
+def test_font_match_warning(caplog):
+ findfont(FontProperties(family=["DejaVu Sans"], weight=750))
+ logs = [rec.message for rec in caplog.records]
+ assert 'findfont: Failed to find font weight 750, now using 700.' in logs
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..da7a198a2a94 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
@@ -1586,8 +1604,8 @@ def test_large_image(fig_test, fig_ref, dim, size, msg, origin):
'accurately displayed.'):
fig_test.canvas.draw()
- array = np.zeros((1, 2))
- array[:, 1] = 1
+ array = np.zeros((1, size // 2 + 1))
+ array[:, array.size // 2:] = 1
if dim == 'col':
array = array.T
im = ax_ref.imshow(array, vmin=0, vmax=1, aspect='auto',
@@ -1645,19 +1663,33 @@ def test__resample_valid_output():
[(np.array([[0.1, 0.3, 0.2]]), mimage.NEAREST,
np.array([[0.1, 0.1, 0.1, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.2]])),
(np.array([[0.1, 0.3, 0.2]]), mimage.BILINEAR,
- np.array([[0.1, 0.1, 0.15078125, 0.21096191, 0.27033691,
- 0.28476562, 0.2546875, 0.22460938, 0.20002441, 0.20002441]])),
+ np.array([[0.1, 0.1, 0.15, 0.21, 0.27, 0.285, 0.255, 0.225, 0.2, 0.2]])),
+ (np.array([[0.1, 0.9]]), mimage.BILINEAR,
+ np.array([[0.1, 0.1, 0.1, 0.1, 0.1, 0.14, 0.22, 0.3, 0.38, 0.46,
+ 0.54, 0.62, 0.7, 0.78, 0.86, 0.9, 0.9, 0.9, 0.9, 0.9]])),
+ (np.array([[0.1, 0.1]]), mimage.BILINEAR, np.full((1, 10), 0.1)),
+ # Test at the subpixel level
+ (np.array([[0.1, 0.9]]), mimage.NEAREST,
+ np.concatenate([np.full(512, 0.1), np.full(512, 0.9)]).reshape(1, -1)),
+ (np.array([[0.1, 0.9]]), mimage.BILINEAR,
+ np.concatenate([np.full(256, 0.1),
+ np.linspace(0.5, 256, 512).astype(int) / 256 * 0.8 + 0.1,
+ np.full(256, 0.9)]).reshape(1, -1)),
]
)
def test_resample_nonaffine(data, interpolation, expected):
- # Test that equivalent affine and nonaffine transforms resample the same
+ # Test that both affine and nonaffine transforms resample to the correct answer
+
+ # If the array is constant, the tolerance can be tight
+ # Otherwise, the tolerance is limited by the subpixel approach in the agg backend
+ atol = 0 if np.all(data == data.ravel()[0]) else 2e-3
# Create a simple affine transform for scaling the input array
affine_transform = Affine2D().scale(sx=expected.shape[1] / data.shape[1], sy=1)
affine_result = np.empty_like(expected)
mimage.resample(data, affine_result, affine_transform, interpolation=interpolation)
- assert_allclose(affine_result, expected)
+ assert_allclose(affine_result, expected, atol=atol)
# Create a nonaffine version of the same transform
# by compositing with a nonaffine identity transform
@@ -1666,13 +1698,13 @@ class NonAffineIdentityTransform(Transform):
output_dims = 2
def inverted(self):
- return self
+ return self
nonaffine_transform = NonAffineIdentityTransform() + affine_transform
nonaffine_result = np.empty_like(expected)
mimage.resample(data, nonaffine_result, nonaffine_transform,
interpolation=interpolation)
- assert_allclose(nonaffine_result, expected, atol=5e-3)
+ assert_allclose(nonaffine_result, expected, atol=atol)
def test_axesimage_get_shape():
@@ -1741,8 +1773,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 9b100037cc41..5f83b25b90a5 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -1068,6 +1068,201 @@ def test_legend_labelcolor_rcparam_markerfacecolor_short():
assert mpl.colors.same_color(text.get_color(), color)
+def assert_last_legend_patch_color(histogram, leg, expected_color,
+ facecolor=False, edgecolor=False):
+ """
+ Check that histogram color, legend handle color, and legend label color all
+ match the expected input. Provide facecolor and edgecolor flags to clarify
+ which feature to match.
+ """
+ label_color = leg.texts[-1].get_color()
+ patch = leg.get_patches()[-1]
+ histogram = histogram[-1][0]
+ assert mpl.colors.same_color(label_color, expected_color)
+ if facecolor:
+ assert mpl.colors.same_color(label_color, patch.get_facecolor())
+ assert mpl.colors.same_color(label_color, histogram.get_facecolor())
+ if edgecolor:
+ assert mpl.colors.same_color(label_color, patch.get_edgecolor())
+ assert mpl.colors.same_color(label_color, histogram.get_edgecolor())
+
+
+def test_legend_labelcolor_linecolor_histograms():
+ x = np.arange(10)
+
+ # testing c kwarg for bar, step, and stepfilled histograms
+ fig, ax = plt.subplots()
+ h = ax.hist(x, histtype='bar', color='r', label="red bar hist with a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+ h = ax.hist(x, histtype='step', color='g', label="green step hist, green label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'g', edgecolor=True)
+
+ h = ax.hist(x, histtype='stepfilled', color='b',
+ label="blue stepfilled hist with a blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'b', facecolor=True)
+
+ # testing c, fc, and ec combinations for bar histograms
+ h = ax.hist(x, histtype='bar', color='r', ec='b',
+ label="red bar hist with blue edges and a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+ h = ax.hist(x, histtype='bar', fc='r', ec='b',
+ label="red bar hist with blue edges and a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+ h = ax.hist(x, histtype='bar', fc='none', ec='b',
+ label="unfilled blue bar hist with a blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'b', edgecolor=True)
+
+ # testing c, and ec combinations for step histograms
+ h = ax.hist(x, histtype='step', color='r', ec='b',
+ label="blue step hist with a blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'b', edgecolor=True)
+
+ h = ax.hist(x, histtype='step', ec='b',
+ label="blue step hist with a blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'b', edgecolor=True)
+
+ # testing c, fc, and ec combinations for stepfilled histograms
+ h = ax.hist(x, histtype='stepfilled', color='r', ec='b',
+ label="red stepfilled hist, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+ h = ax.hist(x, histtype='stepfilled', fc='r', ec='b',
+ label="red stepfilled hist, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+ h = ax.hist(x, histtype='stepfilled', fc='none', ec='b',
+ label="unfilled blue stepfilled hist, blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'b', edgecolor=True)
+
+ h = ax.hist(x, histtype='stepfilled', fc='r', ec='none',
+ label="edgeless red stepfilled hist with a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_patch_color(h, leg, 'r', facecolor=True)
+
+
+def assert_last_legend_linemarker_color(line_marker, leg, expected_color, color=False,
+ facecolor=False, edgecolor=False):
+ """
+ Check that line marker color, legend handle color, and legend label color all
+ match the expected input. Provide color, facecolor and edgecolor flags to clarify
+ which feature to match.
+ """
+ label_color = leg.texts[-1].get_color()
+ leg_marker = leg.get_lines()[-1]
+ assert mpl.colors.same_color(label_color, expected_color)
+ if color:
+ assert mpl.colors.same_color(label_color, leg_marker.get_color())
+ assert mpl.colors.same_color(label_color, line_marker.get_color())
+ if facecolor:
+ assert mpl.colors.same_color(label_color, leg_marker.get_markerfacecolor())
+ assert mpl.colors.same_color(label_color, line_marker.get_markerfacecolor())
+ if edgecolor:
+ assert mpl.colors.same_color(label_color, leg_marker.get_markeredgecolor())
+ assert mpl.colors.same_color(label_color, line_marker.get_markeredgecolor())
+
+
+def test_legend_labelcolor_linecolor_plot():
+ x = np.arange(5)
+
+ # testing line plot
+ fig, ax = plt.subplots()
+ l, = ax.plot(x, c='r', label="red line with a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'r', color=True)
+
+ # testing c, fc, and ec combinations for maker plots
+ l, = ax.plot(x, 'o', c='r', label="red circles with a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'r', color=True)
+
+ l, = ax.plot(x, 'o', c='r', mec='b', label="red circles, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'r', color=True)
+
+ l, = ax.plot(x, 'o', mfc='r', mec='b', label="red circles, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'r', facecolor=True)
+
+ # 'none' cases
+ l, = ax.plot(x, 'o', mfc='none', mec='b',
+ label="blue unfilled circles, blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'b', edgecolor=True)
+
+ l, = ax.plot(x, 'o', mfc='r', mec='none', label="red edgeless circles, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'r', facecolor=True)
+
+ l, = ax.plot(x, 'o', c='none', mec='none',
+ label="black label despite invisible circles for dummy entries")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_linemarker_color(l, leg, 'k')
+
+
+def assert_last_legend_scattermarker_color(scatter_marker, leg, expected_color,
+ facecolor=False, edgecolor=False):
+ """
+ Check that scatter marker color, legend handle color, and legend label color all
+ match the expected input. Provide facecolor and edgecolor flags to clarify
+ which feature to match.
+ """
+ label_color = leg.texts[-1].get_color()
+ leg_handle = leg.legend_handles[-1]
+ assert mpl.colors.same_color(label_color, expected_color)
+ if facecolor:
+ assert mpl.colors.same_color(label_color, leg_handle.get_facecolor())
+ assert mpl.colors.same_color(label_color, scatter_marker.get_facecolor())
+ if edgecolor:
+ assert mpl.colors.same_color(label_color, leg_handle.get_edgecolor())
+ assert mpl.colors.same_color(label_color, scatter_marker.get_edgecolor())
+
+
+def test_legend_labelcolor_linecolor_scatter():
+ x = np.arange(5)
+
+ # testing c, fc, and ec combinations for scatter plots
+ fig, ax = plt.subplots()
+ s = ax.scatter(x, x, c='r', label="red circles with a red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'r', facecolor=True)
+
+ s = ax.scatter(x, x, c='r', ec='b', label="red circles, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'r', facecolor=True)
+
+ s = ax.scatter(x, x, fc='r', ec='b', label="red circles, blue edges, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'r', facecolor=True)
+
+ # 'none' cases
+ s = ax.scatter(x, x, fc='none', ec='b', label="blue unfilled circles, blue label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'b', edgecolor=True)
+
+ s = ax.scatter(x, x, fc='r', ec='none', label="red edgeless circles, red label")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'r', facecolor=True)
+
+ s = ax.scatter(x, x, c='none', ec='none',
+ label="black label despite invisible circles for dummy entries")
+ leg = ax.legend(labelcolor='linecolor')
+ assert_last_legend_scattermarker_color(s, leg, 'k')
+
+
@pytest.mark.filterwarnings("ignore:No artists with labels found to put in legend")
def test_get_set_draggable():
legend = plt.legend()
@@ -1472,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_lines.py b/lib/matplotlib/tests/test_lines.py
index fe92547c5963..8bf6fea2cdf7 100644
--- a/lib/matplotlib/tests/test_lines.py
+++ b/lib/matplotlib/tests/test_lines.py
@@ -33,7 +33,7 @@ def test_segment_hits():
# Runtimes on a loaded system are inherently flaky. Not so much that a rerun
# won't help, hopefully.
-@pytest.mark.flaky(reruns=3)
+@pytest.mark.flaky(reruns=5)
def test_invisible_Line_rendering():
"""
GitHub issue #1256 identified a bug in Line.draw method
diff --git a/lib/matplotlib/tests/test_mlab.py b/lib/matplotlib/tests/test_mlab.py
index 3b0d2529b5f1..109a6d542450 100644
--- a/lib/matplotlib/tests/test_mlab.py
+++ b/lib/matplotlib/tests/test_mlab.py
@@ -1,3 +1,5 @@
+import sys
+
from numpy.testing import (assert_allclose, assert_almost_equal,
assert_array_equal, assert_array_almost_equal_nulp)
import numpy as np
@@ -429,7 +431,16 @@ def test_spectral_helper_psd(self, mode, case):
assert spec.shape[0] == freqs.shape[0]
assert spec.shape[1] == getattr(self, f"t_{case}").shape[0]
- def test_csd(self):
+ @pytest.mark.parametrize('bitsize', [
+ pytest.param(None, id='default'),
+ pytest.param(32,
+ marks=pytest.mark.skipif(sys.maxsize <= 2**32,
+ reason='System is already 32-bit'),
+ id='32-bit')
+ ])
+ def test_csd(self, bitsize, monkeypatch):
+ if bitsize is not None:
+ monkeypatch.setattr(sys, 'maxsize', 2**bitsize)
freqs = self.freqs_density
spec, fsp = mlab.csd(x=self.y, y=self.y+1,
NFFT=self.NFFT_density,
diff --git a/lib/matplotlib/tests/test_multivariate_colormaps.py b/lib/matplotlib/tests/test_multivariate_colormaps.py
index 81a2e6adeb35..592058212a24 100644
--- a/lib/matplotlib/tests/test_multivariate_colormaps.py
+++ b/lib/matplotlib/tests/test_multivariate_colormaps.py
@@ -212,9 +212,26 @@ def test_multivar_resample():
def test_bivar_cmap_call_tuple():
cmap = mpl.bivar_colormaps['BiOrangeBlue']
- assert_allclose(cmap((1.0, 1.0)), (1, 1, 1, 1), atol=0.01)
- assert_allclose(cmap((0.0, 0.0)), (0, 0, 0, 1), atol=0.1)
- assert_allclose(cmap((0.0, 0.0), alpha=0.1), (0, 0, 0, 0.1), atol=0.1)
+ assert_allclose(cmap((1.0, 1.0)), (1, 1, 1, 1))
+ assert_allclose(cmap((0.0, 0.0)), (0, 0, 0, 1))
+ assert_allclose(cmap((0.2, 0.8)), (0.2, 0.5, 0.8, 1))
+ assert_allclose(cmap((0.0, 0.0), alpha=0.1), (0, 0, 0, 0.1))
+
+
+def test_bivar_cmap_lut_smooth():
+ cmap = mpl.bivar_colormaps['BiOrangeBlue']
+
+ assert_allclose(cmap.lut[:, 0, 0], np.linspace(0, 1, 256))
+ assert_allclose(cmap.lut[:, 255, 0], np.linspace(0, 1, 256))
+ assert_allclose(cmap.lut[:, 0, 1], np.linspace(0, 0.5, 256))
+ assert_allclose(cmap.lut[:, 153, 1], np.linspace(0.3, 0.8, 256))
+ assert_allclose(cmap.lut[:, 255, 1], np.linspace(0.5, 1, 256))
+
+ assert_allclose(cmap.lut[0, :, 1], np.linspace(0, 0.5, 256))
+ assert_allclose(cmap.lut[102, :, 1], np.linspace(0.2, 0.7, 256))
+ assert_allclose(cmap.lut[255, :, 1], np.linspace(0.5, 1, 256))
+ assert_allclose(cmap.lut[0, :, 2], np.linspace(0, 1, 256))
+ assert_allclose(cmap.lut[255, :, 2], np.linspace(0, 1, 256))
def test_bivar_cmap_call():
@@ -312,20 +329,36 @@ def test_bivar_cmap_call():
match="only implemented for use with with floats"):
cs = cmap([(0, 5, 9, 0, 0, 9), (0, 0, 0, 5, 11, 11)])
- # test origin
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].with_extremes(origin=(0.5, 0.5))
- assert_allclose(cmap[0](0.5),
- (0.50244140625, 0.5024222412109375, 0.50244140625, 1))
- assert_allclose(cmap[1](0.5),
- (0.50244140625, 0.5024222412109375, 0.50244140625, 1))
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].with_extremes(origin=(1, 1))
- assert_allclose(cmap[0](1.),
- (0.99853515625, 0.9985467529296875, 0.99853515625, 1.0))
- assert_allclose(cmap[1](1.),
- (0.99853515625, 0.9985467529296875, 0.99853515625, 1.0))
+
+def test_bivar_cmap_1d_origin():
+ """
+ Test getting 1D colormaps with different origins
+ """
+ cmap0 = mpl.bivar_colormaps['BiOrangeBlue']
+ assert_allclose(cmap0[0].colors[:, 0], np.linspace(0, 1, 256))
+ assert_allclose(cmap0[0].colors[:, 1], np.linspace(0, 0.5, 256))
+ assert_allclose(cmap0[0].colors[:, 2], 0)
+ assert_allclose(cmap0[1].colors[:, 0], 0)
+ assert_allclose(cmap0[1].colors[:, 1], np.linspace(0, 0.5, 256))
+ assert_allclose(cmap0[1].colors[:, 2], np.linspace(0, 1, 256))
+
+ cmap1 = cmap0.with_extremes(origin=(0, 1))
+ assert_allclose(cmap1[0].colors[:, 0], np.linspace(0, 1, 256))
+ assert_allclose(cmap1[0].colors[:, 1], np.linspace(0.5, 1, 256))
+ assert_allclose(cmap1[0].colors[:, 2], 1)
+ assert_allclose(cmap1[1].colors, cmap0[1].colors)
+
+ cmap2 = cmap0.with_extremes(origin=(0.2, 0.4))
+ assert_allclose(cmap2[0].colors[:, 0], np.linspace(0, 1, 256))
+ assert_allclose(cmap2[0].colors[:, 1], np.linspace(0.2, 0.7, 256))
+ assert_allclose(cmap2[0].colors[:, 2], 0.4)
+ assert_allclose(cmap2[1].colors[:, 0], 0.2)
+ assert_allclose(cmap2[1].colors[:, 1], np.linspace(0.1, 0.6, 256))
+ assert_allclose(cmap2[1].colors[:, 2], np.linspace(0, 1, 256))
+
with pytest.raises(KeyError,
match="only 0 or 1 are valid keys"):
- cs = cmap[2]
+ cs = cmap0[2]
def test_bivar_getitem():
@@ -433,22 +466,18 @@ def test_bivar_cmap_from_image():
def test_bivar_resample():
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].resampled((2, 2))
- assert_allclose(cmap((0.25, 0.25)), (0, 0, 0, 1), atol=1e-2)
-
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].resampled((-2, 2))
- assert_allclose(cmap((0.25, 0.25)), (1., 0.5, 0., 1.), atol=1e-2)
+ cmap = mpl.bivar_colormaps['BiOrangeBlue']
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].resampled((2, -2))
- assert_allclose(cmap((0.25, 0.25)), (0., 0.5, 1., 1.), atol=1e-2)
+ assert_allclose(cmap.resampled((2, 2))((0.25, 0.25)), (0, 0, 0, 1))
+ assert_allclose(cmap.resampled((-2, 2))((0.25, 0.25)), (1., 0.5, 0., 1.))
+ assert_allclose(cmap.resampled((2, -2))((0.25, 0.25)), (0., 0.5, 1., 1.))
+ assert_allclose(cmap.resampled((-2, -2))((0.25, 0.25)), (1, 1, 1, 1))
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].resampled((-2, -2))
- assert_allclose(cmap((0.25, 0.25)), (1, 1, 1, 1), atol=1e-2)
+ assert_allclose(cmap((0.8, 0.4)), (0.8, 0.6, 0.4, 1.))
+ assert_allclose(cmap.reversed()((1 - 0.8, 1 - 0.4)), (0.8, 0.6, 0.4, 1.))
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].reversed()
- assert_allclose(cmap((0.25, 0.25)), (0.748535, 0.748547, 0.748535, 1.), atol=1e-2)
- cmap = mpl.bivar_colormaps['BiOrangeBlue'].transposed()
- assert_allclose(cmap((0.25, 0.25)), (0.252441, 0.252422, 0.252441, 1.), atol=1e-2)
+ assert_allclose(cmap((0.6, 0.2)), (0.6, 0.4, 0.2, 1.))
+ assert_allclose(cmap.transposed()((0.2, 0.6)), (0.6, 0.4, 0.2, 1.))
with pytest.raises(ValueError, match="lutshape must be of length"):
cmap = cmap.resampled(4)
diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py
index bd353ffc719b..f126b1cbb466 100644
--- a/lib/matplotlib/tests/test_offsetbox.py
+++ b/lib/matplotlib/tests/test_offsetbox.py
@@ -470,3 +470,40 @@ def test_draggable_in_subfigure():
bbox = ann.get_window_extent()
MouseEvent("button_press_event", fig.canvas, bbox.x1+2, bbox.y1+2)._process()
assert not ann._draggable.got_artist
+
+
+def test_anchored_offsetbox_tuple_and_float_borderpad():
+ """
+ Test AnchoredOffsetbox correctly handles both float and tuple for borderpad.
+ """
+
+ fig, ax = plt.subplots()
+
+ # Case 1: Establish a baseline with float value
+ text_float = AnchoredText("float", loc='lower left', borderpad=5)
+ ax.add_artist(text_float)
+
+ # Case 2: Test that a symmetric tuple gives the exact same result.
+ text_tuple_equal = AnchoredText("tuple", loc='lower left', borderpad=(5, 5))
+ ax.add_artist(text_tuple_equal)
+
+ # Case 3: Test that an asymmetric tuple with different values works as expected.
+ text_tuple_asym = AnchoredText("tuple_asym", loc='lower left', borderpad=(10, 4))
+ ax.add_artist(text_tuple_asym)
+
+ # Draw the canvas to calculate final positions
+ fig.canvas.draw()
+
+ pos_float = text_float.get_window_extent()
+ pos_tuple_equal = text_tuple_equal.get_window_extent()
+ pos_tuple_asym = text_tuple_asym.get_window_extent()
+
+ # Assertion 1: Prove that borderpad=5 is identical to borderpad=(5, 5).
+ assert pos_tuple_equal.x0 == pos_float.x0
+ assert pos_tuple_equal.y0 == pos_float.y0
+
+ # Assertion 2: Prove that the asymmetric padding moved the box
+ # further from the origin than the baseline in the x-direction and less far
+ # in the y-direction.
+ assert pos_tuple_asym.x0 > pos_float.x0
+ assert pos_tuple_asym.y0 < pos_float.y0
diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py
index d69a9dad4337..ed608eebb6a7 100644
--- a/lib/matplotlib/tests/test_patches.py
+++ b/lib/matplotlib/tests/test_patches.py
@@ -941,7 +941,9 @@ def test_arc_in_collection(fig_test, fig_ref):
arc2 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20)
col = mcollections.PatchCollection(patches=[arc2], facecolors='none',
edgecolors='k')
- fig_ref.subplots().add_patch(arc1)
+ ax_ref = fig_ref.subplots()
+ ax_ref.add_patch(arc1)
+ ax_ref.autoscale_view()
fig_test.subplots().add_collection(col)
diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py
index 82fc60e186c7..1590990cdeb0 100644
--- a/lib/matplotlib/tests/test_pickle.py
+++ b/lib/matplotlib/tests/test_pickle.py
@@ -150,15 +150,7 @@ def test_pickle_load_from_subprocess(fig_test, fig_ref, tmp_path):
proc = subprocess_run_helper(
_pickle_load_subprocess,
timeout=60,
- extra_env={
- "PICKLE_FILE_PATH": str(fp),
- "MPLBACKEND": "Agg",
- # subprocess_run_helper will set SOURCE_DATE_EPOCH=0, so for a dirty tree,
- # the version will have the date 19700101. As we aren't trying to test the
- # version compatibility warning, force setuptools-scm to use the same
- # version as us.
- "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MATPLOTLIB": mpl.__version__,
- },
+ extra_env={"PICKLE_FILE_PATH": str(fp), "MPLBACKEND": "Agg"},
)
loaded_fig = pickle.loads(ast.literal_eval(proc.stdout))
diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py
index 4f9e63380490..4cbb099e3293 100644
--- a/lib/matplotlib/tests/test_polar.py
+++ b/lib/matplotlib/tests/test_polar.py
@@ -214,7 +214,8 @@ def test_polar_theta_position():
ax.set_theta_direction('clockwise')
-@image_comparison(['polar_rlabel_position.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['polar_rlabel_position.png'], style='default', tol=0.07)
def test_polar_rlabel_position():
fig = plt.figure()
ax = fig.add_subplot(projection='polar')
@@ -229,7 +230,8 @@ def test_polar_title_position():
ax.set_title('foo')
-@image_comparison(['polar_theta_wedge.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['polar_theta_wedge.png'], style='default', tol=0.2)
def test_polar_theta_limits():
r = np.arange(0, 3.0, 0.01)
theta = 2*np.pi*r
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_rcparams.py b/lib/matplotlib/tests/test_rcparams.py
index 2235f98b720f..eb9d3bc8866b 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -13,6 +13,7 @@
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import numpy as np
+from matplotlib import rcsetup
from matplotlib.rcsetup import (
validate_bool,
validate_color,
@@ -672,3 +673,21 @@ def test_rc_aliases(group, option, alias, value):
rcParams_key = f"{group}.{option}"
assert mpl.rcParams[rcParams_key] == value
+
+
+def test_all_params_defined_as_code():
+ assert set(p.name for p in rcsetup._params) == set(mpl.rcParams.keys())
+
+
+def test_validators_defined_as_code():
+ for param in rcsetup._params:
+ validator = rcsetup._convert_validator_spec(param.name, param.validator)
+ assert validator == rcsetup._validators[param.name]
+
+
+def test_defaults_as_code():
+ for param in rcsetup._params:
+ if param.name == 'backend':
+ # backend has special handling and no meaningful default
+ continue
+ assert param.default == mpl.rcParamsDefault[param.name], param.name
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_scale.py b/lib/matplotlib/tests/test_scale.py
index b3da951cf464..f98e083d84a0 100644
--- a/lib/matplotlib/tests/test_scale.py
+++ b/lib/matplotlib/tests/test_scale.py
@@ -6,8 +6,12 @@
LogTransform, InvertedLogTransform,
SymmetricalLogTransform)
import matplotlib.scale as mscale
-from matplotlib.ticker import AsinhLocator, LogFormatterSciNotation
+from matplotlib.ticker import (
+ AsinhLocator, AutoLocator, LogFormatterSciNotation,
+ NullFormatter, NullLocator, ScalarFormatter
+)
from matplotlib.testing.decorators import check_figures_equal, image_comparison
+from matplotlib.transforms import IdentityTransform
import numpy as np
from numpy.testing import assert_allclose
@@ -295,3 +299,75 @@ def test_bad_scale(self):
AsinhScale(axis=None, linear_width=-1)
s0 = AsinhScale(axis=None, )
s1 = AsinhScale(axis=None, linear_width=3.0)
+
+
+def test_custom_scale_without_axis():
+ """
+ Test that one can register and use custom scales that don't take an *axis* param.
+ """
+ class CustomTransform(IdentityTransform):
+ pass
+
+ class CustomScale(mscale.ScaleBase):
+ name = "custom"
+
+ # Important: __init__ has no *axis* parameter
+ def __init__(self):
+ self._transform = CustomTransform()
+
+ def get_transform(self):
+ return self._transform
+
+ def set_default_locators_and_formatters(self, axis):
+ axis.set_major_locator(AutoLocator())
+ axis.set_major_formatter(ScalarFormatter())
+ axis.set_minor_locator(NullLocator())
+ axis.set_minor_formatter(NullFormatter())
+
+ try:
+ mscale.register_scale(CustomScale)
+ fig, ax = plt.subplots()
+ ax.set_xscale('custom')
+ assert isinstance(ax.xaxis.get_transform(), CustomTransform)
+ finally:
+ # cleanup - there's no public unregister_scale()
+ del mscale._scale_mapping["custom"]
+ del mscale._scale_has_axis_parameter["custom"]
+
+
+def test_custom_scale_with_axis():
+ """
+ Test that one can still register and use custom scales with an *axis*
+ parameter, but that registering issues a pending-deprecation warning.
+ """
+ class CustomTransform(IdentityTransform):
+ pass
+
+ class CustomScale(mscale.ScaleBase):
+ name = "custom"
+
+ # Important: __init__ still has the *axis* parameter
+ def __init__(self, axis):
+ self._transform = CustomTransform()
+
+ def get_transform(self):
+ return self._transform
+
+ def set_default_locators_and_formatters(self, axis):
+ axis.set_major_locator(AutoLocator())
+ axis.set_major_formatter(ScalarFormatter())
+ axis.set_minor_locator(NullLocator())
+ axis.set_minor_formatter(NullFormatter())
+
+ try:
+ with pytest.warns(
+ PendingDeprecationWarning,
+ match=r"'axis' parameter .* is pending-deprecated"):
+ mscale.register_scale(CustomScale)
+ fig, ax = plt.subplots()
+ ax.set_xscale('custom')
+ assert isinstance(ax.xaxis.get_transform(), CustomTransform)
+ finally:
+ # cleanup - there's no public unregister_scale()
+ del mscale._scale_mapping["custom"]
+ del mscale._scale_has_axis_parameter["custom"]
diff --git a/lib/matplotlib/tests/test_skew.py b/lib/matplotlib/tests/test_skew.py
index 8527e474fa21..125ecd7ff606 100644
--- a/lib/matplotlib/tests/test_skew.py
+++ b/lib/matplotlib/tests/test_skew.py
@@ -25,9 +25,9 @@ def draw(self, renderer):
for artist in [self.gridline, self.tick1line, self.tick2line,
self.label1, self.label2]:
stack.callback(artist.set_visible, artist.get_visible())
- needs_lower = transforms.interval_contains(
+ needs_lower = transforms._interval_contains(
self.axes.lower_xlim, self.get_loc())
- needs_upper = transforms.interval_contains(
+ needs_upper = transforms._interval_contains(
self.axes.upper_xlim, self.get_loc())
self.tick1line.set_visible(
self.tick1line.get_visible() and needs_lower)
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()
@@ -1096,8 +1135,9 @@ def test_empty_annotation_get_window_extent():
assert points[0, 1] == 50.0
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['basictext_wrap'],
- extensions=['png'])
+ extensions=['png'], tol=0.3)
def test_basic_wrap():
fig = plt.figure()
plt.axis([0, 10, 0, 10])
@@ -1113,8 +1153,9 @@ def test_basic_wrap():
plt.text(-1, 0, t, ha='left', rotation=-15, wrap=True)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['fonttext_wrap'],
- extensions=['png'])
+ extensions=['png'], tol=0.3)
def test_font_wrap():
fig = plt.figure()
plt.axis([0, 10, 0, 10])
@@ -1146,8 +1187,9 @@ def test_va_for_angle():
assert alignment in ['center', 'top', 'baseline']
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['xtick_rotation_mode'],
- remove_text=False, extensions=['png'], style='mpl20')
+ remove_text=False, extensions=['png'], style='mpl20', tol=0.3)
def test_xtick_rotation_mode():
fig, ax = plt.subplots(figsize=(12, 1))
ax.set_yticks([])
@@ -1166,8 +1208,9 @@ def test_xtick_rotation_mode():
plt.subplots_adjust(left=0.01, right=0.99, top=.6, bottom=.4)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['ytick_rotation_mode'],
- remove_text=False, extensions=['png'], style='mpl20')
+ remove_text=False, extensions=['png'], style='mpl20', tol=0.3)
def test_ytick_rotation_mode():
fig, ax = plt.subplots(figsize=(1, 12))
ax.set_xticks([])
diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index 0f54230663aa..c3c53ebaea73 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -6,7 +6,7 @@
from packaging.version import parse as parse_version
import numpy as np
-from numpy.testing import assert_almost_equal, assert_array_equal
+from numpy.testing import assert_almost_equal, assert_array_equal, assert_allclose
import pytest
import matplotlib as mpl
@@ -356,6 +356,10 @@ def test_switch_to_autolocator(self):
loc = mticker.LogLocator(subs=np.arange(2, 10))
assert 1.0 not in loc.tick_values(0.9, 20.)
assert 10.0 not in loc.tick_values(0.9, 20.)
+ # don't switch if there's already one major and one minor tick (10 & 20)
+ loc = mticker.LogLocator(subs="auto")
+ tv = loc.tick_values(10, 20)
+ assert_array_equal(tv[(10 <= tv) & (tv <= 20)], [20])
def test_set_params(self):
"""
@@ -1931,7 +1935,10 @@ def test_bad_locator_subs(sub):
@mpl.style.context('default')
def test_small_range_loglocator(numticks, lims, ticks):
ll = mticker.LogLocator(numticks=numticks)
- assert_array_equal(ll.tick_values(*lims), ticks)
+ if parse_version(np.version.version).major < 2:
+ assert_allclose(ll.tick_values(*lims), ticks, rtol=2e-16)
+ else:
+ assert_array_equal(ll.tick_values(*lims), ticks)
@mpl.style.context('default')
diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py
index b4db34db5a91..2b4351a5cfbb 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()
@@ -967,7 +977,7 @@ def test_nonsingular():
zero_expansion = np.array([-0.001, 0.001])
cases = [(0, np.nan), (0, 0), (0, 7.9e-317)]
for args in cases:
- out = np.array(mtransforms.nonsingular(*args))
+ out = np.array(mtransforms._nonsingular(*args))
assert_array_equal(out, zero_expansion)
@@ -1083,21 +1093,21 @@ def test_transformedbbox_contains():
def test_interval_contains():
- assert mtransforms.interval_contains((0, 1), 0.5)
- assert mtransforms.interval_contains((0, 1), 0)
- assert mtransforms.interval_contains((0, 1), 1)
- assert not mtransforms.interval_contains((0, 1), -1)
- assert not mtransforms.interval_contains((0, 1), 2)
- assert mtransforms.interval_contains((1, 0), 0.5)
+ assert mtransforms._interval_contains((0, 1), 0.5)
+ assert mtransforms._interval_contains((0, 1), 0)
+ assert mtransforms._interval_contains((0, 1), 1)
+ assert not mtransforms._interval_contains((0, 1), -1)
+ assert not mtransforms._interval_contains((0, 1), 2)
+ assert mtransforms._interval_contains((1, 0), 0.5)
def test_interval_contains_open():
- assert mtransforms.interval_contains_open((0, 1), 0.5)
- assert not mtransforms.interval_contains_open((0, 1), 0)
- assert not mtransforms.interval_contains_open((0, 1), 1)
- assert not mtransforms.interval_contains_open((0, 1), -1)
- assert not mtransforms.interval_contains_open((0, 1), 2)
- assert mtransforms.interval_contains_open((1, 0), 0.5)
+ assert mtransforms._interval_contains_open((0, 1), 0.5)
+ assert not mtransforms._interval_contains_open((0, 1), 0)
+ assert not mtransforms._interval_contains_open((0, 1), 1)
+ assert not mtransforms._interval_contains_open((0, 1), -1)
+ assert not mtransforms._interval_contains_open((0, 1), 2)
+ assert mtransforms._interval_contains_open((1, 0), 0.5)
def test_scaledrotation_initialization():
diff --git a/lib/matplotlib/tests/test_typing.py b/lib/matplotlib/tests/test_typing.py
new file mode 100644
index 000000000000..c9fc8e5b162f
--- /dev/null
+++ b/lib/matplotlib/tests/test_typing.py
@@ -0,0 +1,51 @@
+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():
+ runtime_cm = plt.cm
+ runtime_cmaps = {
+ name
+ for name, value in vars(runtime_cm).items()
+ if isinstance(value, Colormap)
+ }
+
+ cm_pyi_path = Path(__file__).parent.parent / "cm.pyi"
+ assert cm_pyi_path.exists(), f"{cm_pyi_path} does not exist"
+
+ pyi_content = cm_pyi_path.read_text(encoding='utf-8')
+
+ stubbed_cmaps = set(
+ re.findall(r"^(\w+):\s+colors\.Colormap", pyi_content, re.MULTILINE)
+ )
+
+ assert runtime_cmaps, (
+ "No colormaps variables found at runtime in matplotlib.colors"
+ )
+ assert stubbed_cmaps, (
+ "No colormaps found in cm.pyi"
+ )
+
+ 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/tests/test_units.py b/lib/matplotlib/tests/test_units.py
index d2350667e94f..c13c54a101fc 100644
--- a/lib/matplotlib/tests/test_units.py
+++ b/lib/matplotlib/tests/test_units.py
@@ -80,8 +80,9 @@ def default_units(value, axis):
# Tests that the conversion machinery works properly for classes that
# work as a facade over numpy arrays (like pint)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['plot_pint.png'], style='mpl20',
- tol=0 if platform.machine() == 'x86_64' else 0.03)
+ tol=0.03 if platform.machine() == 'x86_64' else 0.04)
def test_numpy_facade(quantity_converter):
# use former defaults to match existing baseline image
plt.rcParams['axes.formatter.limits'] = -7, 7
@@ -142,8 +143,9 @@ def test_jpl_bar_units():
ax.set_ylim([b - 1 * day, b + w[-1] + (1.001) * day])
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['jpl_barh_units.png'],
- savefig_kwarg={'dpi': 120}, style='mpl20')
+ savefig_kwarg={'dpi': 120}, style='mpl20', tol=0.02)
def test_jpl_barh_units():
import matplotlib.testing.jpl_units as units
units.register()
diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py
index cd9f2597361b..78d9fd6cc948 100644
--- a/lib/matplotlib/tests/test_usetex.py
+++ b/lib/matplotlib/tests/test_usetex.py
@@ -226,8 +226,9 @@ def test_pdf_type1_font_subsetting():
_old_gs_version = True
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['rotation'], extensions=['eps', 'pdf', 'png', 'svg'],
- style='mpl20', tol=3.91 if _old_gs_version else 0)
+ style='mpl20', tol=3.91 if _old_gs_version else 0.2)
def test_rotation():
mpl.rcParams['text.usetex'] = True
diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py
index 808863fd6a94..9eebf165e71f 100644
--- a/lib/matplotlib/tests/test_widgets.py
+++ b/lib/matplotlib/tests/test_widgets.py
@@ -3,13 +3,13 @@
import operator
from unittest import mock
-from matplotlib.backend_bases import MouseEvent
+import matplotlib as mpl
+from matplotlib.backend_bases import DrawEvent, KeyEvent, MouseEvent
import matplotlib.colors as mcolors
import matplotlib.widgets as widgets
import matplotlib.pyplot as plt
from matplotlib.testing.decorators import check_figures_equal, image_comparison
-from matplotlib.testing.widgets import (click_and_drag, do_event, get_ax,
- mock_event, noop)
+from matplotlib.testing.widgets import click_and_drag, get_ax, noop
import numpy as np
from numpy.testing import assert_allclose
@@ -71,11 +71,10 @@ def test_rectangle_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
tool = widgets.RectangleSelector(ax, onselect=onselect, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
-
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (199, 199), 1)._process()
# purposely drag outside of axis for release
- do_event(tool, 'release', xdata=250, ydata=250, button=1)
+ MouseEvent._from_ax_coords("button_release_event", ax, (250, 250), 1)._process()
if kwargs.get('drawtype', None) not in ['line', 'none']:
assert_allclose(tool.geometry,
@@ -137,7 +136,7 @@ def test_rectangle_drag(ax, drag_from_anywhere, new_center):
tool = widgets.RectangleSelector(ax, interactive=True,
drag_from_anywhere=drag_from_anywhere)
# Create rectangle
- click_and_drag(tool, start=(0, 10), end=(100, 120))
+ click_and_drag(tool, start=(10, 10), end=(90, 120))
assert tool.center == (50, 65)
# Drag inside rectangle, but away from centre handle
#
@@ -178,8 +177,8 @@ def test_rectangle_selector_set_props_handle_props(ax):
def test_rectangle_resize(ax):
tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
- click_and_drag(tool, start=(0, 10), end=(100, 120))
- assert tool.extents == (0.0, 100.0, 10.0, 120.0)
+ click_and_drag(tool, start=(10, 10), end=(100, 120))
+ assert tool.extents == (10.0, 100.0, 10.0, 120.0)
# resize NE handle
extents = tool.extents
@@ -446,11 +445,11 @@ def test_rectangle_rotate(ax, selector_class):
assert len(tool._state) == 0
# Rotate anticlockwise using top-right corner
- do_event(tool, 'on_key_press', key='r')
+ KeyEvent("key_press_event", ax.figure.canvas, "r")._process()
assert tool._state == {'rotate'}
assert len(tool._state) == 1
click_and_drag(tool, start=(130, 140), end=(120, 145))
- do_event(tool, 'on_key_press', key='r')
+ KeyEvent("key_press_event", ax.figure.canvas, "r")._process()
assert len(tool._state) == 0
# Extents shouldn't change (as shape of rectangle hasn't changed)
assert tool.extents == (100, 130, 100, 140)
@@ -623,27 +622,36 @@ def test_rectangle_selector_ignore_outside(ax, ignore_event_outside):
('horizontal', False, dict(interactive=True)),
])
def test_span_selector(ax, orientation, onmove_callback, kwargs):
- onselect = mock.Mock(spec=noop, return_value=None)
- onmove = mock.Mock(spec=noop, return_value=None)
- if onmove_callback:
- kwargs['onmove_callback'] = onmove
-
- # While at it, also test that span selectors work in the presence of twin axes on
- # top of the axes that contain the selector. Note that we need to unforce the axes
- # aspect here, otherwise the twin axes forces the original axes' limits (to respect
- # aspect=1) which makes some of the values below go out of bounds.
+ # Also test that span selectors work in the presence of twin axes or for
+ # outside-inset axes on top of the axes that contain the selector. Note
+ # that we need to unforce the axes aspect here, otherwise the twin axes
+ # forces the original axes' limits (to respect aspect=1) which makes some
+ # of the values below go out of bounds.
ax.set_aspect("auto")
- tax = ax.twinx()
-
- tool = widgets.SpanSelector(ax, onselect, orientation, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- # move outside of axis
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
- do_event(tool, 'release', xdata=250, ydata=250, button=1)
-
- onselect.assert_called_once_with(100, 199)
- if onmove_callback:
- onmove.assert_called_once_with(100, 199)
+ ax.twinx()
+ child = ax.inset_axes([0, 1, 1, 1], xlim=(0, 200), ylim=(0, 200))
+
+ for target in [ax, child]:
+ selected = []
+ def onselect(*args): selected.append(args)
+ moved = []
+ def onmove(*args): moved.append(args)
+ if onmove_callback:
+ kwargs['onmove_callback'] = onmove
+
+ tool = widgets.SpanSelector(target, onselect, orientation, **kwargs)
+ MouseEvent._from_ax_coords(
+ "button_press_event", target, (100, 100), 1)._process()
+ # move outside of axis
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", target, (199, 199), 1)._process()
+ MouseEvent._from_ax_coords(
+ "button_release_event", target, (250, 250), 1)._process()
+
+ # tol is set by pixel size (~100 pixels & span of 200 data units)
+ assert_allclose(selected, [(100, 199)], atol=.5)
+ if onmove_callback:
+ assert_allclose(moved, [(100, 199)], atol=.5)
@pytest.mark.parametrize('interactive', [True, False])
@@ -783,7 +791,7 @@ def test_selector_clear(ax, selector):
click_and_drag(tool, start=(130, 130), end=(130, 130))
assert tool._selection_completed
- do_event(tool, 'on_key_press', key='escape')
+ KeyEvent("key_press_event", ax.figure.canvas, "escape")._process()
assert not tool._selection_completed
@@ -905,10 +913,8 @@ def mean(vmin, vmax):
# Add span selector and check that the line is draw after it was updated
# by the callback
- press_data = [1, 2]
- move_data = [2, 2]
- do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1)
- do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (1, 2), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (2, 2), 1)._process()
assert span._get_animated_artists() == (ln, ln2)
assert ln.stale is False
assert ln2.stale
@@ -918,16 +924,12 @@ def mean(vmin, vmax):
# Change span selector and check that the line is drawn/updated after its
# value was updated by the callback
- press_data = [4, 0]
- move_data = [5, 2]
- release_data = [5, 2]
- do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1)
- do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (4, 0), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (5, 2), 1)._process()
assert ln.stale is False
assert ln2.stale
assert_allclose(ln2.get_ydata(), -0.9424150707548072)
- do_event(span, 'release', xdata=release_data[0],
- ydata=release_data[1], button=1)
+ MouseEvent._from_ax_coords("button_release_event", ax, (5, 2), 1)._process()
assert ln2.stale is False
@@ -988,9 +990,9 @@ def test_lasso_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
tool = widgets.LassoSelector(ax, onselect=onselect, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- do_event(tool, 'onmove', xdata=125, ydata=125, button=1)
- do_event(tool, 'release', xdata=150, ydata=150, button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125), 1)._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (150, 150), 1)._process()
onselect.assert_called_once_with([(100, 100), (125, 125), (150, 150)])
@@ -1066,7 +1068,7 @@ def test_TextBox(ax, toolbar):
assert tool.text == ''
- do_event(tool, '_click')
+ MouseEvent._from_ax_coords("button_press_event", ax, (.5, .5), 1)._process()
tool.set_val('x**2')
@@ -1078,9 +1080,9 @@ def test_TextBox(ax, toolbar):
assert submit_event.call_count == 2
- do_event(tool, '_click', xdata=.5, ydata=.5) # Ensure the click is in the axes.
- do_event(tool, '_keypress', key='+')
- do_event(tool, '_keypress', key='5')
+ MouseEvent._from_ax_coords("button_press_event", ax, (.5, .5), 1)._process()
+ KeyEvent("key_press_event", ax.figure.canvas, "+")._process()
+ KeyEvent("key_press_event", ax.figure.canvas, "5")._process()
assert text_change_event.call_count == 3
@@ -1343,162 +1345,160 @@ def test_range_slider_same_init_values(orientation):
assert_allclose(box.get_points().flatten()[idx], [0, 0.25, 0, 0.75])
-def check_polygon_selector(event_sequence, expected_result, selections_count,
- **kwargs):
+def check_polygon_selector(events, expected, selections_count, **kwargs):
"""
Helper function to test Polygon Selector.
Parameters
----------
- event_sequence : list of tuples (etype, dict())
- A sequence of events to perform. The sequence is a list of tuples
- where the first element of the tuple is an etype (e.g., 'onmove',
- 'press', etc.), and the second element of the tuple is a dictionary of
- the arguments for the event (e.g., xdata=5, key='shift', etc.).
- expected_result : list of vertices (xdata, ydata)
- The list of vertices that are expected to result from the event
- sequence.
+ events : list[MouseEvent]
+ A sequence of events to perform.
+ expected : list of vertices (xdata, ydata)
+ The list of vertices expected to result from the event sequence.
selections_count : int
Wait for the tool to call its `onselect` function `selections_count`
- times, before comparing the result to the `expected_result`
+ times, before comparing the result to the `expected`
**kwargs
Keyword arguments are passed to PolygonSelector.
"""
- ax = get_ax()
-
onselect = mock.Mock(spec=noop, return_value=None)
+ ax = events[0].canvas.figure.axes[0]
tool = widgets.PolygonSelector(ax, onselect=onselect, **kwargs)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ for event in events:
+ event._process()
assert onselect.call_count == selections_count
- assert onselect.call_args == ((expected_result, ), {})
+ assert onselect.call_args == ((expected, ), {})
-def polygon_place_vertex(xdata, ydata):
- return [('onmove', dict(xdata=xdata, ydata=ydata)),
- ('press', dict(xdata=xdata, ydata=ydata)),
- ('release', dict(xdata=xdata, ydata=ydata))]
+def polygon_place_vertex(ax, xy):
+ return [
+ MouseEvent._from_ax_coords("motion_notify_event", ax, xy),
+ MouseEvent._from_ax_coords("button_press_event", ax, xy, 1),
+ MouseEvent._from_ax_coords("button_release_event", ax, xy, 1),
+ ]
-def polygon_remove_vertex(xdata, ydata):
- return [('onmove', dict(xdata=xdata, ydata=ydata)),
- ('press', dict(xdata=xdata, ydata=ydata, button=3)),
- ('release', dict(xdata=xdata, ydata=ydata, button=3))]
+def polygon_remove_vertex(ax, xy):
+ return [
+ MouseEvent._from_ax_coords("motion_notify_event", ax, xy),
+ MouseEvent._from_ax_coords("button_press_event", ax, xy, 3),
+ MouseEvent._from_ax_coords("button_release_event", ax, xy, 3),
+ ]
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector(draw_bounding_box):
+def test_polygon_selector(ax, draw_bounding_box):
check_selector = functools.partial(
check_polygon_selector, draw_bounding_box=draw_bounding_box)
# Simple polygon
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Move first vertex before completing the polygon.
expected_result = [(75, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- ('on_key_press', dict(key='control')),
- ('onmove', dict(xdata=50, ydata=50)),
- ('press', dict(xdata=50, ydata=50)),
- ('onmove', dict(xdata=75, ydata=50)),
- ('release', dict(xdata=75, ydata=50)),
- ('on_key_release', dict(key='control')),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(75, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "control"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (50, 50)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (50, 50), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (75, 50)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (75, 50), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "control"),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (75, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Move first two vertices at once before completing the polygon.
expected_result = [(50, 75), (150, 75), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=100, ydata=125)),
- ('release', dict(xdata=100, ydata=125)),
- ('on_key_release', dict(key='shift')),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 75),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (100, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 75)),
]
check_selector(event_sequence, expected_result, 1)
# Move first vertex after completing the polygon.
- expected_result = [(75, 50), (150, 50), (50, 150)]
+ expected_result = [(85, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ('onmove', dict(xdata=50, ydata=50)),
- ('press', dict(xdata=50, ydata=50)),
- ('onmove', dict(xdata=75, ydata=50)),
- ('release', dict(xdata=75, ydata=50)),
+ *polygon_place_vertex(ax, (60, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (60, 50)),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (60, 50)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (60, 50), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (85, 50)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (85, 50), 1),
]
check_selector(event_sequence, expected_result, 2)
# Move all vertices after completing the polygon.
expected_result = [(75, 75), (175, 75), (75, 175)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='shift')),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
]
check_selector(event_sequence, expected_result, 2)
# Try to move a vertex and move all before placing any vertices.
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- ('on_key_press', dict(key='control')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='control')),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='shift')),
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ KeyEvent("key_press_event", ax.figure.canvas, "control"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "control"),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Try to place vertex out-of-bounds, then reset, and start a new polygon.
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(250, 50),
- ('on_key_press', dict(key='escape')),
- ('on_key_release', dict(key='escape')),
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (250, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "escape"),
+ KeyEvent("key_release_event", ax.figure.canvas, "escape"),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
@@ -1510,15 +1510,13 @@ def test_polygon_selector_set_props_handle_props(ax, draw_bounding_box):
handle_props=dict(alpha=0.5),
draw_bounding_box=draw_bounding_box)
- event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ]
-
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ for event in [
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
+ ]:
+ event._process()
artist = tool._selection_artist
assert artist.get_color() == 'b'
@@ -1549,17 +1547,17 @@ def test_rect_visibility(fig_test, fig_ref):
# Change the order that the extra point is inserted in
@pytest.mark.parametrize('idx', [1, 2, 3])
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector_remove(idx, draw_bounding_box):
+def test_polygon_selector_remove(ax, idx, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
- event_sequence = [polygon_place_vertex(*verts[0]),
- polygon_place_vertex(*verts[1]),
- polygon_place_vertex(*verts[2]),
+ event_sequence = [polygon_place_vertex(ax, verts[0]),
+ polygon_place_vertex(ax, verts[1]),
+ polygon_place_vertex(ax, verts[2]),
# Finish the polygon
- polygon_place_vertex(*verts[0])]
+ polygon_place_vertex(ax, verts[0])]
# Add an extra point
- event_sequence.insert(idx, polygon_place_vertex(200, 200))
+ event_sequence.insert(idx, polygon_place_vertex(ax, (200, 200)))
# Remove the extra point
- event_sequence.append(polygon_remove_vertex(200, 200))
+ event_sequence.append(polygon_remove_vertex(ax, (200, 200)))
# Flatten list of lists
event_sequence = functools.reduce(operator.iadd, event_sequence, [])
check_polygon_selector(event_sequence, verts, 2,
@@ -1567,14 +1565,14 @@ def test_polygon_selector_remove(idx, draw_bounding_box):
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector_remove_first_point(draw_bounding_box):
+def test_polygon_selector_remove_first_point(ax, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
- *polygon_remove_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_remove_vertex(ax, verts[0]),
]
check_polygon_selector(event_sequence, verts[1:], 2,
draw_bounding_box=draw_bounding_box)
@@ -1584,20 +1582,20 @@ def test_polygon_selector_remove_first_point(draw_bounding_box):
def test_polygon_selector_redraw(ax, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[0]),
# Polygon completed, now remove first two verts.
- *polygon_remove_vertex(*verts[1]),
- *polygon_remove_vertex(*verts[2]),
+ *polygon_remove_vertex(ax, verts[1]),
+ *polygon_remove_vertex(ax, verts[2]),
# At this point the tool should be reset so we can add more vertices.
- *polygon_place_vertex(*verts[1]),
+ *polygon_place_vertex(ax, verts[1]),
]
tool = widgets.PolygonSelector(ax, draw_bounding_box=draw_bounding_box)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ for event in event_sequence:
+ event._process()
# After removing two verts, only one remains, and the
# selector should be automatically reset
assert tool.verts == verts[0:2]
@@ -1615,14 +1613,13 @@ def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box):
ax_ref = fig_ref.add_subplot()
tool_ref = widgets.PolygonSelector(ax_ref, draw_bounding_box=draw_bounding_box)
- event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
- ]
- for (etype, event_args) in event_sequence:
- do_event(tool_ref, etype, **event_args)
+ for event in [
+ *polygon_place_vertex(ax_ref, verts[0]),
+ *polygon_place_vertex(ax_ref, verts[1]),
+ *polygon_place_vertex(ax_ref, verts[2]),
+ *polygon_place_vertex(ax_ref, verts[0]),
+ ]:
+ event._process()
def test_polygon_selector_box(ax):
@@ -1630,40 +1627,29 @@ def test_polygon_selector_box(ax):
ax.set(xlim=(-10, 50), ylim=(-10, 50))
verts = [(20, 0), (0, 20), (20, 40), (40, 20)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[3]),
- *polygon_place_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[3]),
+ *polygon_place_vertex(ax, verts[0]),
]
# Create selector
tool = widgets.PolygonSelector(ax, draw_bounding_box=True)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
-
- # In order to trigger the correct callbacks, trigger events on the canvas
- # instead of the individual tools
- t = ax.transData
- canvas = ax.get_figure(root=True).canvas
+ for event in event_sequence:
+ event._process()
# Scale to half size using the top right corner of the bounding box
- MouseEvent(
- "button_press_event", canvas, *t.transform((40, 40)), 1)._process()
- MouseEvent(
- "motion_notify_event", canvas, *t.transform((20, 20)))._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((20, 20)), 1)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (40, 40), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (20, 20))._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (20, 20), 1)._process()
np.testing.assert_allclose(
tool.verts, [(10, 0), (0, 10), (10, 20), (20, 10)])
# Move using the center of the bounding box
- MouseEvent(
- "button_press_event", canvas, *t.transform((10, 10)), 1)._process()
- MouseEvent(
- "motion_notify_event", canvas, *t.transform((30, 30)))._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((30, 30)), 1)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (10, 10), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (30, 30))._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (30, 30), 1)._process()
np.testing.assert_allclose(
tool.verts, [(30, 20), (20, 30), (30, 40), (40, 30)])
@@ -1671,10 +1657,8 @@ def test_polygon_selector_box(ax):
np.testing.assert_allclose(
tool._box.extents, (20.0, 40.0, 20.0, 40.0))
- MouseEvent(
- "button_press_event", canvas, *t.transform((30, 20)), 3)._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((30, 20)), 3)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (30, 20), 3)._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (30, 20), 3)._process()
np.testing.assert_allclose(
tool.verts, [(20, 30), (30, 40), (40, 30)])
np.testing.assert_allclose(
@@ -1687,9 +1671,9 @@ def test_polygon_selector_clear_method(ax):
for result in ([(50, 50), (150, 50), (50, 150), (50, 50)],
[(50, 50), (100, 50), (50, 150), (50, 50)]):
- for x, y in result:
- for etype, event_args in polygon_place_vertex(x, y):
- do_event(tool, etype, **event_args)
+ for xy in result:
+ for event in polygon_place_vertex(ax, xy):
+ event._process()
artist = tool._selection_artist
@@ -1706,25 +1690,28 @@ def test_polygon_selector_clear_method(ax):
@pytest.mark.parametrize("horizOn", [False, True])
@pytest.mark.parametrize("vertOn", [False, True])
-def test_MultiCursor(horizOn, vertOn):
+@pytest.mark.parametrize("with_deprecated_canvas", [False, True])
+def test_MultiCursor(horizOn, vertOn, with_deprecated_canvas):
fig = plt.figure()
(ax1, ax3) = fig.subplots(2, sharex=True)
ax2 = plt.figure().subplots()
- # useblit=false to avoid having to draw the figure to cache the renderer
- multi = widgets.MultiCursor(
- None, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
- )
+ if with_deprecated_canvas:
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=r"canvas.*deprecat"):
+ multi = widgets.MultiCursor(
+ None, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
+ )
+ else:
+ # useblit=false to avoid having to draw the figure to cache the renderer
+ multi = widgets.MultiCursor(
+ (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
+ )
# Only two of the axes should have a line drawn on them.
assert len(multi.vlines) == 2
assert len(multi.hlines) == 2
- # mock a motion_notify_event
- # Can't use `do_event` as that helper requires the widget
- # to have a single .ax attribute.
- event = mock_event(ax1, xdata=.5, ydata=.25)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax1, (.5, .25))._process()
# force a draw + draw event to exercise clear
fig.canvas.draw()
@@ -1742,8 +1729,7 @@ def test_MultiCursor(horizOn, vertOn):
# After toggling settings, the opposite lines should be visible after move.
multi.horizOn = not multi.horizOn
multi.vertOn = not multi.vertOn
- event = mock_event(ax1, xdata=.5, ydata=.25)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax1, (.5, .25))._process()
assert len([line for line in multi.vlines if line.get_visible()]) == (
0 if vertOn else 2)
assert len([line for line in multi.hlines if line.get_visible()]) == (
@@ -1751,9 +1737,31 @@ def test_MultiCursor(horizOn, vertOn):
# test a move event in an Axes not part of the MultiCursor
# the lines in ax1 and ax2 should not have moved.
- event = mock_event(ax3, xdata=.75, ydata=.75)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax3, (.75, .75))._process()
for l in multi.vlines:
assert l.get_xdata() == (.5, .5)
for l in multi.hlines:
assert l.get_ydata() == (.25, .25)
+
+
+def test_parent_axes_removal():
+
+ fig, (ax_radio, ax_checks) = plt.subplots(1, 2)
+
+ radio = widgets.RadioButtons(ax_radio, ['1', '2'], 0)
+ checks = widgets.CheckButtons(ax_checks, ['1', '2'], [True, False])
+
+ ax_checks.remove()
+ ax_radio.remove()
+ with io.BytesIO() as out:
+ # verify that saving does not raise
+ fig.savefig(out, format='raw')
+
+ # verify that this method which is triggered by a draw_event callback when
+ # blitting is enabled does not raise. Calling private methods is simpler
+ # than trying to force blitting to be enabled with Agg or use a GUI
+ # framework.
+ renderer = fig._get_renderer()
+ evt = DrawEvent('draw_event', fig.canvas, renderer)
+ radio._clear(evt)
+ checks._clear(evt)
diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py
index 020a26e31cbe..35651a94aa85 100644
--- a/lib/matplotlib/texmanager.py
+++ b/lib/matplotlib/texmanager.py
@@ -23,7 +23,6 @@
import functools
import hashlib
import logging
-import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
@@ -63,7 +62,7 @@ class TexManager:
Repeated calls to this constructor always return the same instance.
"""
- _texcache = os.path.join(mpl.get_cachedir(), 'tex.cache')
+ _cache_dir = Path(mpl.get_cachedir(), 'tex.cache')
_grey_arrayd = {}
_font_families = ('serif', 'sans-serif', 'cursive', 'monospace')
@@ -109,7 +108,7 @@ class TexManager:
@functools.lru_cache # Always return the same instance.
def __new__(cls):
- Path(cls._texcache).mkdir(parents=True, exist_ok=True)
+ cls._cache_dir.mkdir(parents=True, exist_ok=True)
return object.__new__(cls)
@classmethod
@@ -167,23 +166,30 @@ def _get_font_preamble_and_command(cls):
return preamble, fontcmd
@classmethod
- def get_basefile(cls, tex, fontsize, dpi=None):
+ def _get_base_path(cls, tex, fontsize, dpi=None):
"""
- Return a filename based on a hash of the string, fontsize, and dpi.
+ Return a file path based on a hash of the string, fontsize, and dpi.
"""
src = cls._get_tex_source(tex, fontsize) + str(dpi)
filehash = hashlib.sha256(
src.encode('utf-8'),
usedforsecurity=False
).hexdigest()
- filepath = Path(cls._texcache)
+ filepath = cls._cache_dir
num_letters, num_levels = 2, 2
for i in range(0, num_letters*num_levels, num_letters):
- filepath = filepath / Path(filehash[i:i+2])
+ filepath = filepath / filehash[i:i+2]
filepath.mkdir(parents=True, exist_ok=True)
- return os.path.join(filepath, filehash)
+ return filepath / filehash
+
+ @classmethod
+ def get_basefile(cls, tex, fontsize, dpi=None): # Kept for backcompat.
+ """
+ Return a filename based on a hash of the string, fontsize, and dpi.
+ """
+ return str(cls._get_base_path(tex, fontsize, dpi))
@classmethod
def get_font_preamble(cls):
@@ -228,8 +234,6 @@ def _get_tex_source(cls, tex, fontsize):
r"\begin{document}",
r"% The empty hbox ensures that a page is printed even for empty",
r"% inputs, except when using psfrag which gets confused by it.",
- r"% matplotlibbaselinemarker is used by dviread to detect the",
- r"% last line's baseline.",
rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%",
r"\ifdefined\psfrag\else\hbox{}\fi%",
rf"{{{fontcmd} {tex}}}%",
@@ -243,17 +247,16 @@ def make_tex(cls, tex, fontsize):
Return the file name.
"""
- texfile = cls.get_basefile(tex, fontsize) + ".tex"
- Path(texfile).write_text(cls._get_tex_source(tex, fontsize),
- encoding='utf-8')
- return texfile
+ texpath = cls._get_base_path(tex, fontsize).with_suffix(".tex")
+ texpath.write_text(cls._get_tex_source(tex, fontsize), encoding='utf-8')
+ return str(texpath)
@classmethod
def _run_checked_subprocess(cls, command, tex, *, cwd=None):
_log.debug(cbook._pformat_subprocess(command))
try:
report = subprocess.check_output(
- command, cwd=cwd if cwd is not None else cls._texcache,
+ command, cwd=cwd if cwd is not None else cls._cache_dir,
stderr=subprocess.STDOUT)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -281,11 +284,9 @@ def make_dvi(cls, tex, fontsize):
Return the file name.
"""
- basefile = cls.get_basefile(tex, fontsize)
- dvifile = '%s.dvi' % basefile
- if not os.path.exists(dvifile):
- texfile = Path(cls.make_tex(tex, fontsize))
- # Generate the dvi in a temporary directory to avoid race
+ dvipath = cls._get_base_path(tex, fontsize).with_suffix(".dvi")
+ if not dvipath.exists():
+ # Generate the tex and dvi in a temporary directory to avoid race
# conditions e.g. if multiple processes try to process the same tex
# string at the same time. Having tmpdir be a subdirectory of the
# final output dir ensures that they are on the same filesystem,
@@ -294,15 +295,17 @@ def make_dvi(cls, tex, fontsize):
# the absolute path may contain characters (e.g. ~) that TeX does
# not support; n.b. relative paths cannot traverse parents, or it
# will be blocked when `openin_any = p` in texmf.cnf).
- cwd = Path(dvifile).parent
- with TemporaryDirectory(dir=cwd) as tmpdir:
- tmppath = Path(tmpdir)
+ with TemporaryDirectory(dir=dvipath.parent) as tmpdir:
+ Path(tmpdir, "file.tex").write_text(
+ cls._get_tex_source(tex, fontsize), encoding='utf-8')
cls._run_checked_subprocess(
["latex", "-interaction=nonstopmode", "--halt-on-error",
- f"--output-directory={tmppath.name}",
- f"{texfile.name}"], tex, cwd=cwd)
- (tmppath / Path(dvifile).name).replace(dvifile)
- return dvifile
+ "file.tex"], tex, cwd=tmpdir)
+ Path(tmpdir, "file.dvi").replace(dvipath)
+ # Also move the tex source to the main cache directory, but
+ # only for backcompat.
+ Path(tmpdir, "file.tex").replace(dvipath.with_suffix(".tex"))
+ return str(dvipath)
@classmethod
def make_png(cls, tex, fontsize, dpi):
@@ -311,22 +314,22 @@ def make_png(cls, tex, fontsize, dpi):
Return the file name.
"""
- basefile = cls.get_basefile(tex, fontsize, dpi)
- pngfile = '%s.png' % basefile
- # see get_rgba for a discussion of the background
- if not os.path.exists(pngfile):
- dvifile = cls.make_dvi(tex, fontsize)
- cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi),
- "-T", "tight", "-o", pngfile, dvifile]
- # When testing, disable FreeType rendering for reproducibility; but
- # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0
- # mode, so for it we keep FreeType enabled; the image will be
- # slightly off.
- if (getattr(mpl, "_called_from_pytest", False) and
- mpl._get_executable_info("dvipng").raw_version != "1.16"):
- cmd.insert(1, "--freetype0")
- cls._run_checked_subprocess(cmd, tex)
- return pngfile
+ pngpath = cls._get_base_path(tex, fontsize, dpi).with_suffix(".png")
+ if not pngpath.exists():
+ dvipath = cls.make_dvi(tex, fontsize)
+ with TemporaryDirectory(dir=pngpath.parent) as tmpdir:
+ cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi),
+ "-T", "tight", "-o", "file.png", dvipath]
+ # When testing, disable FreeType rendering for reproducibility;
+ # but dvipng 1.16 has a bug (fixed in f3ff241) that breaks
+ # --freetype0 mode, so for it we keep FreeType enabled; the
+ # image will be slightly off.
+ if (getattr(mpl, "_called_from_pytest", False) and
+ mpl._get_executable_info("dvipng").raw_version != "1.16"):
+ cmd.insert(1, "--freetype0")
+ cls._run_checked_subprocess(cmd, tex, cwd=tmpdir)
+ Path(tmpdir, "file.png").replace(pngpath)
+ return str(pngpath)
@classmethod
def get_grey(cls, tex, fontsize=None, dpi=None):
@@ -337,7 +340,7 @@ def get_grey(cls, tex, fontsize=None, dpi=None):
alpha = cls._grey_arrayd.get(key)
if alpha is None:
pngfile = cls.make_png(tex, fontsize, dpi)
- rgba = mpl.image.imread(os.path.join(cls._texcache, pngfile))
+ rgba = mpl.image.imread(pngfile)
cls._grey_arrayd[key] = alpha = rgba[:, :, -1]
return alpha
@@ -363,9 +366,9 @@ def get_text_width_height_descent(cls, tex, fontsize, renderer=None):
"""Return width, height and descent of the text."""
if tex.strip() == '':
return 0, 0, 0
- dvifile = cls.make_dvi(tex, fontsize)
+ dvipath = cls.make_dvi(tex, fontsize)
dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1
- with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi:
+ with dviread.Dvi(dvipath, 72 * dpi_fraction) as dvi:
page, = dvi
# A total height (including the descent) needs to be returned.
return page.width, page.height + page.descent, page.descent
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/ticker.py b/lib/matplotlib/ticker.py
index b5c12e7f4905..e27d71974471 100644
--- a/lib/matplotlib/ticker.py
+++ b/lib/matplotlib/ticker.py
@@ -1029,7 +1029,7 @@ def __call__(self, x, pos=None):
return ''
vmin, vmax = self.axis.get_view_interval()
- vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05)
+ vmin, vmax = mtransforms._nonsingular(vmin, vmax, expander=0.05)
s = self._num_to_string(x, vmin, vmax)
return self.fix_minus(s)
@@ -1730,7 +1730,7 @@ def nonsingular(self, v0, v1):
default view limits.
- Otherwise, ``(v0, v1)`` is returned without modification.
"""
- return mtransforms.nonsingular(v0, v1, expander=.05)
+ return mtransforms._nonsingular(v0, v1, expander=.05)
def view_limits(self, vmin, vmax):
"""
@@ -1738,7 +1738,7 @@ def view_limits(self, vmin, vmax):
Subclasses should override this method to change locator behaviour.
"""
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class IndexLocator(Locator):
@@ -1881,7 +1881,7 @@ def __call__(self):
return self.tick_values(vmin, vmax)
def tick_values(self, vmin, vmax):
- vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05)
+ vmin, vmax = mtransforms._nonsingular(vmin, vmax, expander=0.05)
if (vmin, vmax) in self.presets:
return self.presets[(vmin, vmax)]
@@ -1910,7 +1910,7 @@ def view_limits(self, vmin, vmax):
vmin = math.floor(scale * vmin) / scale
vmax = math.ceil(scale * vmax) / scale
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class MultipleLocator(Locator):
@@ -1980,7 +1980,7 @@ def view_limits(self, dmin, dmax):
vmin = dmin
vmax = dmax
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
def scale_range(vmin, vmax, n=1, threshold=100):
@@ -2236,7 +2236,7 @@ def tick_values(self, vmin, vmax):
if self._symmetric:
vmax = max(abs(vmin), abs(vmax))
vmin = -vmax
- vmin, vmax = mtransforms.nonsingular(
+ vmin, vmax = mtransforms._nonsingular(
vmin, vmax, expander=1e-13, tiny=1e-14)
locs = self._raw_ticks(vmin, vmax)
@@ -2254,7 +2254,7 @@ def view_limits(self, dmin, dmax):
dmax = max(abs(dmin), abs(dmax))
dmin = -dmax
- dmin, dmax = mtransforms.nonsingular(
+ dmin, dmax = mtransforms._nonsingular(
dmin, dmax, expander=1e-12, tiny=1e-13)
if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers':
@@ -2522,10 +2522,12 @@ def tick_values(self, vmin, vmax):
if (len(subs) > 1
and stride == 1
- and ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() <= 1):
+ and (len(decades) - 2 # major
+ + ((vmin <= ticklocs) & (ticklocs <= vmax)).sum()) # minor
+ <= 1):
# If we're a minor locator *that expects at least two ticks per
# decade* and the major locator stride is 1 and there's no more
- # than one minor tick, switch to AutoLocator.
+ # than one major or minor tick, switch to AutoLocator.
return AutoLocator().tick_values(vmin, vmax)
else:
return self.raise_if_exceeds(ticklocs)
@@ -2716,7 +2718,7 @@ def view_limits(self, vmin, vmax):
vmin = _decade_less(vmin, b)
vmax = _decade_greater(vmax, b)
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class AsinhLocator(Locator):
diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py
index 350113c56170..44d01926f2e8 100644
--- a/lib/matplotlib/transforms.py
+++ b/lib/matplotlib/transforms.py
@@ -44,7 +44,7 @@
import numpy as np
from numpy.linalg import inv
-from matplotlib import _api
+from matplotlib import _api, _docstring
from matplotlib._path import affine_transform, count_bboxes_overlapping_bbox
from .path import Path
@@ -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)
@@ -2837,7 +2865,7 @@ def _revalidate(self):
super()._revalidate()
-def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
+def _nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
"""
Modify the endpoints of a range as needed to avoid singularities.
@@ -2895,7 +2923,13 @@ def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
return vmin, vmax
-def interval_contains(interval, val):
+@_api.deprecated("3.11")
+@_docstring.copy(_nonsingular)
+def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
+ return _nonsingular(vmin, vmax, expander, tiny, increasing)
+
+
+def _interval_contains(interval, val):
"""
Check, inclusively, whether an interval includes a given value.
@@ -2917,6 +2951,12 @@ def interval_contains(interval, val):
return a <= val <= b
+@_api.deprecated("3.11")
+@_docstring.copy(_interval_contains)
+def interval_contains(interval, val):
+ return _interval_contains(interval, val)
+
+
def _interval_contains_close(interval, val, rtol=1e-10):
"""
Check, inclusively, whether an interval includes a given value, with the
@@ -2946,7 +2986,7 @@ def _interval_contains_close(interval, val, rtol=1e-10):
return a - rtol <= val <= b + rtol
-def interval_contains_open(interval, val):
+def _interval_contains_open(interval, val):
"""
Check, excluding endpoints, whether an interval includes a given value.
@@ -2966,6 +3006,12 @@ def interval_contains_open(interval, val):
return a < val < b or a > val > b
+@_api.deprecated("3.11")
+@_docstring.copy(_interval_contains_open)
+def interval_contains_open(interval, val):
+ return _interval_contains_open(interval, val)
+
+
def offset_copy(trans, fig=None, x=0.0, y=0.0, units='inches'):
"""
Return a new transform with an added offset.
diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi
index 07d299be297c..ebee3954a3a7 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]
@@ -314,6 +316,13 @@ class TransformedPath(TransformNode):
class TransformedPatchPath(TransformedPath):
def __init__(self, patch: Patch) -> None: ...
+def _nonsingular(
+ vmin: float,
+ vmax: float,
+ expander: float = ...,
+ tiny: float = ...,
+ increasing: bool = ...,
+) -> tuple[float, float]: ...
def nonsingular(
vmin: float,
vmax: float,
@@ -321,7 +330,9 @@ def nonsingular(
tiny: float = ...,
increasing: bool = ...,
) -> tuple[float, float]: ...
+def _interval_contains(interval: tuple[float, float], val: float) -> bool: ...
def interval_contains(interval: tuple[float, float], val: float) -> bool: ...
+def _interval_contains_open(interval: tuple[float, float], val: float) -> bool: ...
def interval_contains_open(interval: tuple[float, float], val: float) -> bool: ...
def offset_copy(
trans: Transform,
diff --git a/lib/matplotlib/tri/_tripcolor.py b/lib/matplotlib/tri/_tripcolor.py
index f3c26b0b25ff..5a5b24522d17 100644
--- a/lib/matplotlib/tri/_tripcolor.py
+++ b/lib/matplotlib/tri/_tripcolor.py
@@ -163,5 +163,7 @@ def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None,
corners = (minx, miny), (maxx, maxy)
ax.update_datalim(corners)
ax.autoscale_view()
- ax.add_collection(collection)
+ # TODO: check whether the above explicit limit handling can be
+ # replaced by autolim=True
+ ax.add_collection(collection, autolim=False)
return collection
diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py
index e3719235cdb8..d2e12c6e08d9 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -12,7 +12,8 @@
"""
from collections.abc import Hashable, Sequence
import pathlib
-from typing import Any, Callable, Literal, TypeAlias, TypeVar, Union
+from typing import Any, Literal, TypeAlias, TypeVar, Union
+from collections.abc import Callable
from . import path
from ._enums import JoinStyle, CapStyle
@@ -69,7 +70,16 @@
)
"""See :doc:`/gallery/lines_bars_and_markers/markevery_demo`."""
-MarkerType: TypeAlias = str | path.Path | MarkerStyle
+MarkerType: TypeAlias = (
+ path.Path | MarkerStyle | str | # str required for "$...$" marker
+ Literal[
+ ".", ",", "o", "v", "^", "<", ">",
+ "1", "2", "3", "4", "8", "s", "p",
+ "P", "*", "h", "H", "+", "x", "X",
+ "D", "d", "|", "_", "none", " ",
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
+ ] | list[tuple[int, int]] | tuple[int, Literal[0, 1, 2], int]
+)
"""
Marker specification. See :doc:`/gallery/lines_bars_and_markers/marker_reference`.
"""
@@ -83,6 +93,9 @@
CapStyleType: TypeAlias = CapStyle | Literal["butt", "projecting", "round"]
"""Line cap styles. See :doc:`/gallery/lines_bars_and_markers/capstyle`."""
+LogLevel: TypeAlias = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+"""Literal type for valid logging levels accepted by `set_loglevel()`."""
+
CoordsBaseType = Union[
str,
Artist,
@@ -137,3 +150,425 @@
ResizeEventType,
CloseEventType,
]
+
+LegendLocType: TypeAlias = (
+ Literal[
+ # for simplicity, we don't distinguish the between allowed positions for
+ # Axes legend and figure legend. It's still better to limit the allowed
+ # range to the union of both rather than to accept arbitrary strings
+ "upper right", "upper left", "lower left", "lower right",
+ "right", "center left", "center right", "lower center", "upper center",
+ "center",
+ # Axes only
+ "best",
+ # Figure only
+ "outside upper left", "outside upper center", "outside upper right",
+ "outside right upper", "outside right center", "outside right lower",
+ "outside lower right", "outside lower center", "outside lower left",
+ "outside left lower", "outside left center", "outside left upper",
+ ] |
+ 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 9ded7c61ce2d..cfe8cdc7d9f5 100644
--- a/lib/matplotlib/widgets.py
+++ b/lib/matplotlib/widgets.py
@@ -1,8 +1,6 @@
"""
-GUI neutral widgets
-===================
-
Widgets that are designed to work for any of the GUI backends.
+
All of these widgets require you to predefine an `~.axes.Axes`
instance and pass that as the first parameter. Matplotlib doesn't try to
be too smart with respect to layout -- you will have to figure out how
@@ -11,6 +9,8 @@
from contextlib import ExitStack
import copy
+import enum
+import functools
import itertools
from numbers import Integral, Number
@@ -116,8 +116,15 @@ class AxesWidget(Widget):
def __init__(self, ax):
self.ax = ax
self._cids = []
+ self._blit_background_id = None
+
+ def __del__(self):
+ if self._blit_background_id is not None:
+ self.canvas._release_blit_background_id(self._blit_background_id)
- canvas = property(lambda self: self.ax.get_figure(root=True).canvas)
+ canvas = property(
+ lambda self: getattr(self.ax.get_figure(root=True), 'canvas', None)
+ )
def connect_event(self, event, callback):
"""
@@ -134,15 +141,59 @@ def disconnect_events(self):
for c in self._cids:
self.canvas.mpl_disconnect(c)
- def _get_data_coords(self, event):
- """Return *event*'s data coordinates in this widget's Axes."""
- # This method handles the possibility that event.inaxes != self.ax (which may
- # occur if multiple Axes are overlaid), in which case event.xdata/.ydata will
- # be wrong. Note that we still special-case the common case where
- # event.inaxes == self.ax and avoid re-running the inverse data transform,
- # because that can introduce floating point errors for synthetic events.
- return ((event.xdata, event.ydata) if event.inaxes is self.ax
- else self.ax.transData.inverted().transform((event.x, event.y)))
+ 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)
+
+ def _save_blit_background(self, background):
+ """
+ Save a blit background.
+
+ The background is stored on the canvas in a uniquely identifiable way.
+ It should be read back via `._load_blit_background`. Be prepared that
+ some events may invalidate the background, in which case
+ `._load_blit_background` will return None.
+
+ This currently allows at most one background per widget, which is
+ good enough for all existing widgets.
+ """
+ if self._blit_background_id is None:
+ self._blit_background_id = self.canvas._get_blit_background_id()
+ self.canvas._blit_backgrounds[self._blit_background_id] = background
+
+ def _load_blit_background(self):
+ """Load a blit background; may be None at any time."""
+ return self.canvas._blit_backgrounds.get(self._blit_background_id)
+
+
+def _call_with_reparented_event(func):
+ """
+ Event callback decorator ensuring that the callback is called with an event
+ that has been reparented to the widget's axes.
+ """
+ # This decorator handles the possibility that event.inaxes != self.ax
+ # (e.g. if multiple Axes are overlaid), in which case event.xdata/.ydata
+ # will be wrong. Note that we still special-case the common case where
+ # event.inaxes == self.ax and avoid re-running the inverse data transform,
+ # because that can introduce floating point errors for synthetic events.
+ @functools.wraps(func)
+ def wrapper(self, event):
+ if event.inaxes is not self.ax:
+ event = copy.copy(event)
+ event.guiEvent = None
+ event.inaxes = self.ax
+ try:
+ event.xdata, event.ydata = (
+ self.ax.transData.inverted().transform((event.x, event.y)))
+ except ValueError: # cf LocationEvent._set_inaxes.
+ event.xdata = event.ydata = None
+ return func(self, event)
+
+ return wrapper
class Button(AxesWidget):
@@ -195,7 +246,7 @@ def __init__(self, ax, label, image=None,
horizontalalignment='center',
transform=ax.transAxes)
- self._useblit = useblit and self.canvas.supports_blit
+ self._useblit = useblit
self._observers = cbook.CallbackRegistry(signals=["clicked"])
@@ -209,12 +260,14 @@ def __init__(self, ax, label, image=None,
self.color = color
self.hovercolor = hovercolor
+ @_call_with_reparented_event
def _click(self, event):
if not self.eventson or self.ignore(event) or not self.ax.contains(event)[0]:
return
if event.canvas.mouse_grabber != self.ax:
event.canvas.grab_mouse(self.ax)
+ @_call_with_reparented_event
def _release(self, event):
if self.ignore(event) or event.canvas.mouse_grabber != self.ax:
return
@@ -222,6 +275,7 @@ def _release(self, event):
if self.eventson and self.ax.contains(event)[0]:
self._observers.process('clicked', event)
+ @_call_with_reparented_event
def _motion(self, event):
if self.ignore(event):
return
@@ -229,7 +283,7 @@ def _motion(self, event):
if not colors.same_color(c, self.ax.get_facecolor()):
self.ax.set_facecolor(c)
if self.drawon:
- if self._useblit:
+ if self._useblit and self.canvas.supports_blit:
self.ax.draw_artist(self.ax)
self.canvas.blit(self.ax.bbox)
else:
@@ -364,8 +418,9 @@ def __init__(self, ax, label, valmin, valmax, *, valinit=0.5, valfmt=None,
The slider initial position.
valfmt : str, default: None
- %-format string used to format the slider value. If None, a
- `.ScalarFormatter` is used instead.
+ The way to format the slider value. If a string, it must be in %-format.
+ If a callable, it must have the signature ``valfmt(val: float) -> str``.
+ If None, a `.ScalarFormatter` is used.
closedmin : bool, default: True
Whether the slider interval is closed on the bottom.
@@ -520,6 +575,7 @@ def _value_in_bounds(self, val):
val = self.slidermax.val
return val
+ @_call_with_reparented_event
def _update(self, event):
"""Update the slider position."""
if self.ignore(event) or event.button != 1:
@@ -538,16 +594,18 @@ def _update(self, event):
event.canvas.release_mouse(self.ax)
return
- xdata, ydata = self._get_data_coords(event)
val = self._value_in_bounds(
- xdata if self.orientation == 'horizontal' else ydata)
+ event.xdata if self.orientation == 'horizontal' else event.ydata)
if val not in [None, self.val]:
self.set_val(val)
def _format(self, val):
"""Pretty-print *val*."""
if self.valfmt is not None:
- return self.valfmt % val
+ if callable(self.valfmt):
+ return self.valfmt(val)
+ else:
+ return self.valfmt % val
else:
_, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
# fmt.get_offset is actually the multiplicative factor, if any.
@@ -644,9 +702,11 @@ def __init__(
The initial positions of the slider. If None the initial positions
will be at the 25th and 75th percentiles of the range.
- valfmt : str, default: None
- %-format string used to format the slider values. If None, a
- `.ScalarFormatter` is used instead.
+ valfmt : str or callable, default: None
+ The way to format the range's minimal and maximal values. If a
+ string, it must be in %-format. If a callable, it must have the
+ signature ``valfmt(val: float) -> str``. If None, a
+ `.ScalarFormatter` is used.
closedmin : bool, default: True
Whether the slider interval is closed on the bottom.
@@ -853,6 +913,7 @@ def _update_val_from_pos(self, pos):
else:
self._active_handle.set_xdata([val])
+ @_call_with_reparented_event
def _update(self, event):
"""Update the slider position."""
if self.ignore(event) or event.button != 1:
@@ -873,11 +934,10 @@ def _update(self, event):
return
# determine which handle was grabbed
- xdata, ydata = self._get_data_coords(event)
handle_index = np.argmin(np.abs(
- [h.get_xdata()[0] - xdata for h in self._handles]
+ [h.get_xdata()[0] - event.xdata for h in self._handles]
if self.orientation == "horizontal" else
- [h.get_ydata()[0] - ydata for h in self._handles]))
+ [h.get_ydata()[0] - event.ydata for h in self._handles]))
handle = self._handles[handle_index]
# these checks ensure smooth behavior if the handles swap which one
@@ -885,12 +945,16 @@ def _update(self, event):
if handle is not self._active_handle:
self._active_handle = handle
- self._update_val_from_pos(xdata if self.orientation == "horizontal" else ydata)
+ self._update_val_from_pos(
+ event.xdata if self.orientation == "horizontal" else event.ydata)
def _format(self, val):
"""Pretty-print *val*."""
if self.valfmt is not None:
- return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
+ if callable(self.valfmt):
+ return f"({self.valfmt(val[0])}, {self.valfmt(val[1])})"
+ else:
+ return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
else:
_, s1, s2, _ = self._fmt.format_ticks(
[self.valmin, *val, self.valmax]
@@ -1010,8 +1074,11 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
.. versionadded:: 3.7
- label_props : dict, optional
- Dictionary of `.Text` properties to be used for the labels.
+ label_props : dict of lists, optional
+ Dictionary of `.Text` properties to be used for the labels. Each
+ dictionary value should be a list of at least a single element. If
+ the list is of length M, its values are cycled such that the Nth
+ label gets the (N mod M) property.
.. versionadded:: 3.7
frame_props : dict, optional
@@ -1039,8 +1106,7 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
if actives is None:
actives = [False] * len(labels)
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
+ self._useblit = useblit
ys = np.linspace(1, 0, len(labels)+2)[1:-1]
@@ -1068,7 +1134,10 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
**cbook.normalize_kwargs(check_props, collections.PathCollection),
'marker': 'x',
'transform': ax.transAxes,
- 'animated': self._useblit,
+ 'animated': self._useblit and self.canvas.supports_blit,
+ # TODO: This may need an update when switching out the canvas.
+ # Can set this to `_useblit` only and live with the animated=True
+ # overhead on unsupported backends.
}
check_props.setdefault('facecolor', check_props.pop('color', 'black'))
self._checks = ax.scatter([0.15] * len(ys), ys, **check_props)
@@ -1087,9 +1156,11 @@ def _clear(self, event):
"""Internal event handler to clear the buttons."""
if self.ignore(event) or self.canvas.is_saving():
return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
+ if self._useblit and self.canvas.supports_blit:
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
self.ax.draw_artist(self._checks)
+ @_call_with_reparented_event
def _clicked(self, event):
if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
return
@@ -1111,7 +1182,8 @@ def set_label_props(self, props):
Parameters
----------
props : dict
- Dictionary of `.Text` properties to be used for the labels.
+ Dictionary of `.Text` properties to be used for the labels. Same
+ format as label_props argument of :class:`CheckButtons`.
"""
_api.check_isinstance(dict, props=props)
props = _expand_text_props(props)
@@ -1190,9 +1262,10 @@ def set_active(self, index, state=None):
self._checks.set_facecolor(facecolors)
if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
+ if self._useblit and self.canvas.supports_blit:
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self._checks)
self.canvas.blit(self.ax.bbox)
else:
@@ -1396,6 +1469,7 @@ def _rendercursor(self):
fig.canvas.draw()
+ @_call_with_reparented_event
def _release(self, event):
if self.ignore(event):
return
@@ -1403,6 +1477,7 @@ def _release(self, event):
return
event.canvas.release_mouse(self.ax)
+ @_call_with_reparented_event
def _keypress(self, event):
if self.ignore(event):
return
@@ -1485,6 +1560,7 @@ def stop_typing(self):
# call it once we've already done our cleanup.
self._observers.process('submit', self.text)
+ @_call_with_reparented_event
def _click(self, event):
if self.ignore(event):
return
@@ -1500,9 +1576,11 @@ def _click(self, event):
self.cursor_index = self.text_disp._char_index_at(event.x)
self._rendercursor()
+ @_call_with_reparented_event
def _resize(self, event):
self.stop_typing()
+ @_call_with_reparented_event
def _motion(self, event):
if self.ignore(event):
return
@@ -1579,8 +1657,11 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
.. versionadded:: 3.7
- label_props : dict or list of dict, optional
- Dictionary of `.Text` properties to be used for the labels.
+ label_props : dict of lists, optional
+ Dictionary of `.Text` properties to be used for the labels. Each
+ dictionary value should be a list of at least a single element. If
+ the list is of length M, its values are cycled such that the Nth
+ label gets the (N mod M) property.
.. versionadded:: 3.7
radio_props : dict, optional
@@ -1622,8 +1703,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
ys = np.linspace(1, 0, len(labels) + 2)[1:-1]
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
+ self._useblit = useblit
label_props = _expand_text_props(label_props)
self.labels = [
@@ -1638,7 +1718,11 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
**radio_props,
'marker': 'o',
'transform': ax.transAxes,
- 'animated': self._useblit,
+ 'animated': self._useblit and self.canvas.supports_blit,
+ # TODO: This may need an update when switching out the canvas.
+ # Can set this to `_useblit` only and live with the animated=True
+ # overhead on unsupported backends.
+
}
radio_props.setdefault('edgecolor', radio_props.get('color', 'black'))
radio_props.setdefault('facecolor',
@@ -1665,9 +1749,11 @@ def _clear(self, event):
"""Internal event handler to clear the buttons."""
if self.ignore(event) or self.canvas.is_saving():
return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
+ if self._useblit and self.canvas.supports_blit:
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
self.ax.draw_artist(self._buttons)
+ @_call_with_reparented_event
def _clicked(self, event):
if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
return
@@ -1689,7 +1775,8 @@ def set_label_props(self, props):
Parameters
----------
props : dict
- Dictionary of `.Text` properties to be used for the labels.
+ Dictionary of `.Text` properties to be used for the labels. Same
+ format as label_props argument of :class:`RadioButtons`.
"""
_api.check_isinstance(dict, props=props)
props = _expand_text_props(props)
@@ -1756,9 +1843,10 @@ def set_active(self, index):
self._buttons.set_facecolor(button_facecolors)
if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
+ if self._useblit and self.canvas.supports_blit:
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self._buttons)
self.canvas.blit(self.ax.bbox)
else:
@@ -1907,14 +1995,13 @@ def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False,
self.visible = True
self.horizOn = horizOn
self.vertOn = vertOn
- self.useblit = useblit and self.canvas.supports_blit
+ self.useblit = useblit and self.canvas.supports_blit # TODO: make dynamic
if self.useblit:
lineprops['animated'] = True
self.lineh = ax.axhline(ax.get_ybound()[0], visible=False, **lineprops)
self.linev = ax.axvline(ax.get_xbound()[0], visible=False, **lineprops)
- self.background = None
self.needclear = False
def clear(self, event):
@@ -1922,8 +2009,9 @@ def clear(self, event):
if self.ignore(event) or self.canvas.is_saving():
return
if self.useblit:
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
+ @_call_with_reparented_event
def onmove(self, event):
"""Internal event handler to draw the cursor when the mouse moves."""
if self.ignore(event):
@@ -1938,17 +2026,17 @@ def onmove(self, event):
self.needclear = False
return
self.needclear = True
- xdata, ydata = self._get_data_coords(event)
- self.linev.set_xdata((xdata, xdata))
+ self.linev.set_xdata((event.xdata, event.xdata))
self.linev.set_visible(self.visible and self.vertOn)
- self.lineh.set_ydata((ydata, ydata))
+ self.lineh.set_ydata((event.ydata, event.ydata))
self.lineh.set_visible(self.visible and self.horizOn)
if not (self.visible and (self.vertOn or self.horizOn)):
return
# Redraw.
if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self.linev)
self.ax.draw_artist(self.lineh)
self.canvas.blit(self.ax.bbox)
@@ -1961,12 +2049,19 @@ class MultiCursor(Widget):
Provide a vertical (default) and/or horizontal line cursor shared between
multiple Axes.
+ Call signatures::
+
+ MultiCursor(axes, *, ...)
+ MultiCursor(canvas, axes, *, ...) # deprecated
+
For the cursor to remain responsive you must keep a reference to it.
Parameters
----------
canvas : object
- This parameter is entirely unused and only kept for back-compatibility.
+ This parameter is entirely unused.
+
+ .. deprecated:: 3.11
axes : list of `~matplotlib.axes.Axes`
The `~.axes.Axes` to attach the cursor to.
@@ -1993,11 +2088,25 @@ class MultiCursor(Widget):
See :doc:`/gallery/widgets/multicursor`.
"""
- def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
+ def __init__(self, *args, useblit=True, horizOn=False, vertOn=True,
**lineprops):
- # canvas is stored only to provide the deprecated .canvas attribute;
- # once it goes away the unused argument won't need to be stored at all.
- self._canvas = canvas
+ # Deprecation of canvas as the first attribute. When the deprecation expires:
+ # - change the signature to __init__(self, axes, *, ...)
+ # - delete the "Call signatures" block in the docstring
+ # - delete this block
+ kwargs = {k: lineprops.pop(k)
+ for k in list(lineprops) if k in ("canvas", "axes")}
+ params = _api.select_matching_signature(
+ [lambda axes: locals(), lambda canvas, axes: locals()], *args, **kwargs)
+ if "canvas" in params:
+ _api.warn_deprecated(
+ "3.11",
+ message="The canvas parameter in MultiCursor is unused and deprecated "
+ "since %(since)s. Please remove it and call MultiCursor(axes) "
+ "instead of MultiCursor(canvas, axes). The latter will start raising "
+ "an error in %(removal)s"
+ )
+ axes = params["axes"]
self.axes = axes
self.horizOn = horizOn
@@ -2016,6 +2125,7 @@ def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
self.useblit = (
useblit
and all(canvas.supports_blit for canvas in self._canvas_infos))
+ # TODO: make dynamic
if self.useblit:
lineprops['animated'] = True
@@ -2090,6 +2200,16 @@ def onmove(self, event):
class _SelectorWidget(AxesWidget):
+ """
+ The base class for selector widgets.
+
+ This class provides common functionality for selector widgets,
+ such as handling mouse and keyboard events, managing state modifier keys, etc.
+
+ The class itself is private and may be changed or removed without prior warning.
+ However, the public API it provides to subclasses is stable and considered
+ public on the subclasses.
+ """
def __init__(self, ax, onselect=None, useblit=False, button=None,
state_modifier_keys=None, use_data_coordinates=False):
@@ -2100,7 +2220,7 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self.onselect = lambda *args: None
else:
self.onselect = onselect
- self.useblit = useblit and self.canvas.supports_blit
+ self._useblit = useblit
self.connect_default_events()
self._state_modifier_keys = dict(move=' ', clear='escape',
@@ -2109,8 +2229,6 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self._state_modifier_keys.update(state_modifier_keys or {})
self._use_data_coordinates = use_data_coordinates
- self.background = None
-
if isinstance(button, Integral):
self.validButtons = [button]
else:
@@ -2126,6 +2244,11 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self._prev_event = None
self._state = set()
+ @property
+ def useblit(self):
+ """Return whether blitting is used (requested and supported by canvas)."""
+ return self._useblit and self.canvas.supports_blit
+
def set_active(self, active):
super().set_active(active)
if active:
@@ -2149,6 +2272,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).
@@ -2164,7 +2289,7 @@ def update_background(self, event):
for artist in artists:
stack.enter_context(artist._cm_set(visible=False))
self.canvas.draw()
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
if needs_redraw:
for artist in artists:
self.ax.draw_artist(artist)
@@ -2181,7 +2306,9 @@ def connect_default_events(self):
def ignore(self, event):
# docstring inherited
- if not self.active or not self.ax.get_visible():
+ if super().ignore(event):
+ return True
+ if not self.ax.get_visible():
return True
# If canvas was locked
if not self.canvas.widgetlock.available(self):
@@ -2209,8 +2336,9 @@ def update(self):
self.ax.get_figure(root=True)._get_renderer() is None):
return
if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
else:
self.update_background(None)
# We need to draw all artists, which are not included in the
@@ -2228,9 +2356,8 @@ def _get_data(self, event):
"""Get the xdata and ydata for event, with limits."""
if event.xdata is None:
return None, None
- xdata, ydata = self._get_data_coords(event)
- xdata = np.clip(xdata, *self.ax.get_xbound())
- ydata = np.clip(ydata, *self.ax.get_ybound())
+ xdata = np.clip(event.xdata, *self.ax.get_xbound())
+ ydata = np.clip(event.ydata, *self.ax.get_ybound())
return xdata, ydata
def _clean_event(self, event):
@@ -2250,6 +2377,7 @@ def _clean_event(self, event):
self._prev_event = event
return event
+ @_call_with_reparented_event
def press(self, event):
"""Button press handler and validator."""
if not self.ignore(event):
@@ -2268,6 +2396,7 @@ def press(self, event):
def _press(self, event):
"""Button press event handler."""
+ @_call_with_reparented_event
def release(self, event):
"""Button release event handler and validator."""
if not self.ignore(event) and self._eventpress:
@@ -2283,6 +2412,7 @@ def release(self, event):
def _release(self, event):
"""Button release event handler."""
+ @_call_with_reparented_event
def onmove(self, event):
"""Cursor move event handler and validator."""
if not self.ignore(event) and self._eventpress:
@@ -2294,6 +2424,7 @@ def onmove(self, event):
def _onmove(self, event):
"""Cursor move event handler."""
+ @_call_with_reparented_event
def on_scroll(self, event):
"""Mouse scroll event handler and validator."""
if not self.ignore(event):
@@ -2302,6 +2433,7 @@ def on_scroll(self, event):
def _on_scroll(self, event):
"""Mouse scroll event handler."""
+ @_call_with_reparented_event
def on_key_press(self, event):
"""Key press event handler and validator for all selection widgets."""
if self.active:
@@ -2326,6 +2458,7 @@ def on_key_press(self, event):
def _on_key_press(self, event):
"""Key press event handler - for widget-specific key press actions."""
+ @_call_with_reparented_event
def on_key_release(self, event):
"""Key release event handler and validator."""
if self.active:
@@ -2381,7 +2514,7 @@ def set_props(self, **props):
def set_handle_props(self, **handle_props):
"""
Set the properties of the handles selector artist. See the
- `handle_props` argument in the selector docstring to know which
+ *handle_props* argument in the selector docstring to know which
properties are supported.
"""
if not hasattr(self, '_handles_artists'):
@@ -2405,13 +2538,15 @@ def _validate_state(self, state):
def add_state(self, state):
"""
Add a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Parameters
----------
state : str
Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Raises
------
@@ -2425,13 +2560,15 @@ def add_state(self, state):
def remove_state(self, state):
"""
Remove a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Parameters
----------
state : str
Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Raises
------
@@ -2543,7 +2680,14 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False,
if props is None:
props = dict(facecolor='red', alpha=0.5)
- props['animated'] = self.useblit
+ # Note: We set this based on the user setting during ínitialization,
+ # not on the actual capability of blitting. But the value is
+ # irrelevant if the backend does not support blitting, so that
+ # we don't have to dynamically update this on the backend.
+ # This relies on the current behavior that the request for
+ # useblit is fixed during initialization and cannot be changed
+ # afterwards.
+ props['animated'] = self._useblit
self.direction = direction
self._extents_on_press = None
@@ -2609,7 +2753,7 @@ def _setup_edge_handles(self, props):
self._edge_handles = ToolLineHandles(self.ax, positions,
direction=self.direction,
line_props=props,
- useblit=self.useblit)
+ useblit=self._useblit)
@property
def _handles_artists(self):
@@ -2618,7 +2762,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
@@ -2627,7 +2771,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
@@ -2637,7 +2781,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:
@@ -2647,8 +2791,7 @@ def _press(self, event):
# Clear previous rectangle before drawing new rectangle.
self.update()
- xdata, ydata = self._get_data_coords(event)
- v = xdata if self.direction == 'horizontal' else ydata
+ v = event.xdata if self.direction == 'horizontal' else event.ydata
if self._active_handle is None and not self.ignore_event_outside:
# when the press event outside the span, we initially set the
@@ -2685,9 +2828,10 @@ def direction(self, direction):
else:
self._direction = direction
+ @_call_with_reparented_event
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)
@@ -2716,6 +2860,7 @@ def _release(self, event):
return False
+ @_call_with_reparented_event
def _hover(self, event):
"""Update the canvas cursor if it's over a handle."""
if self.ignore(event):
@@ -2729,17 +2874,16 @@ 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."""
- xdata, ydata = self._get_data_coords(event)
if self.direction == 'horizontal':
- v = xdata
+ v = event.xdata
vpress = self._eventpress.xdata
else:
- v = ydata
+ v = event.ydata
vpress = self._eventpress.ydata
# move existing span
@@ -3054,7 +3198,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent)
(when already existing) or cancelled.
minspany : float, default: 0
- Selections with an y-span less than or equal to *minspanx* are removed
+ Selections with a y-span less than or equal to *minspanx* are removed
(when already existing) or cancelled.
useblit : bool, default: False
@@ -3120,6 +3264,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):
@@ -3176,7 +3327,7 @@ def __init__(self, ax, onselect=None, *, minspanx=0,
if props is None:
props = dict(facecolor='red', edgecolor='black',
alpha=0.2, fill=True)
- props = {**props, 'animated': self.useblit}
+ props = {**props, 'animated': self._useblit}
self._visible = props.pop('visible', self._visible)
to_draw = self._init_shape(**props)
self.ax.add_patch(to_draw)
@@ -3201,18 +3352,18 @@ def __init__(self, ax, onselect=None, *, minspanx=0,
xc, yc = self.corners
self._corner_handles = ToolHandles(self.ax, xc, yc,
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
self._edge_order = ['W', 'S', 'E', 'N']
xe, ye = self.edge_centers
self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s',
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
xc, yc = self.center
self._center_handle = ToolHandles(self.ax, [xc], [yc], marker='s',
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
self._active_handle = None
@@ -3242,9 +3393,8 @@ def _press(self, event):
if (self._active_handle is None and not self.ignore_event_outside and
self._allow_creation):
- x, y = self._get_data_coords(event)
self._visible = False
- self.extents = x, x, y, y
+ self.extents = event.xdata, event.xdata, event.ydata, event.ydata
self._visible = True
else:
self.set_visible(True)
@@ -3253,10 +3403,24 @@ 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
+ @_call_with_reparented_event
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)
@@ -3300,9 +3464,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.
@@ -3317,12 +3492,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:
+ xdata, ydata = event.xdata, event.ydata
+ 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(
@@ -3342,7 +3515,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
@@ -3351,7 +3524,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)
@@ -3402,7 +3575,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
@@ -3697,7 +3870,7 @@ def __init__(self, ax, onselect=None, *, useblit=True, props=None, button=None):
**(props if props is not None else {}),
# Note that self.useblit may be != useblit, if the canvas doesn't
# support blitting.
- 'animated': self.useblit, 'visible': False,
+ 'animated': self._useblit, 'visible': False,
}
line = Line2D([], [], **props)
self.ax.add_line(line)
@@ -3707,6 +3880,7 @@ def _press(self, event):
self.verts = [self._get_data(event)]
self._selection_artist.set_visible(True)
+ @_call_with_reparented_event
def _release(self, event):
if self.verts is not None:
self.verts.append(self._get_data(event))
@@ -3821,7 +3995,7 @@ def __init__(self, ax, onselect=None, *, useblit=False,
if props is None:
props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5)
- props = {**props, 'animated': self.useblit}
+ props = {**props, 'animated': self._useblit}
self._selection_artist = line = Line2D([], [], **props)
self.ax.add_line(line)
@@ -3830,7 +4004,7 @@ def __init__(self, ax, onselect=None, *, useblit=False,
markerfacecolor=props.get('color', 'k'))
self._handle_props = handle_props
self._polygon_handles = ToolHandles(self.ax, [], [],
- useblit=self.useblit,
+ useblit=self._useblit,
marker_props=self._handle_props)
self._active_handle_idx = -1
@@ -3850,7 +4024,7 @@ def _get_bbox(self):
def _add_box(self):
self._box = RectangleSelector(self.ax,
- useblit=self.useblit,
+ useblit=self._useblit,
grab_range=self.grab_range,
handle_props=self._box_handle_props,
props=self._box_props,
@@ -3877,6 +4051,7 @@ def _update_box(self):
# Save a copy
self._old_box_extents = self._box.extents
+ @_call_with_reparented_event
def _scale_polygon(self, event):
"""
Scale the polygon selector points when the bounding box is moved or
@@ -3941,6 +4116,7 @@ def _press(self, event):
# support the 'move_all' state modifier).
self._xys_at_press = self._xys.copy()
+ @_call_with_reparented_event
def _release(self, event):
"""Button release event handler."""
# Release active tool handle.
@@ -3960,11 +4136,12 @@ def _release(self, event):
elif (not self._selection_completed
and 'move_all' not in self._state
and 'move_vertex' not in self._state):
- self._xys.insert(-1, self._get_data_coords(event))
+ self._xys.insert(-1, (event.xdata, event.ydata))
if self._selection_completed:
self.onselect(self.verts)
+ @_call_with_reparented_event
def onmove(self, event):
"""Cursor move event handler and validator."""
# Method overrides _SelectorWidget.onmove because the polygon selector
@@ -3988,17 +4165,16 @@ def _onmove(self, event):
# Move the active vertex (ToolHandle).
if self._active_handle_idx >= 0:
idx = self._active_handle_idx
- self._xys[idx] = self._get_data_coords(event)
+ self._xys[idx] = (event.xdata, event.ydata)
# Also update the end of the polygon line if the first vertex is
# the active handle and the polygon is completed.
if idx == 0 and self._selection_completed:
- self._xys[-1] = self._get_data_coords(event)
+ self._xys[-1] = (event.xdata, event.ydata)
# Move all vertices.
elif 'move_all' in self._state and self._eventpress:
- xdata, ydata = self._get_data_coords(event)
- dx = xdata - self._eventpress.xdata
- dy = ydata - self._eventpress.ydata
+ dx = event.xdata - self._eventpress.xdata
+ dy = event.ydata - self._eventpress.ydata
for k in range(len(self._xys)):
x_at_press, y_at_press = self._xys_at_press[k]
self._xys[k] = x_at_press + dx, y_at_press + dy
@@ -4018,7 +4194,7 @@ def _onmove(self, event):
if len(self._xys) > 3 and v0_dist < self.grab_range:
self._xys[-1] = self._xys[0]
else:
- self._xys[-1] = self._get_data_coords(event)
+ self._xys[-1] = (event.xdata, event.ydata)
self._draw_polygon()
@@ -4040,12 +4216,12 @@ def _on_key_release(self, event):
and
(event.key == self._state_modifier_keys.get('move_vertex')
or event.key == self._state_modifier_keys.get('move_all'))):
- self._xys.append(self._get_data_coords(event))
+ self._xys.append((event.xdata, event.ydata))
self._draw_polygon()
# Reset the polygon if the released key is the 'clear' key.
elif event.key == self._state_modifier_keys.get('clear'):
event = self._clean_event(event)
- self._xys = [self._get_data_coords(event)]
+ self._xys = [(event.xdata, event.ydata)]
self._selection_completed = False
self._remove_box()
self.set_visible(True)
@@ -4130,7 +4306,7 @@ class Lasso(AxesWidget):
def __init__(self, ax, xy, callback, *, useblit=True, props=None):
super().__init__(ax)
- self.useblit = useblit and self.canvas.supports_blit
+ self.useblit = useblit and self.canvas.supports_blit # TODO: Make dynamic
if self.useblit:
self.background = self.canvas.copy_from_bbox(self.ax.bbox)
@@ -4147,24 +4323,26 @@ def __init__(self, ax, xy, callback, *, useblit=True, props=None):
self.connect_event('button_release_event', self.onrelease)
self.connect_event('motion_notify_event', self.onmove)
+ @_call_with_reparented_event
def onrelease(self, event):
if self.ignore(event):
return
if self.verts is not None:
- self.verts.append(self._get_data_coords(event))
+ self.verts.append((event.xdata, event.ydata))
if len(self.verts) > 2:
self.callback(self.verts)
self.line.remove()
self.verts = None
self.disconnect_events()
+ @_call_with_reparented_event
def onmove(self, event):
if (self.ignore(event)
or self.verts is None
or event.button != 1
or not self.ax.contains(event)[0]):
return
- self.verts.append(self._get_data_coords(event))
+ self.verts.append((event.xdata, event.ydata))
self.line.set_data(list(zip(*self.verts)))
if self.useblit:
diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi
index 0fcd1990e17e..2f34255d625c 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
@@ -34,10 +35,12 @@ class Widget:
class AxesWidget(Widget):
ax: Axes
def __init__(self, ax: Axes) -> None: ...
+ def __del__(self) -> None: ...
@property
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
@@ -64,7 +67,7 @@ class SliderBase(AxesWidget):
valmax: float
valstep: float | ArrayLike | None
drag_active: bool
- valfmt: str
+ valfmt: str | Callable[[float], str] | None
def __init__(
self,
ax: Axes,
@@ -73,7 +76,7 @@ class SliderBase(AxesWidget):
closedmax: bool,
valmin: float,
valmax: float,
- valfmt: str,
+ valfmt: str | Callable[[float], str] | None,
dragging: Slider | None,
valstep: float | ArrayLike | None,
) -> None: ...
@@ -130,7 +133,7 @@ class RangeSlider(SliderBase):
valmax: float,
*,
valinit: tuple[float, float] | None = ...,
- valfmt: str | None = ...,
+ valfmt: str | Callable[[float], str] | None = ...,
closedmin: bool = ...,
closedmax: bool = ...,
dragging: bool = ...,
@@ -154,11 +157,11 @@ class CheckButtons(AxesWidget):
actives: Iterable[bool] | None = ...,
*,
useblit: bool = ...,
- label_props: dict[str, Any] | None = ...,
+ label_props: dict[str, Sequence[Any]] | None = ...,
frame_props: dict[str, Any] | None = ...,
check_props: dict[str, Any] | None = ...,
) -> None: ...
- def set_label_props(self, props: dict[str, Any]) -> None: ...
+ def set_label_props(self, props: dict[str, Sequence[Any]]) -> None: ...
def set_frame_props(self, props: dict[str, Any]) -> None: ...
def set_check_props(self, props: dict[str, Any]) -> None: ...
def set_active(self, index: int, state: bool | None = ...) -> None: ... # type: ignore[override]
@@ -208,10 +211,10 @@ class RadioButtons(AxesWidget):
activecolor: ColorType | None = ...,
*,
useblit: bool = ...,
- label_props: dict[str, Any] | Sequence[dict[str, Any]] | None = ...,
+ label_props: dict[str, Sequence[Any]] | None = ...,
radio_props: dict[str, Any] | None = ...,
) -> None: ...
- def set_label_props(self, props: dict[str, Any]) -> None: ...
+ def set_label_props(self, props: dict[str, Sequence[Any]]) -> None: ...
def set_radio_props(self, props: dict[str, Any]) -> None: ...
def set_active(self, index: int) -> None: ...
def clear(self) -> None: ...
@@ -270,7 +273,7 @@ class MultiCursor(Widget):
class _SelectorWidget(AxesWidget):
onselect: Callable[[float, float], Any]
- useblit: bool
+ _useblit: bool
background: Any
validButtons: list[MouseButton]
def __init__(
@@ -282,6 +285,8 @@ class _SelectorWidget(AxesWidget):
state_modifier_keys: dict[str, str] | None = ...,
use_data_coordinates: bool = ...,
) -> None: ...
+ @property
+ def useblit(self) -> bool: ...
def update_background(self, event: Event) -> None: ...
def connect_default_events(self) -> None: ...
def ignore(self, event: Event) -> bool: ...
@@ -335,6 +340,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 +404,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 64bc8f465f19..b26c87edce1c 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_grid.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py
@@ -84,8 +84,8 @@ def __init__(self, fig,
``121``), or as a `~.SubplotSpec`.
nrows_ncols : (int, int)
Number of rows and columns in the grid.
- n_axes : int or None, default: None
- If not None, only the first *n_axes* axes in the grid are created.
+ n_axes : int, optional
+ If given, only the first *n_axes* axes in the grid are created.
direction : {"row", "column"}, default: "row"
Whether axes are created in row-major ("row by row") or
column-major order ("column by column"). This also affects the
@@ -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):
@@ -322,8 +322,8 @@ def __init__(self, fig,
as a three-digit subplot position code (e.g., "121").
nrows_ncols : (int, int)
Number of rows and columns in the grid.
- n_axes : int or None, default: None
- If not None, only the first *n_axes* axes in the grid are created.
+ n_axes : int, optional
+ If given, only the first *n_axes* axes in the grid are created.
direction : {"row", "column"}, default: "row"
Whether axes are created in row-major ("row by row") or
column-major order ("column by column"). This also affects the
@@ -364,7 +364,7 @@ def __init__(self, fig,
cbar_set_cax : bool, default: True
If True, each axes in the grid has a *cax* attribute that is bound
to associated *cbar_axes*.
- axes_class : subclass of `matplotlib.axes.Axes`, default: None
+ axes_class : subclass of `matplotlib.axes.Axes`, default: `.mpl_axes.Axes`
"""
_api.check_in_list(["each", "single", "edge", None],
cbar_mode=cbar_mode)
diff --git a/lib/mpl_toolkits/axes_grid1/inset_locator.py b/lib/mpl_toolkits/axes_grid1/inset_locator.py
index 52fe6efc0618..a1a9cc8df591 100644
--- a/lib/mpl_toolkits/axes_grid1/inset_locator.py
+++ b/lib/mpl_toolkits/axes_grid1/inset_locator.py
@@ -341,11 +341,16 @@ def inset_axes(parent_axes, width, height, loc='upper right',
%(Axes:kwdoc)s
- borderpad : float, default: 0.5
+ borderpad : float or (float, float), default: 0.5
Padding between inset axes and the bbox_to_anchor.
+ If a float, the same padding is used for both x and y.
+ If a tuple of two floats, it specifies the (x, y) padding.
The units are axes font size, i.e. for a default font size of 10 points
*borderpad = 0.5* is equivalent to a padding of 5 points.
+ .. versionadded:: 3.11
+ The *borderpad* parameter now accepts a tuple of (x, y) paddings.
+
Returns
-------
inset_axes : *axes_class*
diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png
index c7ad1e64b84d..17a1460f6be4 100644
Binary files a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png differ
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/axisartist/axislines.py b/lib/mpl_toolkits/axisartist/axislines.py
index c921ea597cb4..e0057d4f6c1e 100644
--- a/lib/mpl_toolkits/axisartist/axislines.py
+++ b/lib/mpl_toolkits/axisartist/axislines.py
@@ -16,7 +16,7 @@
In the new axes class, xaxis and yaxis is set to not visible by
default, and new set of artist (AxisArtist) are defined to draw axis
line, ticks, ticklabels and axis label. Axes.axis attribute serves as
-a dictionary of these artists, i.e., ax.axis["left"] is a AxisArtist
+a dictionary of these artists, i.e., ax.axis["left"] is an AxisArtist
instance responsible to draw left y-axis. The default Axes.axis contains
"bottom", "left", "top" and "right".
diff --git a/lib/mpl_toolkits/axisartist/grid_finder.py b/lib/mpl_toolkits/axisartist/grid_finder.py
index e51d4912c732..b984c18cab6c 100644
--- a/lib/mpl_toolkits/axisartist/grid_finder.py
+++ b/lib/mpl_toolkits/axisartist/grid_finder.py
@@ -169,13 +169,23 @@ def _format_ticks(self, idx, direction, factor, levels):
return (fmt.format_ticks(levels) if isinstance(fmt, mticker.Formatter)
else fmt(direction, factor, levels))
- def get_grid_info(self, x1, y1, x2, y2):
+ def get_grid_info(self, *args, **kwargs):
"""
- lon_values, lat_values : list of grid values. if integer is given,
- rough number of grids in each direction.
+ Compute positioning information for grid lines and ticks, given the
+ axes' data *bbox*.
"""
+ params = _api.select_matching_signature(
+ [lambda x1, y1, x2, y2: locals(), lambda bbox: locals()], *args, **kwargs)
+ if "x1" in params:
+ _api.warn_deprecated("3.11", message=(
+ "Passing extents as separate arguments to get_grid_info is deprecated "
+ "since %(since)s and support will be removed %(removal)s; pass a "
+ "single bbox instead."))
+ bbox = Bbox.from_extents(
+ params["x1"], params["y1"], params["x2"], params["y2"])
+ else:
+ bbox = params["bbox"]
- bbox = Bbox.from_extents(x1, y1, x2, y2)
tbbox = self.extreme_finder._find_transformed_bbox(
self.get_transform().inverted(), bbox)
diff --git a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
index 1e27b3f571f3..aa37a3680fa5 100644
--- a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
+++ b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
@@ -341,7 +341,7 @@ def new_floating_axis(self, nth_coord, value, axes=None, axis_direction="bottom"
return axisline
def _update_grid(self, bbox):
- self._grid_info = self.grid_finder.get_grid_info(*bbox.extents)
+ self._grid_info = self.grid_finder.get_grid_info(bbox)
def get_gridlines(self, which="major", axis="both"):
grid_lines = []
diff --git a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
index d44a61b6dd4a..96d8a2cde0f3 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
@@ -24,7 +24,8 @@ def test_ticks():
ax.add_artist(ticks_out)
-@image_comparison(['axis_artist_labelbase.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist_labelbase.png'], style='default', tol=0.02)
def test_labelbase():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -41,7 +42,8 @@ def test_labelbase():
ax.add_artist(label)
-@image_comparison(['axis_artist_ticklabels.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist_ticklabels.png'], style='default', tol=0.03)
def test_ticklabels():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -76,7 +78,8 @@ def test_ticklabels():
ax.set_ylim(0, 1)
-@image_comparison(['axis_artist.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist.png'], style='default', tol=0.03)
def test_axis_artist():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
index a1485d4f436b..a47ab2ea8a31 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
@@ -7,7 +7,8 @@
from mpl_toolkits.axisartist import Axes, SubplotHost
-@image_comparison(['SubplotZero.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['SubplotZero.png'], style='default', tol=0.02)
def test_SubplotZero():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -28,7 +29,8 @@ def test_SubplotZero():
ax.set_ylabel("Test")
-@image_comparison(['Subplot.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['Subplot.png'], style='default', tol=0.02)
def test_Subplot():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -130,7 +132,8 @@ def test_axisline_style_tight():
ax.axis[direction].set_visible(False)
-@image_comparison(['subplotzero_ylabel.png'], style='mpl20')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['subplotzero_ylabel.png'], style='mpl20', tol=0.02)
def test_subplotzero_ylabel():
fig = plt.figure()
ax = fig.add_subplot(111, axes_class=SubplotZero)
diff --git a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
index 7d6554782fe6..ac31b8b30c97 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
@@ -76,7 +76,8 @@ def inverted(self):
ax1.grid(True)
-@image_comparison(['polar_box.png'], style='default', tol=0.04)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['polar_box.png'], style='default', tol=0.09)
def test_polar_box():
fig = plt.figure(figsize=(5, 5))
@@ -136,7 +137,7 @@ def test_polar_box():
# Remove tol & kerning_factor when this test image is regenerated.
-@image_comparison(['axis_direction.png'], style='default', tol=0.13)
+@image_comparison(['axis_direction.png'], style='default', tol=0.15)
def test_axis_direction():
plt.rcParams['text.kerning_factor'] = 6
diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py
index 483fd09be163..d06d157db4ce 100644
--- a/lib/mpl_toolkits/mplot3d/art3d.py
+++ b/lib/mpl_toolkits/mplot3d/art3d.py
@@ -58,11 +58,11 @@ def get_dir_vector(zdir):
x, y, z : array
The direction vector.
"""
- if zdir == 'x':
+ if cbook._str_equal(zdir, 'x'):
return np.array((1, 0, 0))
- elif zdir == 'y':
+ elif cbook._str_equal(zdir, 'y'):
return np.array((0, 1, 0))
- elif zdir == 'z':
+ elif cbook._str_equal(zdir, 'z'):
return np.array((0, 0, 1))
elif zdir is None:
return np.array((0, 0, 0))
@@ -123,6 +123,16 @@ class Text3D(mtext.Text):
def __init__(self, x=0, y=0, z=0, text='', zdir='z', axlim_clip=False,
**kwargs):
+ if 'rotation' in kwargs:
+ _api.warn_external(
+ "The `rotation` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
+ if 'rotation_mode' in kwargs:
+ _api.warn_external(
+ "The `rotation_mode` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
mtext.Text.__init__(self, x, y, text, **kwargs)
self.set_3d_properties(z, zdir, axlim_clip)
@@ -737,9 +747,8 @@ def set_depthshade(
depthshade : bool
Whether to shade the patches in order to give the appearance of
depth.
- depthshade_minalpha : float, default: None
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
Sets the minimum alpha value used by depth-shading.
- If None, use the value from rcParams['axes3d.depthshade_minalpha'].
.. versionadded:: 3.11
"""
@@ -1112,17 +1121,15 @@ def patch_collection_2d_to_3d(
zdir : {'x', 'y', 'z'}
The axis in which to place the patches. Default: "z".
See `.get_dir_vector` for a description of the values.
- depthshade : bool, default: None
+ depthshade : bool, default: :rc:`axes3d.depthshade`
Whether to shade the patches to give a sense of depth.
- If None, use the value from rcParams['axes3d.depthshade'].
axlim_clip : bool, default: False
Whether to hide patches with a vertex outside the axes view limits.
.. versionadded:: 3.10
- depthshade_minalpha : float, default: None
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
Sets the minimum alpha value used by depth-shading.
- If None, use the value from rcParams['axes3d.depthshade_minalpha'].
.. versionadded:: 3.11
"""
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index 55b204022fb9..e8b72c421cd2 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -70,7 +70,7 @@ def __init__(
----------
fig : Figure
The parent figure.
- rect : tuple (left, bottom, width, height), default: None.
+ rect : tuple (left, bottom, width, height), default: (0, 0, 1, 1)
The ``(left, bottom, width, height)`` Axes position.
elev : float, default: 30
The elevation angle in degrees rotates the camera above and below
@@ -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'):
@@ -1956,6 +1950,16 @@ def text(self, x, y, z, s, zdir=None, *, axlim_clip=False, **kwargs):
`.Text3D`
The created `.Text3D` instance.
"""
+ if 'rotation' in kwargs:
+ _api.warn_external(
+ "The `rotation` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
+ if 'rotation_mode' in kwargs:
+ _api.warn_external(
+ "The `rotation_mode` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
text = super().text(x, y, s, **kwargs)
art3d.text_2d_to_3d(text, z, zdir, axlim_clip)
return text
@@ -2049,9 +2053,10 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *,
- 'auto': If the points all lie on the same 3D plane, 'polygon' is
used. Otherwise, 'quad' is used.
- facecolors : list of :mpltype:`color`, default: None
+ facecolors : :mpltype:`color` or list of :mpltype:`color`, optional
Colors of each individual patch, or a single color to be used for
- all patches.
+ all patches. If not given, the next color from the patch color
+ cycle is used.
shade : bool, default: None
Whether to shade the facecolors. If *None*, then defaults to *True*
@@ -2133,7 +2138,7 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *,
polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade,
axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data)
return polyc
@@ -2332,7 +2337,7 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
polys, facecolors=color, shade=shade, lightsource=lightsource,
axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz(X, Y, Z, had_data)
return polyc
@@ -2458,7 +2463,7 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs):
lines = list(row_lines) + list(col_lines)
linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs)
- self.add_collection(linec)
+ self.add_collection(linec, autolim="_datalim_only")
return linec
@@ -2559,7 +2564,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
verts, *args, shade=shade, lightsource=lightsource,
facecolors=color, axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz(tri.x, tri.y, z, had_data)
return polyc
@@ -2889,8 +2894,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],
@@ -2901,7 +2908,7 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *,
# Currently unable to do so due to issues with Patch3DCollection
# See https://github.com/matplotlib/matplotlib/issues/14298 for details
- collection = super().add_collection(col)
+ collection = super().add_collection(col, autolim="_datalim_only")
return collection
@_preprocess_data(replace_names=["xs", "ys", "zs", "s",
@@ -2943,15 +2950,13 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=None,
- A 2D array in which the rows are RGB or RGBA.
For more details see the *c* argument of `~.axes.Axes.scatter`.
- depthshade : bool, default: None
+ depthshade : bool, default: :rc:`axes3d.depthshade`
Whether to shade the scatter markers to give the appearance of
depth. Each call to ``scatter()`` will perform its depthshading
independently.
- If None, use the value from rcParams['axes3d.depthshade'].
- depthshade_minalpha : float, default: None
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
The lowest alpha value applied by depth-shading.
- If None, use the value from rcParams['axes3d.depthshade_minalpha'].
.. versionadded:: 3.11
@@ -3231,7 +3236,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None,
lightsource=lightsource,
axlim_clip=axlim_clip,
*args, **kwargs)
- self.add_collection(col)
+ self.add_collection(col, autolim="_datalim_only")
self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data)
@@ -3328,7 +3333,7 @@ def calc_arrows(UVW):
if any(len(v) == 0 for v in input_args):
# No quivers, so just make an empty collection and return early
linec = art3d.Line3DCollection([], **kwargs)
- self.add_collection(linec)
+ self.add_collection(linec, autolim="_datalim_only")
return linec
shaft_dt = np.array([0., length], dtype=float)
@@ -3366,7 +3371,7 @@ def calc_arrows(UVW):
lines = []
linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs)
- self.add_collection(linec)
+ self.add_collection(linec, autolim="_datalim_only")
self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data)
@@ -3627,12 +3632,12 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
Use 'none' (case-insensitive) to plot errorbars without any data
markers.
- ecolor : :mpltype:`color`, default: None
- The color of the errorbar lines. If None, use the color of the
+ ecolor : :mpltype:`color`, optional
+ The color of the errorbar lines. If not given, use the color of the
line connecting the markers.
- elinewidth : float, default: None
- The linewidth of the errorbar lines. If None, the linewidth of
+ elinewidth : float, optional
+ The linewidth of the errorbar lines. If not given, the linewidth of
the current style is used.
capsize : float, default: :rc:`errorbar.capsize`
@@ -3897,7 +3902,7 @@ def _extract_errs(err, data, lomask, himask):
errline = art3d.Line3DCollection(np.array(coorderr).T,
axlim_clip=axlim_clip,
**eb_lines_style)
- self.add_collection(errline)
+ self.add_collection(errline, autolim="_datalim_only")
errlines.append(errline)
coorderrs.append(coorderr)
@@ -4047,7 +4052,7 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
stemlines = art3d.Line3DCollection(
lines, linestyles=linestyle, colors=linecolor, label='_nolegend_',
axlim_clip=axlim_clip)
- self.add_collection(stemlines)
+ self.add_collection(stemlines, autolim="_datalim_only")
markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_')
stem_container = StemContainer((markerline, stemlines, baseline),
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_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
index 174c12608ae9..aca943f9e0c0 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
@@ -1,15 +1,32 @@
import numpy as np
+import numpy.testing as nptest
+import pytest
import matplotlib.pyplot as plt
from matplotlib.backend_bases import MouseEvent
from mpl_toolkits.mplot3d.art3d import (
+ get_dir_vector,
Line3DCollection,
Poly3DCollection,
_all_points_on_plane,
)
+@pytest.mark.parametrize("zdir, expected", [
+ ("x", (1, 0, 0)),
+ ("y", (0, 1, 0)),
+ ("z", (0, 0, 1)),
+ (None, (0, 0, 0)),
+ ((1, 2, 3), (1, 2, 3)),
+ (np.array([4, 5, 6]), (4, 5, 6)),
+])
+def test_get_dir_vector(zdir, expected):
+ res = get_dir_vector(zdir)
+ assert isinstance(res, np.ndarray)
+ nptest.assert_array_equal(res, expected)
+
+
def test_scatter_3d_projection_conservation():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -55,7 +72,7 @@ def test_zordered_error():
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
- ax.add_collection(Line3DCollection(lc))
+ ax.add_collection(Line3DCollection(lc), autolim="_datalim_only")
ax.scatter(*pc, visible=False)
plt.draw()
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index cd45c8e33a6f..e9809ce2a106 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -3,6 +3,7 @@
import platform
import sys
+from packaging.version import parse as parse_version
import pytest
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
@@ -13,11 +14,11 @@
from matplotlib import cm
from matplotlib import colors as mcolors, patches as mpatch
from matplotlib.testing.decorators import image_comparison, check_figures_equal
-from matplotlib.testing.widgets import mock_event
from matplotlib.collections import LineCollection, PolyCollection
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
@@ -181,7 +182,8 @@ def test_bar3d_shaded():
fig.canvas.draw()
-@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20')
+@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20',
+ tol=0.01 if parse_version(np.version.version).major < 2 else 0)
def test_bar3d_notshaded():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -285,18 +287,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)
@@ -652,7 +647,8 @@ def test_surface3d():
fig.colorbar(surf, shrink=0.5, aspect=5)
-@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20', tol=0.07)
def test_surface3d_label_offset_tick_position():
plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
ax = plt.figure().add_subplot(projection="3d")
@@ -748,7 +744,8 @@ def test_surface3d_masked_strides():
ax.view_init(60, -45, 0)
-@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20', tol=0.1)
def test_text3d():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -1127,8 +1124,9 @@ def test_poly3dCollection_autoscaling():
assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333))
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@mpl3d_image_comparison(['axes3d_labelpad.png'],
- remove_text=False, style='mpl20')
+ remove_text=False, style='mpl20', tol=0.06)
def test_axes3d_labelpad():
fig = plt.figure()
ax = fig.add_axes(Axes3D(fig))
@@ -1218,7 +1216,7 @@ def _test_proj_draw_axes(M, s=1, *args, **kwargs):
fig, ax = plt.subplots(*args, **kwargs)
linec = LineCollection(lines)
- ax.add_collection(linec)
+ ax.add_collection(linec, autolim="_datalim_only")
for x, y, t in zip(txs, tys, ['o', 'x', 'y', 'z']):
ax.text(x, y, t)
@@ -2012,11 +2010,11 @@ def test_rotate(style):
ax.figure.canvas.draw()
# drag mouse to change orientation
- ax._button_press(
- mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
- ax._on_move(
- mock_event(ax, button=MouseButton.LEFT,
- xdata=s*dx*ax._pseudo_w, ydata=s*dy*ax._pseudo_h))
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 0), MouseButton.LEFT)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (s*dx*ax._pseudo_w, s*dy*ax._pseudo_h),
+ MouseButton.LEFT)._process()
ax.figure.canvas.draw()
c = np.sqrt(3)/2
@@ -2076,10 +2074,10 @@ def convert_lim(dmin, dmax):
z_center0, z_range0 = convert_lim(*ax.get_zlim3d())
# move mouse diagonally to pan along all axis.
- ax._button_press(
- mock_event(ax, button=MouseButton.MIDDLE, xdata=0, ydata=0))
- ax._on_move(
- mock_event(ax, button=MouseButton.MIDDLE, xdata=1, ydata=1))
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 0), MouseButton.MIDDLE)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (1, 1), MouseButton.MIDDLE)._process()
x_center, x_range = convert_lim(*ax.get_xlim3d())
y_center, y_range = convert_lim(*ax.get_ylim3d())
@@ -2553,11 +2551,10 @@ def test_on_move_vertical_axis(vertical_axis: str) -> None:
ax.get_figure().canvas.draw()
proj_before = ax.get_proj()
- event_click = mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=1)
- ax._button_press(event_click)
-
- event_move = mock_event(ax, button=MouseButton.LEFT, xdata=0.5, ydata=0.8)
- ax._on_move(event_move)
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 1), MouseButton.LEFT)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (.5, .8), MouseButton.LEFT)._process()
assert ax._axis_names.index(vertical_axis) == ax._vertical_axis
@@ -2691,3 +2688,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 7fd676df1e31..9ca048e18ba9 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
@@ -47,9 +47,9 @@ def test_linecollection_scaled_dashes():
lc3 = art3d.Line3DCollection(lines3, linestyles=":", lw=.5)
fig, ax = plt.subplots(subplot_kw=dict(projection='3d'))
- ax.add_collection(lc1)
- ax.add_collection(lc2)
- ax.add_collection(lc3)
+ ax.add_collection(lc1, autolim="_datalim_only")
+ ax.add_collection(lc2, autolim="_datalim_only")
+ ax.add_collection(lc3, autolim="_datalim_only")
leg = ax.legend([lc1, lc2, lc3], ['line1', 'line2', 'line 3'])
h1, h2, h3 = leg.legend_handles
@@ -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/meson.build b/meson.build
index 54249473fe8e..47244656705f 100644
--- a/meson.build
+++ b/meson.build
@@ -31,6 +31,10 @@ project(
],
)
+# Enable bug fixes in Agg
+add_project_arguments('-DMPL_FIX_AGG_IMAGE_FILTER_LUT_BUGS', language : 'cpp')
+add_project_arguments('-DMPL_FIX_AGG_INTERPOLATION_ENDPOINT_BUG', language : 'cpp')
+
cc = meson.get_compiler('c')
cpp = meson.get_compiler('cpp')
diff --git a/pyproject.toml b/pyproject.toml
index b580feff930e..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",
]
@@ -91,6 +92,7 @@ known_pydata = "numpy, matplotlib.pyplot"
known_firstparty = "matplotlib,mpl_toolkits"
sections = "FUTURE,STDLIB,THIRDPARTY,PYDATA,FIRSTPARTY,LOCALFOLDER"
force_sort_within_sections = true
+line_length = 88
[tool.ruff]
extend-exclude = [
@@ -140,6 +142,7 @@ select = [
"E",
"F",
"W",
+ "UP035",
# The following error codes require the preview mode to be enabled.
"E201",
"E202",
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..1b7af133de31 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;
@@ -569,23 +569,28 @@ class lookup_distortion
{
public:
lookup_distortion(const double *mesh, int in_width, int in_height,
- int out_width, int out_height) :
+ int out_width, int out_height, bool edge_aligned_subpixels) :
m_mesh(mesh),
m_in_width(in_width),
m_in_height(in_height),
m_out_width(out_width),
- m_out_height(out_height)
+ m_out_height(out_height),
+ m_edge_aligned_subpixels(edge_aligned_subpixels)
{}
void calculate(int* x, int* y) {
if (m_mesh) {
+ // Nearest-neighbor interpolation needs edge-aligned subpixels
+ // All other interpolation approaches need center-aligned subpixels
+ double offset = m_edge_aligned_subpixels ? 0 : 0.5;
+
double dx = double(*x) / agg::image_subpixel_scale;
double dy = double(*y) / agg::image_subpixel_scale;
if (dx >= 0 && dx < m_out_width &&
dy >= 0 && dy < m_out_height) {
const double *coord = m_mesh + (int(dy) * m_out_width + int(dx)) * 2;
- *x = int(coord[0] * agg::image_subpixel_scale);
- *y = int(coord[1] * agg::image_subpixel_scale);
+ *x = int(coord[0] * agg::image_subpixel_scale + offset);
+ *y = int(coord[1] * agg::image_subpixel_scale + offset);
}
}
}
@@ -596,6 +601,7 @@ class lookup_distortion
int m_in_height;
int m_out_width;
int m_out_height;
+ bool m_edge_aligned_subpixels;
};
@@ -781,7 +787,7 @@ void resample(
using span_conv_t = agg::span_converter;
using nn_renderer_t = agg::renderer_scanline_aa;
lookup_distortion dist(
- params.transform_mesh, in_width, in_height, out_width, out_height);
+ params.transform_mesh, in_width, in_height, out_width, out_height, true);
arbitrary_interpolator_t interpolator(inverted, dist);
span_gen_t span_gen(input_accessor, interpolator);
span_conv_t span_conv(span_gen, conv_alpha);
@@ -806,7 +812,7 @@ void resample(
using span_conv_t = agg::span_converter;
using int_renderer_t = agg::renderer_scanline_aa;
lookup_distortion dist(
- params.transform_mesh, in_width, in_height, out_width, out_height);
+ params.transform_mesh, in_width, in_height, out_width, out_height, false);
arbitrary_interpolator_t interpolator(inverted, dist);
span_gen_t span_gen(input_accessor, interpolator, filter);
span_conv_t span_conv(span_gen, conv_alpha);
diff --git a/src/_image_wrapper.cpp b/src/_image_wrapper.cpp
index 6528c4a9270c..c062ef14a8f1 100644
--- a/src/_image_wrapper.cpp
+++ b/src/_image_wrapper.cpp
@@ -167,12 +167,17 @@ image_resample(py::array input_array,
if (is_affine) {
convert_trans_affine(transform, params.affine);
- params.is_affine = true;
- } else {
+ // If affine parameters will make subpixels visible, treat as nonaffine instead
+ if (params.affine.sx >= agg::image_subpixel_scale / 2 || params.affine.sy >= agg::image_subpixel_scale / 2) {
+ is_affine = false;
+ params.affine = agg::trans_affine(); // reset to identity affine parameters
+ }
+ }
+ if (!is_affine) {
transform_mesh = _get_transform_mesh(transform, output_array.shape());
params.transform_mesh = transform_mesh.data();
- params.is_affine = false;
}
+ params.is_affine = is_affine;
}
if (auto resampler =
diff --git a/src/_macosx.m b/src/_macosx.m
index 1372157bc80d..9ca6c0749322 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;
}
@@ -1003,7 +1034,7 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
// Make it a zero-width box if we don't have enough room
rect.size.width = fmax(bounds.size.width - rect.origin.x, 0);
rect.origin.x = bounds.size.width - rect.size.width;
- NSTextView* messagebox = [[[NSTextView alloc] initWithFrame: rect] autorelease];
+ NSTextView* messagebox = [[NSTextView alloc] initWithFrame: rect];
messagebox.textContainer.maximumNumberOfLines = 2;
messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
messagebox.alignment = NSTextAlignmentRight;
@@ -1013,7 +1044,6 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
/* if selectable, the messagebox can become first responder,
* which is not supposed to happen */
[[window contentView] addSubview: messagebox];
- [messagebox release];
[[window contentView] display];
self->messagebox = messagebox;
@@ -1024,6 +1054,7 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
NavigationToolbar2_dealloc(NavigationToolbar2 *self)
{
[self->handler release];
+ [self->messagebox release];
Py_TYPE(self)->tp_free((PyObject*)self);
}
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 3be7d6ca21e4..07b67a3e04ee 100644
--- a/tools/cache_zenodo_svg.py
+++ b/tools/cache_zenodo_svg.py
@@ -63,6 +63,11 @@ 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",
"v3.10.0": "14464227",
"v3.9.4": "14436121",
"v3.9.3": "14249941",
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",
diff --git a/tools/triage_tests.py b/tools/triage_tests.py
index 5153b1c712cb..6df720f29d2b 100644
--- a/tools/triage_tests.py
+++ b/tools/triage_tests.py
@@ -263,7 +263,7 @@ def __init__(self, path, root, source):
]
self.thumbnails = [self.dir / x for x in self.thumbnails]
- if not Path(self.destdir, self.generated).exists():
+ if self.destdir is None or not Path(self.destdir, self.generated).exists():
# This case arises from a check_figures_equal test.
self.status = 'autogen'
elif ((self.dir / self.generated).read_bytes()
@@ -281,7 +281,6 @@ def get_dest_dir(self, reldir):
path = self.source / baseline_dir / reldir
if path.is_dir():
return path
- raise ValueError(f"Can't find baseline dir for {reldir}")
@property
def display(self):