diff --git a/pandas/_libs/tslib.pyx b/pandas/_libs/tslib.pyx index 4c72e09a4851b..61cf582300034 100644 --- a/pandas/_libs/tslib.pyx +++ b/pandas/_libs/tslib.pyx @@ -235,23 +235,6 @@ def _test_parse_iso8601(object ts): return Timestamp(obj.value) -cpdef inline object _localize_pydatetime(object dt, object tz): - """ - Take a datetime/Timestamp in UTC and localizes to timezone tz. - """ - if tz is None: - return dt - elif isinstance(dt, Timestamp): - return dt.tz_localize(tz) - elif tz == 'UTC' or tz is UTC: - return UTC.localize(dt) - try: - # datetime.replace with pytz may be incorrect result - return tz.localize(dt) - except AttributeError: - return dt.replace(tzinfo=tz) - - def format_array_from_datetime(ndarray[int64_t] values, object tz=None, object format=None, object na_rep=None): """ diff --git a/pandas/_libs/tslibs/conversion.pxd b/pandas/_libs/tslibs/conversion.pxd index 8f887dc3af203..448dbd27e8278 100644 --- a/pandas/_libs/tslibs/conversion.pxd +++ b/pandas/_libs/tslibs/conversion.pxd @@ -31,3 +31,5 @@ cpdef int64_t pydt_to_i8(object pydt) except? -1 cdef maybe_datetimelike_to_i8(object val) cdef int64_t tz_convert_utc_to_tzlocal(int64_t utc_val, tzinfo tz) + +cpdef datetime localize_pydatetime(datetime dt, object tz) diff --git a/pandas/_libs/tslibs/conversion.pyx b/pandas/_libs/tslibs/conversion.pyx index 2e1a1e732203e..cf5053acb229b 100644 --- a/pandas/_libs/tslibs/conversion.pyx +++ b/pandas/_libs/tslibs/conversion.pyx @@ -565,7 +565,7 @@ cdef inline datetime _localize_pydatetime(datetime dt, tzinfo tz): """ Take a datetime/Timestamp in UTC and localizes to timezone tz. - NB: Unlike the version in tslib, this treats datetime and Timestamp objects + NB: Unlike the public version, this treats datetime and Timestamp objects identically, i.e. discards nanos from Timestamps. It also assumes that the `tz` input is not None. """ @@ -580,6 +580,33 @@ cdef inline datetime _localize_pydatetime(datetime dt, tzinfo tz): # ---------------------------------------------------------------------- # Timezone Conversion +cpdef inline datetime localize_pydatetime(datetime dt, object tz): + """ + Take a datetime/Timestamp in UTC and localizes to timezone tz. + + Parameters + ---------- + dt : datetime or Timestamp + tz : tzinfo, "UTC", or None + + Returns + ------- + localized : datetime or Timestamp + """ + if tz is None: + return dt + elif not PyDateTime_CheckExact(dt): + # i.e. is a Timestamp + return dt.tz_localize(tz) + elif tz == 'UTC' or tz is UTC: + return UTC.localize(dt) + try: + # datetime.replace with pytz may be incorrect result + return tz.localize(dt) + except AttributeError: + return dt.replace(tzinfo=tz) + + cdef inline int64_t tz_convert_tzlocal_to_utc(int64_t val, tzinfo tz): """ Parameters diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 841db80cf094e..be3f11ea4da1f 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -5,9 +5,13 @@ cimport cython from cython cimport Py_ssize_t import time -from cpython.datetime cimport datetime, timedelta, time as dt_time +from cpython.datetime cimport (PyDateTime_IMPORT, PyDateTime_CheckExact, + datetime, timedelta, + time as dt_time) +PyDateTime_IMPORT from dateutil.relativedelta import relativedelta +from pytz import UTC import numpy as np cimport numpy as cnp @@ -19,7 +23,7 @@ from util cimport is_string_object, is_integer_object from ccalendar import MONTHS, DAYS from ccalendar cimport get_days_in_month, dayofweek -from conversion cimport tz_convert_single, pydt_to_i8 +from conversion cimport tz_convert_single, pydt_to_i8, localize_pydatetime from frequencies cimport get_freq_code from nattype cimport NPY_NAT from np_datetime cimport (pandas_datetimestruct, @@ -494,6 +498,31 @@ class BaseOffset(_BaseOffset): # ---------------------------------------------------------------------- # RelativeDelta Arithmetic +cpdef datetime shift_day(datetime other, int days): + """ + Increment the datetime `other` by the given number of days, retaining + the time-portion of the datetime. For tz-naive datetimes this is + equivalent to adding a timedelta. For tz-aware datetimes it is similar to + dateutil's relativedelta.__add__, but handles pytz tzinfo objects. + + Parameters + ---------- + other : datetime or Timestamp + days : int + + Returns + ------- + shifted: datetime or Timestamp + """ + if other.tzinfo is None: + return other + timedelta(days=days) + + tz = other.tzinfo + naive = other.replace(tzinfo=None) + shifted = naive + timedelta(days=days) + return localize_pydatetime(shifted, tz) + + cdef inline int year_add_months(pandas_datetimestruct dts, int months) nogil: """new year number after shifting pandas_datetimestruct number of months""" return dts.year + (dts.month + months - 1) / 12 diff --git a/pandas/tests/indexes/datetimes/test_arithmetic.py b/pandas/tests/indexes/datetimes/test_arithmetic.py index cb0715c32167a..555f804800588 100644 --- a/pandas/tests/indexes/datetimes/test_arithmetic.py +++ b/pandas/tests/indexes/datetimes/test_arithmetic.py @@ -15,7 +15,7 @@ DatetimeIndex, TimedeltaIndex, date_range) from pandas.core import ops -from pandas._libs import tslib +from pandas._libs.tslibs.conversion import localize_pydatetime from pandas._libs.tslibs.offsets import shift_months @@ -56,10 +56,7 @@ def test_dti_cmp_datetimelike(self, other, tz): if isinstance(other, np.datetime64): # no tzaware version available return - elif isinstance(other, Timestamp): - other = other.tz_localize(dti.tzinfo) - else: - other = tslib._localize_pydatetime(other, dti.tzinfo) + other = localize_pydatetime(other, dti.tzinfo) result = dti == other expected = np.array([True, False]) diff --git a/pandas/tests/indexes/datetimes/test_timezones.py b/pandas/tests/indexes/datetimes/test_timezones.py index 573940edaa08f..3697d183d2fc6 100644 --- a/pandas/tests/indexes/datetimes/test_timezones.py +++ b/pandas/tests/indexes/datetimes/test_timezones.py @@ -15,8 +15,7 @@ import pandas.util._test_decorators as td import pandas as pd -from pandas._libs import tslib -from pandas._libs.tslibs import timezones +from pandas._libs.tslibs import timezones, conversion from pandas.compat import lrange, zip, PY3 from pandas import (DatetimeIndex, date_range, bdate_range, Timestamp, isna, to_datetime, Index) @@ -911,12 +910,12 @@ def test_with_tz(self, tz): central = dr.tz_convert(tz) assert central.tz is tz naive = central[0].to_pydatetime().replace(tzinfo=None) - comp = tslib._localize_pydatetime(naive, tz).tzinfo + comp = conversion.localize_pydatetime(naive, tz).tzinfo assert central[0].tz is comp # compare vs a localized tz naive = dr[0].to_pydatetime().replace(tzinfo=None) - comp = tslib._localize_pydatetime(naive, tz).tzinfo + comp = conversion.localize_pydatetime(naive, tz).tzinfo assert central[0].tz is comp # datetimes with tzinfo set @@ -946,7 +945,7 @@ def test_dti_convert_tz_aware_datetime_datetime(self, tz): dates = [datetime(2000, 1, 1), datetime(2000, 1, 2), datetime(2000, 1, 3)] - dates_aware = [tslib._localize_pydatetime(x, tz) for x in dates] + dates_aware = [conversion.localize_pydatetime(x, tz) for x in dates] result = DatetimeIndex(dates_aware) assert timezones.tz_compare(result.tz, tz) diff --git a/pandas/tests/scalar/timestamp/test_unary_ops.py b/pandas/tests/scalar/timestamp/test_unary_ops.py index b02fef707a6fe..fef01512b2060 100644 --- a/pandas/tests/scalar/timestamp/test_unary_ops.py +++ b/pandas/tests/scalar/timestamp/test_unary_ops.py @@ -10,7 +10,7 @@ import pandas.util._test_decorators as td from pandas.compat import PY3 -from pandas._libs import tslib +from pandas._libs.tslibs import conversion from pandas._libs.tslibs.frequencies import _INVALID_FREQ_ERROR from pandas import Timestamp, NaT @@ -242,7 +242,7 @@ def test_replace_across_dst(self, tz, normalize): # GH#18319 check that 1) timezone is correctly normalized and # 2) that hour is not incorrectly changed by this normalization ts_naive = Timestamp('2017-12-03 16:03:30') - ts_aware = tslib._localize_pydatetime(ts_naive, tz) + ts_aware = conversion.localize_pydatetime(ts_naive, tz) # Preliminary sanity-check assert ts_aware == normalize(ts_aware) diff --git a/pandas/tests/series/test_timezones.py b/pandas/tests/series/test_timezones.py index f2433163352ac..d59e7fd445f17 100644 --- a/pandas/tests/series/test_timezones.py +++ b/pandas/tests/series/test_timezones.py @@ -10,8 +10,7 @@ from dateutil.tz import tzoffset import pandas.util.testing as tm -from pandas._libs import tslib -from pandas._libs.tslibs import timezones +from pandas._libs.tslibs import timezones, conversion from pandas.compat import lrange from pandas.core.indexes.datetimes import date_range from pandas import Series, Timestamp, DatetimeIndex, Index @@ -298,7 +297,7 @@ def test_getitem_pydatetime_tz(self, tzstr): time_pandas = Timestamp('2012-12-24 17:00', tz=tzstr) dt = datetime(2012, 12, 24, 17, 0) - time_datetime = tslib._localize_pydatetime(dt, tz) + time_datetime = conversion.localize_pydatetime(dt, tz) assert ts[time_pandas] == ts[time_datetime] def test_series_truncate_datetimeindex_tz(self): diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index b93a0206479ca..79df847d0c8aa 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -10,6 +10,7 @@ from pandas.compat.numpy import np_datetime64_compat from pandas.core.series import Series +from pandas._libs.tslibs import conversion from pandas._libs.tslibs.frequencies import (get_freq_code, get_freq_str, _INVALID_FREQ_ERROR) from pandas.tseries.frequencies import _offset_map, get_offset @@ -319,7 +320,7 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected, for tz in self.timezones: expected_localize = expected.tz_localize(tz) tz_obj = timezones.maybe_get_tz(tz) - dt_tz = tslib._localize_pydatetime(dt, tz_obj) + dt_tz = conversion.localize_pydatetime(dt, tz_obj) result = func(dt_tz) assert isinstance(result, Timestamp) diff --git a/pandas/tests/tslibs/test_timezones.py b/pandas/tests/tslibs/test_timezones.py index 1bb355f267938..12f04505d953d 100644 --- a/pandas/tests/tslibs/test_timezones.py +++ b/pandas/tests/tslibs/test_timezones.py @@ -5,8 +5,7 @@ import pytz import dateutil.tz -from pandas._libs import tslib -from pandas._libs.tslibs import timezones +from pandas._libs.tslibs import timezones, conversion from pandas import Timestamp @@ -51,17 +50,17 @@ def test_infer_tz(eastern, localize): end = localize(eastern, end_naive) assert (timezones.infer_tzinfo(start, end) is - tslib._localize_pydatetime(start_naive, eastern).tzinfo) + conversion.localize_pydatetime(start_naive, eastern).tzinfo) assert (timezones.infer_tzinfo(start, None) is - tslib._localize_pydatetime(start_naive, eastern).tzinfo) + conversion.localize_pydatetime(start_naive, eastern).tzinfo) assert (timezones.infer_tzinfo(None, end) is - tslib._localize_pydatetime(end_naive, eastern).tzinfo) + conversion.localize_pydatetime(end_naive, eastern).tzinfo) start = utc.localize(start_naive) end = utc.localize(end_naive) assert timezones.infer_tzinfo(start, end) is utc - end = tslib._localize_pydatetime(end_naive, eastern) + end = conversion.localize_pydatetime(end_naive, eastern) with pytest.raises(Exception): timezones.infer_tzinfo(start, end) with pytest.raises(Exception): diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 1cfd3f476f8ab..f9c9ec09844d6 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -16,7 +16,9 @@ from pandas._libs import tslib, Timestamp, OutOfBoundsDatetime, Timedelta from pandas.util._decorators import cache_readonly -from pandas._libs.tslibs import ccalendar, frequencies as libfrequencies +from pandas._libs.tslibs import ( + ccalendar, conversion, + frequencies as libfrequencies) from pandas._libs.tslibs.timedeltas import delta_to_nanoseconds import pandas._libs.tslibs.offsets as liboffsets from pandas._libs.tslibs.offsets import ( @@ -76,7 +78,7 @@ def wrapper(self, other): result = func(self, other) if self._adjust_dst: - result = tslib._localize_pydatetime(result, tz) + result = conversion.localize_pydatetime(result, tz) result = Timestamp(result) if self.normalize: @@ -94,7 +96,7 @@ def wrapper(self, other): result = Timestamp(value + nano) if tz is not None and result.tzinfo is None: - result = tslib._localize_pydatetime(result, tz) + result = conversion.localize_pydatetime(result, tz) except OutOfBoundsDatetime: result = func(self, as_datetime(other)) @@ -104,37 +106,12 @@ def wrapper(self, other): result = tslib.normalize_date(result) if tz is not None and result.tzinfo is None: - result = tslib._localize_pydatetime(result, tz) + result = conversion.localize_pydatetime(result, tz) return result return wrapper -def shift_day(other, days): - """ - Increment the datetime `other` by the given number of days, retaining - the time-portion of the datetime. For tz-naive datetimes this is - equivalent to adding a timedelta. For tz-aware datetimes it is similar to - dateutil's relativedelta.__add__, but handles pytz tzinfo objects. - - Parameters - ---------- - other : datetime or Timestamp - days : int - - Returns - ------- - shifted: datetime or Timestamp - """ - if other.tzinfo is None: - return other + timedelta(days=days) - - tz = other.tzinfo - naive = other.replace(tzinfo=None) - shifted = naive + timedelta(days=days) - return tslib._localize_pydatetime(shifted, tz) - - # --------------------------------------------------------------------- # DateOffset @@ -221,7 +198,7 @@ def apply(self, other): if tzinfo is not None and self._use_relativedelta: # bring tz back from UTC calculation - other = tslib._localize_pydatetime(other, tzinfo) + other = conversion.localize_pydatetime(other, tzinfo) return as_timestamp(other) else: @@ -1355,7 +1332,7 @@ def apply(self, other): shifted = shift_month(other, months, 'start') to_day = self._get_offset_day(shifted) - return shift_day(shifted, to_day - shifted.day) + return liboffsets.shift_day(shifted, to_day - shifted.day) def onOffset(self, dt): if self.normalize and not _is_normalized(dt): @@ -1781,9 +1758,9 @@ def apply(self, other): next_year = self.get_year_end( datetime(other.year + 1, self.startingMonth, 1)) - prev_year = tslib._localize_pydatetime(prev_year, other.tzinfo) - cur_year = tslib._localize_pydatetime(cur_year, other.tzinfo) - next_year = tslib._localize_pydatetime(next_year, other.tzinfo) + prev_year = conversion.localize_pydatetime(prev_year, other.tzinfo) + cur_year = conversion.localize_pydatetime(cur_year, other.tzinfo) + next_year = conversion.localize_pydatetime(next_year, other.tzinfo) # Note: next_year.year == other.year + 1, so we will always # have other < next_year @@ -1984,7 +1961,7 @@ def _rollback_to_year(self, other): qtr_lens = self.get_weeks(norm) # check thet qtr_lens is consistent with self._offset addition - end = shift_day(start, days=7 * sum(qtr_lens)) + end = liboffsets.shift_day(start, days=7 * sum(qtr_lens)) assert self._offset.onOffset(end), (start, end, qtr_lens) tdelta = norm - start @@ -2024,7 +2001,7 @@ def apply(self, other): # Note: we always have 0 <= n < 4 weeks = sum(qtr_lens[:n]) if weeks: - res = shift_day(res, days=weeks * 7) + res = liboffsets.shift_day(res, days=weeks * 7) return res @@ -2061,7 +2038,7 @@ def onOffset(self, dt): current = next_year_end for qtr_len in qtr_lens: - current = shift_day(current, days=qtr_len * 7) + current = liboffsets.shift_day(current, days=qtr_len * 7) if dt == current: return True return False @@ -2096,8 +2073,8 @@ def apply(self, other): current_easter = easter(other.year) current_easter = datetime(current_easter.year, current_easter.month, current_easter.day) - current_easter = tslib._localize_pydatetime(current_easter, - other.tzinfo) + current_easter = conversion.localize_pydatetime(current_easter, + other.tzinfo) n = self.n if n >= 0 and other < current_easter: