From 773363fe329872e96788e49dfb1395901e212caa Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 17 Aug 2019 11:45:57 +0100 Subject: [PATCH 1/5] Add framework for un-converting units --- lib/matplotlib/axis.py | 10 ++++++++++ lib/matplotlib/dates.py | 30 +++++++++++++++++++++++------- lib/matplotlib/units.py | 8 ++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 2101d802264c..bf7608af9178 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1576,6 +1576,16 @@ def convert_units(self, x): f'units: {x!r}') from e return ret + @property + def _can_unconvert_units(self): + if self.converter is not None: + if hasattr(self.converter, 'un_convert'): + return True + return False + + 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..b52ac1d8589f 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,24 @@ def default_units(x, axis): return None +class DateConverter(BaseDateConverter): + @staticmethod + def un_convert(value, unit, axis): + return num2date(value) + + +class Datetime64Converter(BaseDateConverter): + @staticmethod + def un_convert(value, unit, axis): + return np.datetime64(num2date(value).replace(tzinfo=None)) + + +class DatetimeConverter(BaseDateConverter): + @staticmethod + def un_convert(value, unit, axis): + return num2date(value) + + class ConciseDateConverter(DateConverter): """ Converter for datetime.date and datetime.datetime data, @@ -1968,6 +1984,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/units.py b/lib/matplotlib/units.py index 89ccaeb7aab2..7828f030c57c 100644 --- a/lib/matplotlib/units.py +++ b/lib/matplotlib/units.py @@ -137,6 +137,14 @@ def convert(obj, unit, axis): """ return obj + @staticmethod + def un_convert(data, unit, axis): + """ + Convert data that has already been converted back to its original + value. + """ + return data + @staticmethod def is_numlike(x): """ From fc958e6670ba2f321f42bda8a75b5405fe2c7765 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Fri, 27 Sep 2019 13:00:09 +0100 Subject: [PATCH 2/5] Enforce convert() and un_convert() --- lib/matplotlib/tests/test_axes.py | 30 +++++++++++++++++++----- lib/matplotlib/tests/test_dates.py | 10 ++++++-- lib/matplotlib/tests/test_patches.py | 5 +++- lib/matplotlib/tests/test_units.py | 29 +++++++++++++++++++++-- lib/matplotlib/units.py | 35 +++++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 12 deletions(-) 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..42ffc0de6224 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 Quantitfy(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 7828f030c57c..9ba463cff793 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,15 +142,25 @@ 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. """ - return data + pass + ''' @staticmethod def is_numlike(x): @@ -188,6 +205,14 @@ 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. @@ -202,6 +227,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"): From 798ae9dfc3d9a9b95e9e093e53434601ffdc56fb Mon Sep 17 00:00:00 2001 From: David Stansby Date: Fri, 27 Sep 2019 13:05:56 +0100 Subject: [PATCH 3/5] Remove additions to Axis --- lib/matplotlib/axis.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index bf7608af9178..3ac1027387b6 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1576,15 +1576,12 @@ def convert_units(self, x): f'units: {x!r}') from e return ret - @property - def _can_unconvert_units(self): - if self.converter is not None: - if hasattr(self.converter, 'un_convert'): - return True - return False - + # 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): """ From 1507a7a69a2edfdf68b96a087358e0daf96bac0a Mon Sep 17 00:00:00 2001 From: David Stansby Date: Fri, 27 Sep 2019 16:00:27 +0100 Subject: [PATCH 4/5] PEP8 --- lib/matplotlib/tests/test_units.py | 2 +- lib/matplotlib/units.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index 42ffc0de6224..2798fab2d926 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -58,7 +58,7 @@ def convert(value, unit, axis): return Quantity(value, axis.get_units()).to(unit).magnitude def un_convert(value, unit, axis): - return Quantitfy(value, unit) + return Quantity(value, unit) def default_units(value, axis): if hasattr(value, 'units'): diff --git a/lib/matplotlib/units.py b/lib/matplotlib/units.py index 9ba463cff793..e297e93f6765 100644 --- a/lib/matplotlib/units.py +++ b/lib/matplotlib/units.py @@ -212,7 +212,6 @@ def un_convert(value, unit, axis): """ return Decimal(value) - @staticmethod def axisinfo(unit, axis): # Since Decimal is a kind of Number, don't need specific axisinfo. From 21abb446be72125356055bddbc6f4930c7021be0 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Fri, 27 Sep 2019 16:04:42 +0100 Subject: [PATCH 5/5] Add new converter doc --- lib/matplotlib/dates.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index b52ac1d8589f..7905f8820967 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -1929,18 +1929,27 @@ def default_units(x, axis): 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)