diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 0db8bdd6f643..f68f2c813a54 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,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] + if not isinstance(_widths[0], (datetime.timedelta)): + 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)