From 599914acfa904bebf7d2a74bc8d24b4d78a8f0c6 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Sat, 30 May 2020 11:48:14 -0700 Subject: [PATCH 1/3] ENH: mul(Tick, float); simplify to_offset --- pandas/_libs/tslibs/offsets.pyx | 67 ++++++++++++++++++---- pandas/tests/tseries/offsets/test_ticks.py | 16 ++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index b804ed883e693..9a8ad800b6b1d 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -25,7 +25,11 @@ cnp.import_array() from pandas._libs.properties import cache_readonly from pandas._libs.tslibs cimport util -from pandas._libs.tslibs.util cimport is_integer_object, is_datetime64_object +from pandas._libs.tslibs.util cimport ( + is_integer_object, + is_datetime64_object, + is_float_object, +) from pandas._libs.tslibs.base cimport ABCTimestamp @@ -743,6 +747,25 @@ cdef class Tick(SingleConstructorOffset): "Tick offset with `normalize=True` are not allowed." ) + # FIXME: Without making this cpdef, we get AttributeError when calling + # from __mul__ + cpdef Tick next_higher_resolution(Tick self): + if type(self) is Day: + return Hour(self.n * 24) + if type(self) is Hour: + return Minute(self.n * 60) + if type(self) is Minute: + return Second(self.n * 60) + if type(self) is Second: + return Milli(self.n * 1000) + if type(self) is Milli: + return Micro(self.n * 1000) + if type(self) is Micro: + return Nano(self.n * 1000) + raise NotImplementedError(type(self)) + + # -------------------------------------------------------------------- + def _repr_attrs(self) -> str: # Since cdef classes have no __dict__, we need to override return "" @@ -791,6 +814,21 @@ cdef class Tick(SingleConstructorOffset): def __gt__(self, other): return self.delta.__gt__(other) + def __mul__(self, other): + if not isinstance(self, Tick): + # cython semantics, this is __rmul__ + return other.__mul__(self) + if is_float_object(other): + n = other * self.n + # If the new `n` is an integer, we can represent it using the + # same Tick subclass as self, otherwise we need to move up + # to a higher-resolution subclass + if np.isclose(n % 1, 0): + return type(self)(int(n)) + new_self = self.next_higher_resolution() + return new_self * other + return BaseOffset.__mul__(self, other) + def __truediv__(self, other): if not isinstance(self, Tick): # cython semantics mean the args are sometimes swapped @@ -3563,6 +3601,9 @@ cpdef to_offset(freq): >>> to_offset(Hour()) """ + # TODO: avoid runtime imports + from .timedeltas import Timedelta + if freq is None: return None @@ -3589,7 +3630,9 @@ cpdef to_offset(freq): if split[-1] != "" and not split[-1].isspace(): # the last element must be blank raise ValueError("last element must be blank") - for sep, stride, name in zip(split[0::4], split[1::4], split[2::4]): + + tups = zip(split[0::4], split[1::4], split[2::4]) + for n, (sep, stride, name) in enumerate(tups): if sep != "" and not sep.isspace(): raise ValueError("separator must be spaces") prefix = _lite_rule_alias.get(name) or name @@ -3598,15 +3641,19 @@ cpdef to_offset(freq): if not stride: stride = 1 - from .resolution import Resolution # TODO: avoid runtime import + if prefix in {"D", "H", "T", "S", "L", "U", "N"}: + td = Timedelta(1, unit=prefix) + off = delta_to_tick(td) + offset = off * float(stride) + if n != 0: + # If n==0, then stride_sign is already incorporated + # into the offset + offset *= stride_sign + else: + stride = int(stride) + offset = _get_offset(name) + offset = offset * int(np.fabs(stride) * stride_sign) - if prefix in Resolution.reso_str_bump_map: - stride, name = Resolution.get_stride_from_decimal( - float(stride), prefix - ) - stride = int(stride) - offset = _get_offset(name) - offset = offset * int(np.fabs(stride) * stride_sign) if delta is None: delta = offset else: diff --git a/pandas/tests/tseries/offsets/test_ticks.py b/pandas/tests/tseries/offsets/test_ticks.py index e5b0142dae48b..10c239c683bc0 100644 --- a/pandas/tests/tseries/offsets/test_ticks.py +++ b/pandas/tests/tseries/offsets/test_ticks.py @@ -244,6 +244,22 @@ def test_tick_division(cls): assert result.delta == off.delta / 0.001 +def test_tick_mul_float(): + off = Micro(2) + + # Case where we retain type + result = off * 1.5 + expected = Micro(3) + assert result == expected + assert isinstance(result, Micro) + + # Case where we bump up to the next type + result = off * 1.25 + expected = Nano(2500) + assert result == expected + assert isinstance(result, Nano) + + @pytest.mark.parametrize("cls", tick_classes) def test_tick_rdiv(cls): off = cls(10) From 5c61312297df82f24fdfc785312a75c650c32c9e Mon Sep 17 00:00:00 2001 From: brock Date: Sat, 30 May 2020 13:49:19 -0700 Subject: [PATCH 2/3] troubleshoot docbuild --- pandas/_libs/tslibs/offsets.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 9a8ad800b6b1d..0c558c4d7866e 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -749,7 +749,7 @@ cdef class Tick(SingleConstructorOffset): # FIXME: Without making this cpdef, we get AttributeError when calling # from __mul__ - cpdef Tick next_higher_resolution(Tick self): + cpdef Tick _next_higher_resolution(Tick self): if type(self) is Day: return Hour(self.n * 24) if type(self) is Hour: @@ -825,7 +825,7 @@ cdef class Tick(SingleConstructorOffset): # to a higher-resolution subclass if np.isclose(n % 1, 0): return type(self)(int(n)) - new_self = self.next_higher_resolution() + new_self = self._next_higher_resolution() return new_self * other return BaseOffset.__mul__(self, other) From c844352f341a2e7c249065cae6fb12c68cc24822 Mon Sep 17 00:00:00 2001 From: Brock Date: Sun, 31 May 2020 15:47:52 -0700 Subject: [PATCH 3/3] whatsnew, comments --- doc/source/whatsnew/v1.1.0.rst | 1 + pandas/_libs/tslibs/offsets.pyx | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v1.1.0.rst b/doc/source/whatsnew/v1.1.0.rst index 88bf0e005a221..77adf6dfe53a9 100644 --- a/doc/source/whatsnew/v1.1.0.rst +++ b/doc/source/whatsnew/v1.1.0.rst @@ -994,6 +994,7 @@ Other - Bug in :meth:`DataFrame.plot.scatter` caused an error when plotting variable marker sizes (:issue:`32904`) - :class:`IntegerArray` now implements the ``sum`` operation (:issue:`33172`) - Bug in :class:`Tick` comparisons raising ``TypeError`` when comparing against timedelta-like objects (:issue:`34088`) +- Bug in :class:`Tick` multiplication raising ``TypeError`` when multiplying by a float (:issue:`34486`) .. --------------------------------------------------------------------------- diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 0c558c4d7866e..0caacd81c53f5 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -3602,7 +3602,7 @@ cpdef to_offset(freq): """ # TODO: avoid runtime imports - from .timedeltas import Timedelta + from pandas._libs.tslibs.timedeltas import Timedelta if freq is None: return None @@ -3642,6 +3642,9 @@ cpdef to_offset(freq): stride = 1 if prefix in {"D", "H", "T", "S", "L", "U", "N"}: + # For these prefixes, we have something like "3H" or + # "2.5T", so we can construct a Timedelta with the + # matching unit and get our offset from delta_to_tick td = Timedelta(1, unit=prefix) off = delta_to_tick(td) offset = off * float(stride)