diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 2101d802264c..3ac1027387b6 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1576,6 +1576,13 @@ def convert_units(self, x): f'units: {x!r}') from e return ret + # Uncomment this in 3.5 when Converters are enforced to have an + # un_convert() method + """ + def unconvert_units(self, x): + return self.converter.un_convert(x, self.units, self) + """ + def set_units(self, u): """ Set the units for axis. diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index cfa7149680fc..7905f8820967 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -1873,15 +1873,13 @@ def weeks(w): return w * DAYS_PER_WEEK -class DateConverter(units.ConversionInterface): +class BaseDateConverter(units.ConversionInterface): """ - Converter for datetime.date and datetime.datetime data, - or for date/time data represented as it would be converted - by :func:`date2num`. + A base converter for datetime.date and datetime.datetime data, or for + date/time data represented as it would be converted by :func:`date2num`. The 'unit' tag for such data is None or a tzinfo instance. """ - @staticmethod def axisinfo(unit, axis): """ @@ -1930,6 +1928,33 @@ def default_units(x, axis): return None +class DateConverter(BaseDateConverter): + """ + A converter for `datetime.date` data. + """ + @staticmethod + def un_convert(value, unit, axis): + return num2date(value) + + +class Datetime64Converter(BaseDateConverter): + """ + A converter for `numpy.datetime64` data. + """ + @staticmethod + def un_convert(value, unit, axis): + return np.datetime64(num2date(value).replace(tzinfo=None)) + + +class DatetimeConverter(BaseDateConverter): + """ + A converter for `datetime.datetime` data. + """ + @staticmethod + def un_convert(value, unit, axis): + return num2date(value) + + class ConciseDateConverter(DateConverter): """ Converter for datetime.date and datetime.datetime data, @@ -1968,6 +1993,6 @@ def axisinfo(self, unit, axis): default_limits=(datemin, datemax)) -units.registry[np.datetime64] = DateConverter() +units.registry[np.datetime64] = Datetime64Converter() units.registry[datetime.date] = DateConverter() -units.registry[datetime.datetime] = DateConverter() +units.registry[datetime.datetime] = DatetimeConverter() diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 9190dafd6cc4..05fd407c19cb 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -97,7 +97,10 @@ def test_matshow(): ]) def test_formatter_ticker(): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # This should affect the tick size. (Tests issue #543) matplotlib.rcParams['lines.markeredgewidth'] = 30 @@ -486,7 +489,10 @@ def test_polar_alignment(): def test_fill_units(): from datetime import datetime import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # generate some data t = units.Epoch("ET", dt=datetime(2009, 4, 27)) @@ -654,7 +660,10 @@ def test_polar_wrap(fig_test, fig_ref): @check_figures_equal() def test_polar_units_1(fig_test, fig_ref): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() xs = [30.0, 45.0, 60.0, 90.0] ys = [1.0, 2.0, 3.0, 4.0] @@ -669,7 +678,10 @@ def test_polar_units_1(fig_test, fig_ref): @check_figures_equal() def test_polar_units_2(fig_test, fig_ref): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() xs = [30.0, 45.0, 60.0, 90.0] xs_deg = [x * units.deg for x in xs] ys = [1.0, 2.0, 3.0, 4.0] @@ -838,7 +850,10 @@ def test_aitoff_proj(): def test_axvspan_epoch(): from datetime import datetime import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # generate some data t0 = units.Epoch("ET", dt=datetime(2009, 1, 20)) @@ -854,7 +869,10 @@ def test_axvspan_epoch(): def test_axhspan_epoch(): from datetime import datetime import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # generate some data t0 = units.Epoch("ET", dt=datetime(2009, 1, 20)) diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 69c050bec937..35f4ac3c3c7a 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -165,7 +165,10 @@ def test_too_many_date_ticks(caplog): @image_comparison(['RRuleLocator_bounds.png']) def test_RRuleLocator(): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # This will cause the RRuleLocator to go out of bounds when it tries # to add padding to the limits, so we make sure it caps at the correct @@ -198,7 +201,10 @@ def test_RRuleLocator_dayrange(): @image_comparison(['DateFormatter_fractionalSeconds.png']) def test_DateFormatter(): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() # Lets make sure that DateFormatter will allow us to have tick marks # at intervals of fractional seconds. diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 32ce5db1cebe..676ec045845e 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -367,7 +367,10 @@ def test_multi_color_hatch(): @image_comparison(['units_rectangle.png']) def test_units_rectangle(): import matplotlib.testing.jpl_units as U - U.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + U.register() p = mpatches.Rectangle((5*U.km, 6*U.km), 1*U.km, 2*U.km) diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index f14425144dbf..2798fab2d926 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import matplotlib.pyplot as plt +from matplotlib import cbook from matplotlib.testing.decorators import check_figures_equal, image_comparison import matplotlib.units as munits import numpy as np @@ -56,6 +57,9 @@ def convert(value, unit, axis): else: return Quantity(value, axis.get_units()).to(unit).magnitude + def un_convert(value, unit, axis): + return Quantity(value, unit) + def default_units(value, axis): if hasattr(value, 'units'): return value.units @@ -68,6 +72,7 @@ def default_units(value, axis): qc.convert = MagicMock(side_effect=convert) qc.axisinfo = MagicMock(side_effect=lambda u, a: munits.AxisInfo(label=u)) qc.default_units = MagicMock(side_effect=default_units) + qc.un_convert = MagicMock(side_effect=un_convert) return qc @@ -124,7 +129,10 @@ def test_empty_set_limits_with_units(quantity_converter): savefig_kwarg={'dpi': 120}, style='mpl20') def test_jpl_bar_units(): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() day = units.Duration("ET", 24.0 * 60.0 * 60.0) x = [0*units.km, 1*units.km, 2*units.km] @@ -140,7 +148,10 @@ def test_jpl_bar_units(): savefig_kwarg={'dpi': 120}, style='mpl20') def test_jpl_barh_units(): import matplotlib.testing.jpl_units as units - units.register() + # Catch warnings thrown whilst jpl unit converters don't have an + # un_convert() method + with pytest.warns(Warning, match='does not define an un_convert'): + units.register() day = units.Duration("ET", 24.0 * 60.0 * 60.0) x = [0*units.km, 1*units.km, 2*units.km] @@ -175,3 +186,17 @@ class subdate(datetime): fig_test.subplots().plot(subdate(2000, 1, 1), 0, "o") fig_ref.subplots().plot(datetime(2000, 1, 1), 0, "o") + + +def test_no_conveter_warnings(): + class Converter(munits.ConversionInterface): + pass + + # Check that a converter without a manuallly defined convert() method + # warns + with pytest.warns(cbook.deprecation.MatplotlibDeprecationWarning): + Converter.convert(0, 0, 0) + + # Check that manually defining a conveter doesn't warn + Converter.convert = lambda obj, unit, axis: obj + Converter.convert(0, 0, 0) diff --git a/lib/matplotlib/units.py b/lib/matplotlib/units.py index 89ccaeb7aab2..e297e93f6765 100644 --- a/lib/matplotlib/units.py +++ b/lib/matplotlib/units.py @@ -22,6 +22,11 @@ def convert(value, unit, axis): 'Convert a datetime value to a scalar or array' return dates.date2num(value) + @staticmethod + def un_convert(value, unit, axis): + 'Convert a float back to a datetime value' + return dates.num2date(value) + @staticmethod def axisinfo(unit, axis): 'Return major and minor tick locators and formatters' @@ -44,6 +49,7 @@ def default_units(x, axis): from decimal import Decimal from numbers import Number +import warnings import numpy as np from numpy import ma @@ -127,6 +133,7 @@ def default_units(x, axis): """ return None + # Make this an abstractmethod in 3.5 @staticmethod def convert(obj, unit, axis): """ @@ -135,8 +142,26 @@ def convert(obj, unit, axis): If *obj* is a sequence, return the converted sequence. The output must be a sequence of scalars that can be used by the numpy array layer. """ + cbook.warn_deprecated( + '3.3', + message=('Using the default "does nothing" convert() method for ' + 'Matplotlib ConversionInterface converters is deprecated ' + 'and will raise an error in version 3.5. ' + 'Please manually override convert().')) return obj + # Uncomment this in version 3.5 to enforce an un_convert() method + ''' + @staticmethod + @abc.abstractmethod + def un_convert(data, unit, axis): + """ + Convert data that has already been converted back to its original + value. + """ + pass + ''' + @staticmethod def is_numlike(x): """ @@ -180,6 +205,13 @@ def convert(value, unit, axis): converter = ma.asarray return converter(value, dtype=np.float) + @staticmethod + def un_convert(value, unit, axis): + """ + Un-convert from floats to Decimals. + """ + return Decimal(value) + @staticmethod def axisinfo(unit, axis): # Since Decimal is a kind of Number, don't need specific axisinfo. @@ -194,6 +226,14 @@ def default_units(x, axis): class Registry(dict): """Register types with conversion interface.""" + def __setitem__(self, cls, converter): + if not hasattr(converter, 'un_convert'): + warnings.warn( + f'{converter.__class__.__name__} does not define an ' + 'un_convert() method. From Matplotlib 3.5 this will be ' + 'required, and if not present will raise an error.') + super().__setitem__(cls, converter) + def get_converter(self, x): """Get the converter interface instance for *x*, or None.""" if hasattr(x, "values"):