diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index eb24ff1fac84..50ad48c346dd 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -671,6 +671,8 @@ def __init__(self, axes, *, pickradius=15): self._major_tick_kw = dict() self._minor_tick_kw = dict() + self._axisinfo = None + self.clear() self._autoscale_on = True @@ -1674,6 +1676,7 @@ def _update_axisinfo(self): return info = self.converter.axisinfo(self.units, self) + self._axisinfo = info if info is None: return @@ -1821,6 +1824,11 @@ def _set_formatter(self, formatter, level): _api.warn_external('FixedFormatter should only be used together ' 'with FixedLocator') + assert isinstance(formatter, mticker.Formatter) + + if hasattr(self, "converter") and self.converter is not None: + formatter.validate_converter(self.converter, self._axisinfo) + if level == self.major: self.isDefault_majfmt = False else: diff --git a/lib/matplotlib/category.py b/lib/matplotlib/category.py index 4ac2379ea5f5..102ac2669598 100644 --- a/lib/matplotlib/category.py +++ b/lib/matplotlib/category.py @@ -164,6 +164,12 @@ def _text(value): value = str(value) return value + def validate_converter(self, converter, axisinfo): + if not isinstance(converter, StrCategoryConverter): + _api.warn_external( + "Expected a StrCategoryConverter for StrCategoryFormatter" + ) + class UnitData: def __init__(self, data=None): diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index 381dd810a5d2..4e7fb356fcab 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -651,6 +651,11 @@ def __call__(self, x, pos=0): def set_tzinfo(self, tz): self.tz = _get_tzinfo(tz) + def validate_converter(self, converter, axisinfo): + if axisinfo.description != "days since matplotlib epoch": + print(converter, axisinfo.majfmt) + _api.warn_external("Converter may not be compatible with date formatting.") + class ConciseDateFormatter(ticker.Formatter): """ @@ -872,6 +877,10 @@ def get_offset(self): def format_data_short(self, value): return num2date(value, tz=self._tz).strftime('%Y-%m-%d %H:%M:%S') + def validate_converter(self, converter, axisinfo): + if axisinfo.description != "days since matplotlib epoch": + _api.warn_external("Converter may not be compatible with date formatting.") + class AutoDateFormatter(ticker.Formatter): """ @@ -992,6 +1001,9 @@ def __call__(self, x, pos=None): return result + def validate_converter(self, converter, axisinfo): + self._formatter.validate_converter(converter, axisinfo) + class rrulewrapper: """ @@ -1804,7 +1816,8 @@ def axisinfo(self, unit, axis): datemax = datetime.date(1970, 1, 2) return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='', - default_limits=(datemin, datemax)) + default_limits=(datemin, datemax), + description="days since matplotlib epoch") @staticmethod def convert(value, unit, axis): @@ -1861,7 +1874,8 @@ def axisinfo(self, unit, axis): datemin = datetime.date(1970, 1, 1) datemax = datetime.date(1970, 1, 2) return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='', - default_limits=(datemin, datemax)) + default_limits=(datemin, datemax), + description="days since matplotlib epoch") class _SwitchableDateConverter: diff --git a/lib/matplotlib/testing/jpl_units/EpochConverter.py b/lib/matplotlib/testing/jpl_units/EpochConverter.py index f42d7b71d041..c8e016909903 100644 --- a/lib/matplotlib/testing/jpl_units/EpochConverter.py +++ b/lib/matplotlib/testing/jpl_units/EpochConverter.py @@ -21,7 +21,15 @@ def axisinfo(unit, axis): # docstring inherited majloc = date_ticker.AutoDateLocator() majfmt = date_ticker.AutoDateFormatter(majloc) - return units.AxisInfo(majloc=majloc, majfmt=majfmt, label=unit) + + # There is actually an off by one error here, but allow DateFormatters + descr = "days since matplotlib epoch" + return units.AxisInfo( + majloc=majloc, + majfmt=majfmt, + label=unit, + description=descr, + ) @staticmethod def float2epoch(value, unit): diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index a770c2b43eea..a067bb256a2f 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -253,6 +253,11 @@ def _set_locator(self, locator): """Subclasses may want to override this to set a locator.""" pass + def validate_converter(self, converter, axisinfo): + """Raise an exception if the converter is not valid for this formatter.""" + # By default, accept any converter + pass + class NullFormatter(Formatter): """Always return the empty string.""" diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index 48077336c54d..585470ef2080 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -1,6 +1,7 @@ from matplotlib.axis import Axis -from matplotlib.transforms import Transform from matplotlib.projections.polar import _AxisWrapper +from matplotlib.transforms import Transform +from matplotlib.units import ConversionInterface, AxisInfo from collections.abc import Callable, Sequence from typing import Any, Literal @@ -31,6 +32,7 @@ class Formatter(TickHelper): def set_locs(self, locs: list[float]) -> None: ... @staticmethod def fix_minus(s: str) -> str: ... + def validate_converter(self, converter: ConversionInterface, axisinfo: AxisInfo) -> None: ... class NullFormatter(Formatter): ... diff --git a/lib/matplotlib/units.py b/lib/matplotlib/units.py index e3480f228bb4..2c6b9af2ef18 100644 --- a/lib/matplotlib/units.py +++ b/lib/matplotlib/units.py @@ -78,7 +78,7 @@ class AxisInfo: """ def __init__(self, majloc=None, minloc=None, majfmt=None, minfmt=None, label=None, - default_limits=None): + default_limits=None, description=None): """ Parameters ---------- @@ -89,8 +89,10 @@ def __init__(self, majloc=None, minloc=None, label : str, optional The default axis label. default_limits : optional - The default min and max limits of the axis if no data has - been plotted. + The default min and max limits of the axis if no data has been plotted. + description: str, optional + A human readable description which may additionally be used to validate + converters. Notes ----- @@ -103,6 +105,7 @@ def __init__(self, majloc=None, minloc=None, self.minfmt = minfmt self.label = label self.default_limits = default_limits + self.description = description class ConversionInterface: