diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 778c72aaedf9..df7c630dd853 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -2,6 +2,7 @@ import itertools import logging import math +import datetime from numbers import Integral, Number, Real import re @@ -9050,14 +9051,28 @@ def violin(self, vpstats, positions=None, vert=None, positions = range(1, N + 1) elif len(positions) != N: raise ValueError(datashape_message.format("positions")) - # Validate widths if np.isscalar(widths): widths = [widths] * N elif len(widths) != N: raise ValueError(datashape_message.format("widths")) - # Validate side + # 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 + if N > 0: # No need to validate if there is no data + pos0 = positions[0] + width0 = widths[0] + if (isinstance(pos0, (datetime.datetime, datetime.date)) + and not isinstance(width0, datetime.timedelta)): + raise TypeError( + "datetime/date 'position' values require timedelta 'widths'. " + "For example, use positions=[datetime.date(2024, 1, 1)] " + "and widths=[datetime.timedelta(days=1)].") + elif (isinstance(pos0, np.datetime64) + and not isinstance(width0, np.timedelta64)): + raise TypeError( + "np.datetime64 'position' values require np.timedelta64 'widths'") _api.check_in_list(["both", "low", "high"], side=side) # Calculate ranges for statistics lines (shape (2, N)). diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index b5c965d0eb4d..c3b182e550d4 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4121,6 +4121,85 @@ def test_violinplot_sides(): showextrema=True, showmedians=True, side=side) +def violin_plot_stats(): + datetimes = [ + datetime.datetime(2023, 2, 10), + datetime.datetime(2023, 5, 18), + datetime.datetime(2023, 6, 6) + ] + return [{ + 'coords': datetimes, + 'vals': [1.2, 2.8, 1.5], + 'mean': 1.84, + 'median': 1.5, + 'min': 1.2, + 'max': 2.8, + 'quantiles': [1.2, 1.5, 2.8] + }, { + 'coords': datetimes, + 'vals': [0.8, 1.1, 0.9], + 'mean': 0.94, + 'median': 0.9, + 'min': 0.8, + 'max': 1.1, + 'quantiles': [0.8, 0.9, 1.1] + }] + + +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=("datetime/date 'position' values require " + "timedelta 'widths'")): + 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=("datetime/date 'position' values require " + "timedelta 'widths'")): + 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_raises(): + """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=("datetime/date 'position' values require " + "timedelta 'widths'")): + ax.violin(violin_plot_stats(), positions=positions, widths=widths) + + def test_violinplot_bad_positions(): ax = plt.axes() # First 9 digits of frac(sqrt(47))