diff --git a/doc/api/axis_api.rst b/doc/api/axis_api.rst index 0870d38c9439..85ba990ffece 100644 --- a/doc/api/axis_api.rst +++ b/doc/api/axis_api.rst @@ -169,6 +169,8 @@ Units Axis.convert_units Axis.set_units Axis.get_units + Axis.set_converter + Axis.get_converter Axis.update_units diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 8e612bd8c702..0c095e9c4e0a 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -600,6 +600,10 @@ class Axis(martist.Artist): # The class used in _get_tick() to create tick instances. Must either be # overwritten in subclasses, or subclasses must reimplement _get_tick(). _tick_class = None + converter = _api.deprecate_privatize_attribute( + "3.10", + alternative="get_converter and set_converter methods" + ) def __str__(self): return "{}({},{})".format( @@ -656,7 +660,8 @@ def __init__(self, axes, *, pickradius=15, clear=True): if clear: self.clear() else: - self.converter = None + self._converter = None + self._converter_is_explicit = False self.units = None self._autoscale_on = True @@ -886,7 +891,8 @@ def clear(self): mpl.rcParams['axes.grid.which'] in ('both', 'minor')) self.reset_ticks() - self.converter = None + self._converter = None + self._converter_is_explicit = False self.units = None self.stale = True @@ -1738,16 +1744,20 @@ def grid(self, visible=None, which='major', **kwargs): def update_units(self, data): """ Introspect *data* for units converter and update the - ``axis.converter`` instance if necessary. Return *True* + ``axis.get_converter`` instance if necessary. Return *True* if *data* is registered for unit conversion. """ - converter = munits.registry.get_converter(data) + if not self._converter_is_explicit: + converter = munits.registry.get_converter(data) + else: + converter = self._converter + if converter is None: return False - neednew = self.converter != converter - self.converter = converter - default = self.converter.default_units(data, self) + neednew = self._converter != converter + self._set_converter(converter) + default = self._converter.default_units(data, self) if default is not None and self.units is None: self.set_units(default) @@ -1761,10 +1771,10 @@ def _update_axisinfo(self): Check the axis converter for the stored units to see if the axis info needs to be updated. """ - if self.converter is None: + if self._converter is None: return - info = self.converter.axisinfo(self.units, self) + info = self._converter.axisinfo(self.units, self) if info is None: return @@ -1791,25 +1801,58 @@ def _update_axisinfo(self): self.set_default_intervals() def have_units(self): - return self.converter is not None or self.units is not None + return self._converter is not None or self.units is not None def convert_units(self, x): # If x is natively supported by Matplotlib, doesn't need converting if munits._is_natively_supported(x): return x - if self.converter is None: - self.converter = munits.registry.get_converter(x) + if self._converter is None: + self._set_converter(munits.registry.get_converter(x)) - if self.converter is None: + if self._converter is None: return x try: - ret = self.converter.convert(x, self.units, self) + ret = self._converter.convert(x, self.units, self) except Exception as e: raise munits.ConversionError('Failed to convert value(s) to axis ' f'units: {x!r}') from e return ret + def get_converter(self): + """ + Get the unit converter for axis. + + Returns + ------- + `~matplotlib.units.ConversionInterface` or None + """ + return self._converter + + def set_converter(self, converter): + """ + Set the unit converter for axis. + + Parameters + ---------- + converter : `~matplotlib.units.ConversionInterface` + """ + self._set_converter(converter) + self._converter_is_explicit = True + + def _set_converter(self, converter): + if self._converter == converter: + return + if self._converter_is_explicit: + raise RuntimeError("Axis already has an explicit converter set") + elif self._converter is not None: + _api.warn_external( + "This axis already has a converter set and " + "is updating to a potentially incompatible converter" + ) + self._converter = converter + def set_units(self, u): """ Set the units for axis. @@ -2529,8 +2572,8 @@ def set_default_intervals(self): # not changed the view: if (not self.axes.dataLim.mutatedx() and not self.axes.viewLim.mutatedx()): - if self.converter is not None: - info = self.converter.axisinfo(self.units, self) + if self._converter is not None: + info = self._converter.axisinfo(self.units, self) if info.default_limits is not None: xmin, xmax = self.convert_units(info.default_limits) self.axes.viewLim.intervalx = xmin, xmax @@ -2759,8 +2802,8 @@ def set_default_intervals(self): # not changed the view: if (not self.axes.dataLim.mutatedy() and not self.axes.viewLim.mutatedy()): - if self.converter is not None: - info = self.converter.axisinfo(self.units, self) + if self._converter is not None: + info = self._converter.axisinfo(self.units, self) if info.default_limits is not None: ymin, ymax = self.convert_units(info.default_limits) self.axes.viewLim.intervaly = ymin, ymax diff --git a/lib/matplotlib/axis.pyi b/lib/matplotlib/axis.pyi index 8f7b213c51e3..f2c5b1fc586d 100644 --- a/lib/matplotlib/axis.pyi +++ b/lib/matplotlib/axis.pyi @@ -15,6 +15,7 @@ from matplotlib.text import Text from matplotlib.ticker import Locator, Formatter from matplotlib.transforms import Transform, Bbox from matplotlib.typing import ColorType +from matplotlib.units import ConversionInterface GRIDLINE_INTERPOLATION_STEPS: int @@ -207,6 +208,8 @@ class Axis(martist.Artist): def update_units(self, data): ... def have_units(self) -> bool: ... def convert_units(self, x): ... + def get_converter(self) -> ConversionInterface | None: ... + def set_converter(self, converter: ConversionInterface) -> None: ... def set_units(self, u) -> None: ... def get_units(self): ... def set_label_text( diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index b025ef3e056e..2809e63c4e8c 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -42,7 +42,7 @@ def convert_limits(lim, converter): axis_map = axes._axis_map axis_limits = { name: tuple(convert_limits( - getattr(axes, f'get_{name}lim')(), axis.converter + getattr(axes, f'get_{name}lim')(), axis.get_converter() )) for name, axis in axis_map.items() } @@ -66,7 +66,7 @@ def convert_limits(lim, converter): # Save the converter and unit data axis_converter = { - name: axis.converter + name: axis.get_converter() for name, axis in axis_map.items() } axis_units = { @@ -209,7 +209,7 @@ def apply_callback(data): axis.set_label_text(axis_label) # Restore the unit data - axis.converter = axis_converter[name] + axis._set_converter(axis_converter[name]) axis.set_units(axis_units[name]) # Set / Curves diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 2d60e3525b2a..73f10cec52aa 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -668,10 +668,12 @@ def test_concise_converter_stays(): fig, ax = plt.subplots() ax.plot(x, y) # Bypass Switchable date converter - ax.xaxis.converter = conv = mdates.ConciseDateConverter() + conv = mdates.ConciseDateConverter() + with pytest.warns(UserWarning, match="already has a converter"): + ax.xaxis.set_converter(conv) assert ax.xaxis.units is None ax.set_xlim(*x) - assert ax.xaxis.converter == conv + assert ax.xaxis.get_converter() == conv def test_offset_changes(): diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index ae6372fea1e1..a8735e180bf0 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -5,7 +5,7 @@ import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal, image_comparison import matplotlib.units as munits -from matplotlib.category import UnitData +from matplotlib.category import StrCategoryConverter, UnitData import numpy as np import pytest @@ -236,6 +236,32 @@ def test_shared_axis_categorical(): assert "c" in ax2.xaxis.get_units()._mapping.keys() +def test_explicit_converter(): + d1 = {"a": 1, "b": 2} + str_cat_converter = StrCategoryConverter() + str_cat_converter_2 = StrCategoryConverter() + + # Explicit is set + fig1, ax1 = plt.subplots() + ax1.xaxis.set_converter(str_cat_converter) + assert ax1.xaxis.get_converter() == str_cat_converter + # Explicit not overridden by implicit + ax1.plot(d1.keys(), d1.values()) + assert ax1.xaxis.get_converter() == str_cat_converter + # No error when called twice with equivalent input + ax1.xaxis.set_converter(str_cat_converter) + # Error when explicit called twice + with pytest.raises(RuntimeError): + ax1.xaxis.set_converter(str_cat_converter_2) + + # Warn when implicit overridden + fig2, ax2 = plt.subplots() + ax2.plot(d1.keys(), d1.values()) + + with pytest.warns(): + ax2.xaxis.set_converter(str_cat_converter) + + def test_empty_default_limits(quantity_converter): munits.registry[Quantity] = quantity_converter fig, ax1 = plt.subplots()