From be9f7883174934312a4494e21f7d728082955fa1 Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Wed, 10 Sep 2025 21:23:30 -0400 Subject: [PATCH 01/13] Improving error message for width and position type mismatch in violinplot --- lib/matplotlib/axes/_axes.py | 15 +++ .../tests/test_violinplot_datetime.py | 95 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 lib/matplotlib/tests/test_violinplot_datetime.py diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 34b85464f841..265f512c19db 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1,3 +1,4 @@ +import datetime import functools import itertools import logging @@ -9049,6 +9050,20 @@ def violin(self, vpstats, positions=None, vert=None, elif len(widths) != N: raise ValueError(datashape_message.format("widths")) + # Proactive validation: if positions are datetime-like + # widths must be timedelta-like. + if any(isinstance(p, (datetime.datetime, datetime.date)) + for p in positions): + _widths = widths if not np.isscalar(widths) else [widths] * N + if any(not isinstance(w, (datetime.timedelta, np.timedelta64)) + for w in _widths): + raise TypeError( + "If positions are datetime/date values, pass widths as " + "datetime.timedelta (e.g., datetime.timedelta(days=10))" + "or numpy.timedelta64." + ) + + # Validate side _api.check_in_list(["both", "low", "high"], side=side) diff --git a/lib/matplotlib/tests/test_violinplot_datetime.py b/lib/matplotlib/tests/test_violinplot_datetime.py new file mode 100644 index 000000000000..ddfb5a411b26 --- /dev/null +++ b/lib/matplotlib/tests/test_violinplot_datetime.py @@ -0,0 +1,95 @@ +""" +Unit tests for proactive validation of datetime +positions and timedelta widths in violinplot. +""" + +import datetime +import pytest + +import matplotlib.pyplot as plt + + +def make_vpstats(): + """Create minimal valid stats for a violin plot.""" + + +def violin_plot_stats(): + # Stats for violin plot + datetimes = [ + datetime.datetime(2023, 2, 10), + datetime.datetime(2023, 5, 18), + datetime.datetime(2023, 6, 6) + ] + return [{ + 'coords': datetimes, + 'vals': [0.1, 0.5, 0.2], + 'mean': datetimes[1], + 'median': datetimes[1], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }, { + 'coords': datetimes, + 'vals': [0.2, 0.3, 0.4], + 'mean': datetimes[2], + 'median': datetimes[2], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }] + + +def test_datetime_positions_with_float_widths_raises(): + """Test that datetime positions with float widths raise TypeError.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_datetime_positions_with_scalar_float_width_raises(): + """Test that datetime positions with scalar float width raise TypeError.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = 0.75 + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_numeric_positions_with_float_widths_ok(): + """Test that numeric positions with float widths work.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [1.0, 2.0] + widths = [0.5, 1.0] + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_mixed_positions_datetime_and_numeric_behaves(): + """Test that mixed datetime and numeric positions + with float widths raise TypeError. + """ + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), 2.0] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) From 806b7bef0be506ebf65f030250a381872dbf6ce3 Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Mon, 29 Sep 2025 21:48:57 -0400 Subject: [PATCH 02/13] Integrate tests in test_axes.py --- lib/matplotlib/tests/test_axes.py | 66 +++++++++++++ .../tests/test_violinplot_datetime.py | 95 ------------------- 2 files changed, 66 insertions(+), 95 deletions(-) delete mode 100644 lib/matplotlib/tests/test_violinplot_datetime.py diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index cd5689e58526..abd9b4e124e2 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4112,6 +4112,72 @@ def test_violinplot_sides(): showextrema=True, showmedians=True, side=side) +def violin_plot_stats(): + # Stats for violin plot + datetimes = [ + datetime.datetime(2023, 2, 10), + datetime.datetime(2023, 5, 18), + datetime.datetime(2023, 6, 6) + ] + return [{ + 'coords': datetimes, + 'vals': [0.1, 0.5, 0.2], + 'mean': datetimes[1], + 'median': datetimes[1], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }, { + 'coords': datetimes, + 'vals': [0.2, 0.3, 0.4], + 'mean': datetimes[2], + 'median': datetimes[2], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }] + + +def test_datetime_positions_with_float_widths_raises(): + """Test that datetime positions with float widths raise TypeError.""" + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_datetime_positions_with_scalar_float_width_raises(): + """Test that datetime positions with scalar float width raise TypeError.""" + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = 0.75 + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_numeric_positions_with_float_widths_ok(): + """Test that numeric positions with float widths work.""" + fig, ax = plt.subplots() + positions = [1.0, 2.0] + widths = [0.5, 1.0] + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_mixed_positions_datetime_and_numeric_behaves(): + """Test that mixed datetime and numeric positions + with float widths raise TypeError. + """ + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), 2.0] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + def test_violinplot_bad_positions(): ax = plt.axes() # First 9 digits of frac(sqrt(47)) diff --git a/lib/matplotlib/tests/test_violinplot_datetime.py b/lib/matplotlib/tests/test_violinplot_datetime.py deleted file mode 100644 index ddfb5a411b26..000000000000 --- a/lib/matplotlib/tests/test_violinplot_datetime.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Unit tests for proactive validation of datetime -positions and timedelta widths in violinplot. -""" - -import datetime -import pytest - -import matplotlib.pyplot as plt - - -def make_vpstats(): - """Create minimal valid stats for a violin plot.""" - - -def violin_plot_stats(): - # Stats for violin plot - datetimes = [ - datetime.datetime(2023, 2, 10), - datetime.datetime(2023, 5, 18), - datetime.datetime(2023, 6, 6) - ] - return [{ - 'coords': datetimes, - 'vals': [0.1, 0.5, 0.2], - 'mean': datetimes[1], - 'median': datetimes[1], - 'min': datetimes[0], - 'max': datetimes[-1], - 'quantiles': datetimes - }, { - 'coords': datetimes, - 'vals': [0.2, 0.3, 0.4], - 'mean': datetimes[2], - 'median': datetimes[2], - 'min': datetimes[0], - 'max': datetimes[-1], - 'quantiles': datetimes - }] - - -def test_datetime_positions_with_float_widths_raises(): - """Test that datetime positions with float widths raise TypeError.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] - widths = [0.5, 1.0] - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_datetime_positions_with_scalar_float_width_raises(): - """Test that datetime positions with scalar float width raise TypeError.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] - widths = 0.75 - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_numeric_positions_with_float_widths_ok(): - """Test that numeric positions with float widths work.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [1.0, 2.0] - widths = [0.5, 1.0] - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_mixed_positions_datetime_and_numeric_behaves(): - """Test that mixed datetime and numeric positions - with float widths raise TypeError. - """ - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), 2.0] - widths = [0.5, 1.0] - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) From c22a7a367e90b8012123ad9423d3b87e24dcdd7a Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 27 Sep 2025 15:51:59 +0200 Subject: [PATCH 03/13] Bump mpl-sphinx-theme version --- requirements/doc/doc-requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements/doc/doc-requirements.txt b/requirements/doc/doc-requirements.txt index 77cb606130b0..1a352eaae975 100644 --- a/requirements/doc/doc-requirements.txt +++ b/requirements/doc/doc-requirements.txt @@ -14,8 +14,7 @@ ipywidgets ipykernel numpydoc>=1.0 packaging>=20 -pydata-sphinx-theme~=0.15.0 -mpl-sphinx-theme~=3.9.0 +mpl-sphinx-theme~=3.10.0 pyyaml PyStemmer sphinxcontrib-svg2pdfconverter>=1.1.0 From 7935cceb1c897fe2db109d2b15d9e6031bdd1035 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sun, 31 Aug 2025 09:12:27 +0100 Subject: [PATCH 04/13] Improve cursor icons with RectangleSelector --- lib/matplotlib/widgets.py | 56 +++++++++++++++++++++++++++++--------- lib/matplotlib/widgets.pyi | 3 ++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index f4adcd60f6cb..1a7d46e02fa5 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -150,6 +150,10 @@ def ignore(self, event): # docstring inherited return super().ignore(event) or self.canvas is None + def _set_cursor(self, cursor): + """Update the canvas cursor.""" + self.ax.get_figure(root=True).canvas.set_cursor(cursor) + class Button(AxesWidget): """ @@ -2645,7 +2649,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 @@ -2654,7 +2658,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 @@ -2664,7 +2668,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_cursor(True) + self._set_span_cursor(True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2714,7 +2718,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_cursor(False) + self._set_span_cursor(False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2756,7 +2760,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_cursor(e_dist <= self.grab_range) + self._set_span_cursor(e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" @@ -3280,10 +3284,24 @@ def _press(self, event): self._rotation_on_press = self._rotation self._set_aspect_ratio_correction() + match self._get_action(): + case "rotate": + # TODO: set to a rotate cursor if possible? + pass + case "move": + self._set_cursor(backend_tools.cursors.MOVE) + case "resize": + # TODO: set to a resize cursor if possible? + pass + case "create": + self._set_cursor(backend_tools.cursors.SELECT_REGION) + + return False def _release(self, event): """Button release event handler.""" + self._set_cursor(backend_tools.Cursors.POINTER) if not self._interactive: self._selection_artist.set_visible(False) @@ -3327,9 +3345,23 @@ def _release(self, event): self.update() self._active_handle = None self._extents_on_press = None - return False + def _get_action(self): + """ + Return one of "rotate", "move", "resize", "create" + """ + state = self._state + if 'rotate' in state and self._active_handle in self._corner_order: + return 'rotate' + elif self._active_handle == 'C': + return 'move' + elif self._active_handle: + return 'resize' + + return 'create' + + def _onmove(self, event): """ Motion notify event handler. @@ -3344,12 +3376,10 @@ def _onmove(self, event): # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move state = self._state - rotate = 'rotate' in state and self._active_handle in self._corner_order - move = self._active_handle == 'C' - resize = self._active_handle and not move + action = self._get_action() xdata, ydata = self._get_data_coords(event) - if resize: + if action == "resize": inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3369,7 +3399,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if rotate: + if action == "rotate": # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3378,7 +3408,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif resize: + elif action == "resize": size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3429,7 +3459,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif move: + elif action == "move": x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index e143d0b2c96e..cd26ab84c49c 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -6,6 +6,7 @@ from .figure import Figure from .lines import Line2D from .patches import Polygon, Rectangle from .text import Text +from .backend_tools import Cursors import PIL.Image @@ -38,6 +39,7 @@ class AxesWidget(Widget): def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... def disconnect_events(self) -> None: ... + def _set_cursor(self, cursor: Cursors) -> None: ... class Button(AxesWidget): label: Text @@ -398,6 +400,7 @@ class RectangleSelector(_SelectorWidget): minspany: float spancoords: Literal["data", "pixels"] grab_range: float + _active_handle: None | Literal["C", "N", "NE", "E", "SE", "S", "SW", "W", "NW"] def __init__( self, ax: Axes, From 43fd15499d5d5435dd73236f3d72f50fd3067858 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:02:55 +0100 Subject: [PATCH 05/13] Use an enum for RectangleSelector state --- lib/matplotlib/widgets.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 1a7d46e02fa5..96b3d5f8720c 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,6 +11,7 @@ from contextlib import ExitStack import copy +import enum import itertools from numbers import Integral, Number @@ -3151,6 +3152,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ +class _RectangleSelectorState(enum.Enum): + ROTATE = enum.auto() + MOVE = enum.auto() + RESIZE = enum.auto() + CREATE = enum.auto() + + @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): @@ -3285,18 +3293,17 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case "rotate": + case _RectangleSelectorState.ROTATE: # TODO: set to a rotate cursor if possible? pass - case "move": + case _RectangleSelectorState.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case "resize": + case _RectangleSelectorState.RESIZE: # TODO: set to a resize cursor if possible? pass - case "create": + case _RectangleSelectorState.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) - return False def _release(self, event): @@ -3348,18 +3355,15 @@ def _release(self, event): return False def _get_action(self): - """ - Return one of "rotate", "move", "resize", "create" - """ state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return 'rotate' + return _RectangleSelectorState.ROTATE elif self._active_handle == 'C': - return 'move' + return _RectangleSelectorState.MOVE elif self._active_handle: - return 'resize' + return _RectangleSelectorState.RESIZE - return 'create' + return _RectangleSelectorState.CREATE def _onmove(self, event): @@ -3379,7 +3383,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == "resize": + if action == _RectangleSelectorState.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3399,7 +3403,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == "rotate": + if action == _RectangleSelectorState.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3408,7 +3412,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == "resize": + elif action == _RectangleSelectorState.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3459,7 +3463,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == "move": + elif action == _RectangleSelectorState.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata From 59f54c0d9fd1f39722750113707089167575a2e4 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:05:46 +0100 Subject: [PATCH 06/13] Improve signature of set_span_cursor --- lib/matplotlib/widgets.py | 8 ++++---- lib/matplotlib/widgets.pyi | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 96b3d5f8720c..1b2ad0ac1976 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2650,7 +2650,7 @@ def _handles_artists(self): else: return () - def _set_span_cursor(self, enabled): + def _set_span_cursor(self, *, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2669,7 +2669,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_span_cursor(True) + self._set_span_cursor(enabled=True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2719,7 +2719,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_span_cursor(False) + self._set_span_cursor(enabled=False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2761,7 +2761,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_span_cursor(e_dist <= self.grab_range) + self._set_span_cursor(enabled=e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index cd26ab84c49c..f74b9c7f32bf 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -337,6 +337,7 @@ class SpanSelector(_SelectorWidget): _props: dict[str, Any] | None = ..., _init: bool = ..., ) -> None: ... + def _set_span_cursor(self, *, enabled: bool) -> None: ... def connect_default_events(self) -> None: ... @property def direction(self) -> Literal["horizontal", "vertical"]: ... From 30adbc894ef2f61279cadee3e07c4d929b26955d Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:07:58 +0100 Subject: [PATCH 07/13] Improve variable naming --- lib/matplotlib/widgets.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 1b2ad0ac1976..0410c4f03092 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3152,7 +3152,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ -class _RectangleSelectorState(enum.Enum): +class _RectangleSelectorAction(enum.Enum): ROTATE = enum.auto() MOVE = enum.auto() RESIZE = enum.auto() @@ -3293,15 +3293,15 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case _RectangleSelectorState.ROTATE: + case _RectangleSelectorAction.ROTATE: # TODO: set to a rotate cursor if possible? pass - case _RectangleSelectorState.MOVE: + case _RectangleSelectorAction.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case _RectangleSelectorState.RESIZE: + case _RectangleSelectorAction.RESIZE: # TODO: set to a resize cursor if possible? pass - case _RectangleSelectorState.CREATE: + case _RectangleSelectorAction.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) return False @@ -3357,13 +3357,13 @@ def _release(self, event): def _get_action(self): state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return _RectangleSelectorState.ROTATE + return _RectangleSelectorAction.ROTATE elif self._active_handle == 'C': - return _RectangleSelectorState.MOVE + return _RectangleSelectorAction.MOVE elif self._active_handle: - return _RectangleSelectorState.RESIZE + return _RectangleSelectorAction.RESIZE - return _RectangleSelectorState.CREATE + return _RectangleSelectorAction.CREATE def _onmove(self, event): @@ -3383,7 +3383,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == _RectangleSelectorState.RESIZE: + if action == _RectangleSelectorAction.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3403,7 +3403,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == _RectangleSelectorState.ROTATE: + if action == _RectangleSelectorAction.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3412,7 +3412,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == _RectangleSelectorState.RESIZE: + elif action == _RectangleSelectorAction.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3463,7 +3463,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == _RectangleSelectorState.MOVE: + elif action == _RectangleSelectorAction.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata From ce577fc266c58f3424bc8897451be3fce86f45cd Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 30 Sep 2025 09:20:10 +0200 Subject: [PATCH 08/13] Add --debug flag to python -mmatplotlib.dviread CLI. The dviread module logs some information at the debug level (e.g., dvi "specials"). Allow printing them from the CLI, to ease verification of dvi internals. --- lib/matplotlib/dviread.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 702543f9db26..f07157a63524 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -1347,8 +1347,12 @@ def _fontfile(cls, suffix, texname): parser = ArgumentParser() parser.add_argument("filename") parser.add_argument("dpi", nargs="?", type=float, default=None) + parser.add_argument("-d", "--debug", action="store_true") args = parser.parse_args() + if args.debug: + logging.basicConfig(level=logging.DEBUG) + def _print_fields(*args): print(" ".join(map("{:>11}".format, args))) From 2245b1d2d324c053f7aab3a7b8690235eae441f2 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 30 Sep 2025 09:12:56 +0200 Subject: [PATCH 09/13] Include step info in str(scroll_event). Although event.step is only nonzero for scroll events, it seems reasonable to always add it to str(MouseEvent). After all, that str() always contains e.g. dblclick, which doesn't make sense for motion_notify_event either. (IOW the alternative would be to more carefully write different str()s for each kind of MouseEvents.) --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2ec6e42ddd02..e8d370dbceb8 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1433,7 +1433,7 @@ def _from_ax_coords(cls, name, ax, xy, *args, **kwargs): def __str__(self): return (f"{self.name}: " f"xy=({self.x}, {self.y}) xydata=({self.xdata}, {self.ydata}) " - f"button={self.button} dblclick={self.dblclick} " + f"button={self.button} dblclick={self.dblclick} step={self.step} " f"inaxes={self.inaxes}") From dd65c51555335833c8364657706f5b5cd5ed4157 Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Mon, 13 Oct 2025 11:46:55 -0400 Subject: [PATCH 10/13] Update lib/matplotlib/axes/_axes.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/axes/_axes.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 265f512c19db..3fd6e6459aa0 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -9050,19 +9050,19 @@ def violin(self, vpstats, positions=None, vert=None, elif len(widths) != N: raise ValueError(datashape_message.format("widths")) - # Proactive validation: if positions are datetime-like - # widths must be timedelta-like. - if any(isinstance(p, (datetime.datetime, datetime.date)) - for p in positions): - _widths = widths if not np.isscalar(widths) else [widths] * N - if any(not isinstance(w, (datetime.timedelta, np.timedelta64)) - for w in _widths): - raise TypeError( - "If positions are datetime/date values, pass widths as " - "datetime.timedelta (e.g., datetime.timedelta(days=10))" - "or numpy.timedelta64." - ) - + # For usability / better error message: + # Validate that datetime-like positions have timedelta-like widths. + # Checking only the first element is good enough for standard misuse cases + pos0 = positions[0] + width0 = widths if np.isscalar(widths) else widths[0] + if (isinstance(pos0, (datetime.datetime, datetime.date)) + and not isinstance(width0, datetime.timedelta)): + raise TypeError( + "datetime/date 'position' values, require timedelta 'widths'") + elif (isinstance(pos0, np.datetime64) + and not isinstance(width0, np.timedelta64): + raise TypeError( + "np.datetime64 'position' values, require np.timdelta64 'widths'") # Validate side _api.check_in_list(["both", "low", "high"], side=side) From 1e2bcae3e50a0b63950b017d4aea45593eeb1e7d Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Wed, 10 Sep 2025 21:23:30 -0400 Subject: [PATCH 11/13] Improving error message for width and position type mismatch in violinplot --- lib/matplotlib/axes/_axes.py | 15 +++ .../tests/test_violinplot_datetime.py | 95 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 lib/matplotlib/tests/test_violinplot_datetime.py diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 0db8bdd6f643..83cab232ac4d 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1,3 +1,4 @@ +import datetime import functools import itertools import logging @@ -9047,6 +9048,20 @@ def violin(self, vpstats, positions=None, vert=None, elif len(widths) != N: raise ValueError(datashape_message.format("widths")) + # Proactive validation: if positions are datetime-like + # widths must be timedelta-like. + if any(isinstance(p, (datetime.datetime, datetime.date)) + for p in positions): + _widths = widths if not np.isscalar(widths) else [widths] * N + if any(not isinstance(w, (datetime.timedelta, np.timedelta64)) + for w in _widths): + raise TypeError( + "If positions are datetime/date values, pass widths as " + "datetime.timedelta (e.g., datetime.timedelta(days=10))" + "or numpy.timedelta64." + ) + + # Validate side _api.check_in_list(["both", "low", "high"], side=side) diff --git a/lib/matplotlib/tests/test_violinplot_datetime.py b/lib/matplotlib/tests/test_violinplot_datetime.py new file mode 100644 index 000000000000..ddfb5a411b26 --- /dev/null +++ b/lib/matplotlib/tests/test_violinplot_datetime.py @@ -0,0 +1,95 @@ +""" +Unit tests for proactive validation of datetime +positions and timedelta widths in violinplot. +""" + +import datetime +import pytest + +import matplotlib.pyplot as plt + + +def make_vpstats(): + """Create minimal valid stats for a violin plot.""" + + +def violin_plot_stats(): + # Stats for violin plot + datetimes = [ + datetime.datetime(2023, 2, 10), + datetime.datetime(2023, 5, 18), + datetime.datetime(2023, 6, 6) + ] + return [{ + 'coords': datetimes, + 'vals': [0.1, 0.5, 0.2], + 'mean': datetimes[1], + 'median': datetimes[1], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }, { + 'coords': datetimes, + 'vals': [0.2, 0.3, 0.4], + 'mean': datetimes[2], + 'median': datetimes[2], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }] + + +def test_datetime_positions_with_float_widths_raises(): + """Test that datetime positions with float widths raise TypeError.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_datetime_positions_with_scalar_float_width_raises(): + """Test that datetime positions with scalar float width raise TypeError.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = 0.75 + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_numeric_positions_with_float_widths_ok(): + """Test that numeric positions with float widths work.""" + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [1.0, 2.0] + widths = [0.5, 1.0] + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) + + +def test_mixed_positions_datetime_and_numeric_behaves(): + """Test that mixed datetime and numeric positions + with float widths raise TypeError. + """ + fig, ax = plt.subplots() + try: + vpstats = violin_plot_stats() + positions = [datetime.datetime(2020, 1, 1), 2.0] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(vpstats, positions=positions, widths=widths) + finally: + plt.close(fig) From c5e3e0d5b58fb6592a0e86e149a5f1598a3a19ad Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Mon, 29 Sep 2025 21:48:57 -0400 Subject: [PATCH 12/13] Integrate tests in test_axes.py --- lib/matplotlib/tests/test_axes.py | 66 +++++++++++++ .../tests/test_violinplot_datetime.py | 95 ------------------- 2 files changed, 66 insertions(+), 95 deletions(-) delete mode 100644 lib/matplotlib/tests/test_violinplot_datetime.py diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 0c445f86d9aa..e1d71aaf9513 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4112,6 +4112,72 @@ def test_violinplot_sides(): showextrema=True, showmedians=True, side=side) +def violin_plot_stats(): + # Stats for violin plot + datetimes = [ + datetime.datetime(2023, 2, 10), + datetime.datetime(2023, 5, 18), + datetime.datetime(2023, 6, 6) + ] + return [{ + 'coords': datetimes, + 'vals': [0.1, 0.5, 0.2], + 'mean': datetimes[1], + 'median': datetimes[1], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }, { + 'coords': datetimes, + 'vals': [0.2, 0.3, 0.4], + 'mean': datetimes[2], + 'median': datetimes[2], + 'min': datetimes[0], + 'max': datetimes[-1], + 'quantiles': datetimes + }] + + +def test_datetime_positions_with_float_widths_raises(): + """Test that datetime positions with float widths raise TypeError.""" + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_datetime_positions_with_scalar_float_width_raises(): + """Test that datetime positions with scalar float width raise TypeError.""" + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] + widths = 0.75 + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_numeric_positions_with_float_widths_ok(): + """Test that numeric positions with float widths work.""" + fig, ax = plt.subplots() + positions = [1.0, 2.0] + widths = [0.5, 1.0] + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + +def test_mixed_positions_datetime_and_numeric_behaves(): + """Test that mixed datetime and numeric positions + with float widths raise TypeError. + """ + fig, ax = plt.subplots() + positions = [datetime.datetime(2020, 1, 1), 2.0] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="positions are datetime/date.*widths as datetime\\.timedelta"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + def test_violinplot_bad_positions(): ax = plt.axes() # First 9 digits of frac(sqrt(47)) diff --git a/lib/matplotlib/tests/test_violinplot_datetime.py b/lib/matplotlib/tests/test_violinplot_datetime.py deleted file mode 100644 index ddfb5a411b26..000000000000 --- a/lib/matplotlib/tests/test_violinplot_datetime.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Unit tests for proactive validation of datetime -positions and timedelta widths in violinplot. -""" - -import datetime -import pytest - -import matplotlib.pyplot as plt - - -def make_vpstats(): - """Create minimal valid stats for a violin plot.""" - - -def violin_plot_stats(): - # Stats for violin plot - datetimes = [ - datetime.datetime(2023, 2, 10), - datetime.datetime(2023, 5, 18), - datetime.datetime(2023, 6, 6) - ] - return [{ - 'coords': datetimes, - 'vals': [0.1, 0.5, 0.2], - 'mean': datetimes[1], - 'median': datetimes[1], - 'min': datetimes[0], - 'max': datetimes[-1], - 'quantiles': datetimes - }, { - 'coords': datetimes, - 'vals': [0.2, 0.3, 0.4], - 'mean': datetimes[2], - 'median': datetimes[2], - 'min': datetimes[0], - 'max': datetimes[-1], - 'quantiles': datetimes - }] - - -def test_datetime_positions_with_float_widths_raises(): - """Test that datetime positions with float widths raise TypeError.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] - widths = [0.5, 1.0] - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_datetime_positions_with_scalar_float_width_raises(): - """Test that datetime positions with scalar float width raise TypeError.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] - widths = 0.75 - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_numeric_positions_with_float_widths_ok(): - """Test that numeric positions with float widths work.""" - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [1.0, 2.0] - widths = [0.5, 1.0] - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) - - -def test_mixed_positions_datetime_and_numeric_behaves(): - """Test that mixed datetime and numeric positions - with float widths raise TypeError. - """ - fig, ax = plt.subplots() - try: - vpstats = violin_plot_stats() - positions = [datetime.datetime(2020, 1, 1), 2.0] - widths = [0.5, 1.0] - with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): - ax.violin(vpstats, positions=positions, widths=widths) - finally: - plt.close(fig) From 666fd6d8dfb079bae44a3d99a5ab6b134689074b Mon Sep 17 00:00:00 2001 From: Hasan Rashid Date: Mon, 13 Oct 2025 11:46:55 -0400 Subject: [PATCH 13/13] Update lib/matplotlib/axes/_axes.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/axes/_axes.py | 26 +++++++++++++------------- lib/matplotlib/tests/test_axes.py | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 83cab232ac4d..7fc930a3c4f4 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -9048,19 +9048,19 @@ def violin(self, vpstats, positions=None, vert=None, elif len(widths) != N: raise ValueError(datashape_message.format("widths")) - # Proactive validation: if positions are datetime-like - # widths must be timedelta-like. - if any(isinstance(p, (datetime.datetime, datetime.date)) - for p in positions): - _widths = widths if not np.isscalar(widths) else [widths] * N - if any(not isinstance(w, (datetime.timedelta, np.timedelta64)) - for w in _widths): - raise TypeError( - "If positions are datetime/date values, pass widths as " - "datetime.timedelta (e.g., datetime.timedelta(days=10))" - "or numpy.timedelta64." - ) - + # For usability / better error message: + # Validate that datetime-like positions have timedelta-like widths. + # Checking only the first element is good enough for standard misuse cases + pos0 = positions[0] + width0 = widths if np.isscalar(widths) else widths[0] + if (isinstance(pos0, (datetime.datetime, datetime.date)) + and not isinstance(width0, datetime.timedelta)): + raise TypeError( + "datetime/date 'position' values, require timedelta 'widths'") + elif (isinstance(pos0, np.datetime64) + and not isinstance(width0, np.timedelta64)): + raise TypeError( + "np.datetime64 'position' values, require np.timedelta64 'widths'") # Validate side _api.check_in_list(["both", "low", "high"], side=side) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index e1d71aaf9513..2b2aa93b71b9 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4113,7 +4113,6 @@ def test_violinplot_sides(): def violin_plot_stats(): - # Stats for violin plot datetimes = [ datetime.datetime(2023, 2, 10), datetime.datetime(2023, 5, 18), @@ -4138,13 +4137,23 @@ def violin_plot_stats(): }] +def test_datetime_positions_with_datetime64(): + """Test that datetime positions with float widths raise TypeError.""" + fig, ax = plt.subplots() + positions = [np.datetime64('2020-01-01'), np.datetime64('2021-01-01')] + widths = [0.5, 1.0] + with pytest.raises(TypeError, + match="np.datetime64 'position' values, require np.timedelta64 'widths'"): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + def test_datetime_positions_with_float_widths_raises(): """Test that datetime positions with float widths raise TypeError.""" fig, ax = plt.subplots() positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] widths = [0.5, 1.0] with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): + match="datetime/date 'position' values, require timedelta 'widths'"): ax.violin(violin_plot_stats(), positions=positions, widths=widths) @@ -4154,7 +4163,7 @@ def test_datetime_positions_with_scalar_float_width_raises(): positions = [datetime.datetime(2020, 1, 1), datetime.datetime(2021, 1, 1)] widths = 0.75 with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): + match="datetime/date 'position' values, require timedelta 'widths'"): ax.violin(violin_plot_stats(), positions=positions, widths=widths) @@ -4174,7 +4183,7 @@ def test_mixed_positions_datetime_and_numeric_behaves(): positions = [datetime.datetime(2020, 1, 1), 2.0] widths = [0.5, 1.0] with pytest.raises(TypeError, - match="positions are datetime/date.*widths as datetime\\.timedelta"): + match="datetime/date 'position' values, require timedelta 'widths'"): ax.violin(violin_plot_stats(), positions=positions, widths=widths)