diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst index 11185e0370e30..99ed779487c34 100644 --- a/doc/source/whatsnew/v2.0.0.rst +++ b/doc/source/whatsnew/v2.0.0.rst @@ -799,6 +799,7 @@ Datetimelike - Bug in :class:`Timestamp` was showing ``UserWarning``, which was not actionable by users, when parsing non-ISO8601 delimited date strings (:issue:`50232`) - Bug in :func:`to_datetime` was showing misleading ``ValueError`` when parsing dates with format containing ISO week directive and ISO weekday directive (:issue:`50308`) - Bug in :func:`to_datetime` was not raising ``ValueError`` when invalid format was passed and ``errors`` was ``'ignore'`` or ``'coerce'`` (:issue:`50266`) +- Bug in :class:`DateOffset` was throwing ``TypeError`` when constructing with milliseconds and another super-daily argument (:issue:`49897`) - Timedelta diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index f9905f297be10..470d1e89e5b88 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -298,43 +298,54 @@ _relativedelta_kwds = {"years", "months", "weeks", "days", "year", "month", cdef _determine_offset(kwds): - # timedelta is used for sub-daily plural offsets and all singular - # offsets, relativedelta is used for plural offsets of daily length or - # more, nanosecond(s) are handled by apply_wraps - kwds_no_nanos = dict( - (k, v) for k, v in kwds.items() - if k not in ("nanosecond", "nanoseconds") - ) - # TODO: Are nanosecond and nanoseconds allowed somewhere? - - _kwds_use_relativedelta = ("years", "months", "weeks", "days", - "year", "month", "week", "day", "weekday", - "hour", "minute", "second", "microsecond", - "millisecond") - - use_relativedelta = False - if len(kwds_no_nanos) > 0: - if any(k in _kwds_use_relativedelta for k in kwds_no_nanos): - if "millisecond" in kwds_no_nanos: - raise NotImplementedError( - "Using DateOffset to replace `millisecond` component in " - "datetime object is not supported. Use " - "`microsecond=timestamp.microsecond % 1000 + ms * 1000` " - "instead." - ) - offset = relativedelta(**kwds_no_nanos) - use_relativedelta = True - else: - # sub-daily offset - use timedelta (tz-aware) - offset = timedelta(**kwds_no_nanos) - elif any(nano in kwds for nano in ("nanosecond", "nanoseconds")): - offset = timedelta(days=0) - else: - # GH 45643/45890: (historically) defaults to 1 day for non-nano - # since datetime.timedelta doesn't handle nanoseconds - offset = timedelta(days=1) - return offset, use_relativedelta + if not kwds: + # GH 45643/45890: (historically) defaults to 1 day + return timedelta(days=1), False + + if "millisecond" in kwds: + raise NotImplementedError( + "Using DateOffset to replace `millisecond` component in " + "datetime object is not supported. Use " + "`microsecond=timestamp.microsecond % 1000 + ms * 1000` " + "instead." + ) + + nanos = {"nanosecond", "nanoseconds"} + + # nanos are handled by apply_wraps + if all(k in nanos for k in kwds): + return timedelta(days=0), False + kwds_no_nanos = {k: v for k, v in kwds.items() if k not in nanos} + + kwds_use_relativedelta = { + "year", "month", "day", "hour", "minute", + "second", "microsecond", "weekday", "years", "months", "weeks", "days", + "hours", "minutes", "seconds", "microseconds" + } + + # "weeks" and "days" are left out despite being valid args for timedelta, + # because (historically) timedelta is used only for sub-daily. + kwds_use_timedelta = { + "seconds", "microseconds", "milliseconds", "minutes", "hours", + } + + if all(k in kwds_use_timedelta for k in kwds_no_nanos): + # Sub-daily offset - use timedelta (tz-aware) + # This also handles "milliseconds" (plur): see GH 49897 + return timedelta(**kwds_no_nanos), False + + # convert milliseconds to microseconds, so relativedelta can parse it + if "milliseconds" in kwds_no_nanos: + micro = kwds_no_nanos.pop("milliseconds") * 1000 + kwds_no_nanos["microseconds"] = kwds_no_nanos.get("microseconds", 0) + micro + + if all(k in kwds_use_relativedelta for k in kwds_no_nanos): + return relativedelta(**kwds_no_nanos), True + + raise ValueError( + f"Invalid argument/s or bad combination of arguments: {list(kwds.keys())}" + ) # --------------------------------------------------------------------- # Mixins & Singletons @@ -1163,7 +1174,6 @@ cdef class RelativeDeltaOffset(BaseOffset): def __init__(self, n=1, normalize=False, **kwds): BaseOffset.__init__(self, n, normalize) - off, use_rd = _determine_offset(kwds) object.__setattr__(self, "_offset", off) object.__setattr__(self, "_use_relativedelta", use_rd) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 63594c2b2c48a..135227d66d541 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -739,6 +739,33 @@ def test_eq(self): assert DateOffset(milliseconds=3) != DateOffset(milliseconds=7) + @pytest.mark.parametrize( + "offset_kwargs, expected_arg", + [ + ({"microseconds": 1, "milliseconds": 1}, "2022-01-01 00:00:00.001001"), + ({"seconds": 1, "milliseconds": 1}, "2022-01-01 00:00:01.001"), + ({"minutes": 1, "milliseconds": 1}, "2022-01-01 00:01:00.001"), + ({"hours": 1, "milliseconds": 1}, "2022-01-01 01:00:00.001"), + ({"days": 1, "milliseconds": 1}, "2022-01-02 00:00:00.001"), + ({"weeks": 1, "milliseconds": 1}, "2022-01-08 00:00:00.001"), + ({"months": 1, "milliseconds": 1}, "2022-02-01 00:00:00.001"), + ({"years": 1, "milliseconds": 1}, "2023-01-01 00:00:00.001"), + ], + ) + def test_milliseconds_combination(self, offset_kwargs, expected_arg): + # GH 49897 + offset = DateOffset(**offset_kwargs) + ts = Timestamp("2022-01-01") + result = ts + offset + expected = Timestamp(expected_arg) + + assert result == expected + + def test_offset_invalid_arguments(self): + msg = "^Invalid argument/s or bad combination of arguments" + with pytest.raises(ValueError, match=msg): + DateOffset(picoseconds=1) + class TestOffsetNames: def test_get_offset_name(self):