Skip to content

Commit 7bc699f

Browse files
authored
BUG: pd.DateOffset handle milliseconds (#50020)
* BUG: fix milliseconds and add test * BUG: move condition-check outside main if-block * DOC: update whatsnew * DOC: update whatsnew * CLN: reduce docstring * CLN: remove return statement * TST: update assertions * CLN: rework _determine_offset and add tests * CLN: def to cdef * BUG: GH49897 raise for invalid arguments + test * TST: GH 49897 remove useless test * TST: BUG49897 convert millis to micros so relativedelta can use them * TST: GH49897 test millis + micros * CLN: GH49897 remove comment * DOC: GH49897 update rst * CLN: re-order if-conditions * TST: GH49897 test raise
1 parent cc478c4 commit 7bc699f

File tree

3 files changed

+75
-37
lines changed

3 files changed

+75
-37
lines changed

doc/source/whatsnew/v2.0.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ Datetimelike
799799
- Bug in :class:`Timestamp` was showing ``UserWarning``, which was not actionable by users, when parsing non-ISO8601 delimited date strings (:issue:`50232`)
800800
- Bug in :func:`to_datetime` was showing misleading ``ValueError`` when parsing dates with format containing ISO week directive and ISO weekday directive (:issue:`50308`)
801801
- Bug in :func:`to_datetime` was not raising ``ValueError`` when invalid format was passed and ``errors`` was ``'ignore'`` or ``'coerce'`` (:issue:`50266`)
802+
- Bug in :class:`DateOffset` was throwing ``TypeError`` when constructing with milliseconds and another super-daily argument (:issue:`49897`)
802803
-
803804

804805
Timedelta

pandas/_libs/tslibs/offsets.pyx

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -298,43 +298,54 @@ _relativedelta_kwds = {"years", "months", "weeks", "days", "year", "month",
298298

299299

300300
cdef _determine_offset(kwds):
301-
# timedelta is used for sub-daily plural offsets and all singular
302-
# offsets, relativedelta is used for plural offsets of daily length or
303-
# more, nanosecond(s) are handled by apply_wraps
304-
kwds_no_nanos = dict(
305-
(k, v) for k, v in kwds.items()
306-
if k not in ("nanosecond", "nanoseconds")
307-
)
308-
# TODO: Are nanosecond and nanoseconds allowed somewhere?
309-
310-
_kwds_use_relativedelta = ("years", "months", "weeks", "days",
311-
"year", "month", "week", "day", "weekday",
312-
"hour", "minute", "second", "microsecond",
313-
"millisecond")
314-
315-
use_relativedelta = False
316-
if len(kwds_no_nanos) > 0:
317-
if any(k in _kwds_use_relativedelta for k in kwds_no_nanos):
318-
if "millisecond" in kwds_no_nanos:
319-
raise NotImplementedError(
320-
"Using DateOffset to replace `millisecond` component in "
321-
"datetime object is not supported. Use "
322-
"`microsecond=timestamp.microsecond % 1000 + ms * 1000` "
323-
"instead."
324-
)
325-
offset = relativedelta(**kwds_no_nanos)
326-
use_relativedelta = True
327-
else:
328-
# sub-daily offset - use timedelta (tz-aware)
329-
offset = timedelta(**kwds_no_nanos)
330-
elif any(nano in kwds for nano in ("nanosecond", "nanoseconds")):
331-
offset = timedelta(days=0)
332-
else:
333-
# GH 45643/45890: (historically) defaults to 1 day for non-nano
334-
# since datetime.timedelta doesn't handle nanoseconds
335-
offset = timedelta(days=1)
336-
return offset, use_relativedelta
301+
if not kwds:
302+
# GH 45643/45890: (historically) defaults to 1 day
303+
return timedelta(days=1), False
304+
305+
if "millisecond" in kwds:
306+
raise NotImplementedError(
307+
"Using DateOffset to replace `millisecond` component in "
308+
"datetime object is not supported. Use "
309+
"`microsecond=timestamp.microsecond % 1000 + ms * 1000` "
310+
"instead."
311+
)
312+
313+
nanos = {"nanosecond", "nanoseconds"}
314+
315+
# nanos are handled by apply_wraps
316+
if all(k in nanos for k in kwds):
317+
return timedelta(days=0), False
337318

319+
kwds_no_nanos = {k: v for k, v in kwds.items() if k not in nanos}
320+
321+
kwds_use_relativedelta = {
322+
"year", "month", "day", "hour", "minute",
323+
"second", "microsecond", "weekday", "years", "months", "weeks", "days",
324+
"hours", "minutes", "seconds", "microseconds"
325+
}
326+
327+
# "weeks" and "days" are left out despite being valid args for timedelta,
328+
# because (historically) timedelta is used only for sub-daily.
329+
kwds_use_timedelta = {
330+
"seconds", "microseconds", "milliseconds", "minutes", "hours",
331+
}
332+
333+
if all(k in kwds_use_timedelta for k in kwds_no_nanos):
334+
# Sub-daily offset - use timedelta (tz-aware)
335+
# This also handles "milliseconds" (plur): see GH 49897
336+
return timedelta(**kwds_no_nanos), False
337+
338+
# convert milliseconds to microseconds, so relativedelta can parse it
339+
if "milliseconds" in kwds_no_nanos:
340+
micro = kwds_no_nanos.pop("milliseconds") * 1000
341+
kwds_no_nanos["microseconds"] = kwds_no_nanos.get("microseconds", 0) + micro
342+
343+
if all(k in kwds_use_relativedelta for k in kwds_no_nanos):
344+
return relativedelta(**kwds_no_nanos), True
345+
346+
raise ValueError(
347+
f"Invalid argument/s or bad combination of arguments: {list(kwds.keys())}"
348+
)
338349

339350
# ---------------------------------------------------------------------
340351
# Mixins & Singletons
@@ -1163,7 +1174,6 @@ cdef class RelativeDeltaOffset(BaseOffset):
11631174

11641175
def __init__(self, n=1, normalize=False, **kwds):
11651176
BaseOffset.__init__(self, n, normalize)
1166-
11671177
off, use_rd = _determine_offset(kwds)
11681178
object.__setattr__(self, "_offset", off)
11691179
object.__setattr__(self, "_use_relativedelta", use_rd)

pandas/tests/tseries/offsets/test_offsets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,33 @@ def test_eq(self):
739739

740740
assert DateOffset(milliseconds=3) != DateOffset(milliseconds=7)
741741

742+
@pytest.mark.parametrize(
743+
"offset_kwargs, expected_arg",
744+
[
745+
({"microseconds": 1, "milliseconds": 1}, "2022-01-01 00:00:00.001001"),
746+
({"seconds": 1, "milliseconds": 1}, "2022-01-01 00:00:01.001"),
747+
({"minutes": 1, "milliseconds": 1}, "2022-01-01 00:01:00.001"),
748+
({"hours": 1, "milliseconds": 1}, "2022-01-01 01:00:00.001"),
749+
({"days": 1, "milliseconds": 1}, "2022-01-02 00:00:00.001"),
750+
({"weeks": 1, "milliseconds": 1}, "2022-01-08 00:00:00.001"),
751+
({"months": 1, "milliseconds": 1}, "2022-02-01 00:00:00.001"),
752+
({"years": 1, "milliseconds": 1}, "2023-01-01 00:00:00.001"),
753+
],
754+
)
755+
def test_milliseconds_combination(self, offset_kwargs, expected_arg):
756+
# GH 49897
757+
offset = DateOffset(**offset_kwargs)
758+
ts = Timestamp("2022-01-01")
759+
result = ts + offset
760+
expected = Timestamp(expected_arg)
761+
762+
assert result == expected
763+
764+
def test_offset_invalid_arguments(self):
765+
msg = "^Invalid argument/s or bad combination of arguments"
766+
with pytest.raises(ValueError, match=msg):
767+
DateOffset(picoseconds=1)
768+
742769

743770
class TestOffsetNames:
744771
def test_get_offset_name(self):

0 commit comments

Comments
 (0)