Repository: matplotlib/matplotlib

Issue:
Add public API for setting an axis unit converter
### Problem

I would like to define and use a custom unit converter class. The existing `mpl.units.registry` system is type-based, which does not work for my usecase. I need my converter to generalize over types. But I also know exactly which (existing) axis objects should use the converter.

The closest I can get in associating a custom converter with an existing axis, following the code in `axis.update_units` is

```python
ax = plt.figure().subplots()
ax.xaxis.converter = CustomConverter()
ax.xaxis._update_axisinfo()
ax.xaxis.stale = True
```

### Proposed Solution

A public `axis.set_converter` API would solve this problem, e.g.

```python
ax = plt.figure().subplots()
ax.set_converter(CustomConverter())
```

The idea is that this would basically do what `Axis.update_units` does, but consuming a `ConversionInterface` rather than consuming data and then getting a converter from the registry.

### Additional context and prior art

The other thing I would mention is that I could probably work-around this problem if the registry lookup were based on the container `dtype` rather than the type of the individual elements (as is currently the case). But that seems to complexify the existing API, whereas the this proposal would add an orthogonal API, and it is probably better for that reason.


Discussion:
This would not be enough as https://github.com/matplotlib/matplotlib/pull/10206 added a cut-out (which was refactored a bit in https://github.com/matplotlib/matplotlib/pull/13738 and https://github.com/matplotlib/matplotlib/pull/14363) to not try and convert numbers.

In addition to adding `set_converter` would would have to extend the public API of the converters to at the instance level bail out of trying to do the conversion.  It looks like we could do this by over-riding `is_numlike` on sub-classes, but then we can not defer the "check if we need to predict a converter" step (which has been in the code base from 84ede567056bde8411446146854b86cdb2ff5ae3 ) 
I think we could remove the cutout and make dealing with numbers the prerogative of the converter?   
FYI I've decided to solve this in seaborn by internally coercing data to strings in situations where it should receive "categorical" treatment. I I think, for now at least, I don't need any changes in matplotlib to support this particular use-case. I'm going to close the issue, but if you think this is a good idea independently of my narrow problem, feel free to re-open.
I think we should keep this open for the upcoming unit work.  
This issue has been marked "inactive" because it has been 365 days since the last comment. If this issue is still present in recent Matplotlib releases, or the feature request is still wanted, please leave a comment and this label will be removed. If there are no updates in another 30 days, this issue will be automatically closed, but you are free to re-open or create a new issue if needed. We value issue reports, and this procedure is meant to help us resurface and prioritize issues that have not been addressed yet, not make them disappear.  Thanks for your help!



Patch:
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


Test patch:
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()