Skip to content

Commit fdc662f

Browse files
jbrockmendelNo-Stream
authored andcommitted
Implement BaseOffset in tslibs.offsets (pandas-dev#18016)
1 parent e466369 commit fdc662f

File tree

3 files changed

+197
-151
lines changed

3 files changed

+197
-151
lines changed

pandas/_libs/tslibs/offsets.pyx

+174-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
cimport cython
55

66
import time
7-
from cpython.datetime cimport time as dt_time
7+
from cpython.datetime cimport timedelta, time as dt_time
8+
9+
from dateutil.relativedelta import relativedelta
810

911
import numpy as np
1012
cimport numpy as np
@@ -13,9 +15,11 @@ np.import_array()
1315

1416
from util cimport is_string_object
1517

16-
from conversion cimport tz_convert_single
1718
from pandas._libs.tslib import pydt_to_i8
1819

20+
from frequencies cimport get_freq_code
21+
from conversion cimport tz_convert_single
22+
1923
# ---------------------------------------------------------------------
2024
# Constants
2125

@@ -79,7 +83,6 @@ _offset_to_period_map = {
7983

8084
need_suffix = ['QS', 'BQ', 'BQS', 'YS', 'AS', 'BY', 'BA', 'BYS', 'BAS']
8185

82-
8386
for __prefix in need_suffix:
8487
for _m in _MONTHS:
8588
key = '%s-%s' % (__prefix, _m)
@@ -105,17 +108,38 @@ def as_datetime(obj):
105108
return obj
106109

107110

108-
def _is_normalized(dt):
111+
cpdef bint _is_normalized(dt):
109112
if (dt.hour != 0 or dt.minute != 0 or dt.second != 0 or
110113
dt.microsecond != 0 or getattr(dt, 'nanosecond', 0) != 0):
111114
return False
112115
return True
113116

114117

118+
def apply_index_wraps(func):
119+
# Note: normally we would use `@functools.wraps(func)`, but this does
120+
# not play nicely wtih cython class methods
121+
def wrapper(self, other):
122+
result = func(self, other)
123+
if self.normalize:
124+
result = result.to_period('D').to_timestamp()
125+
return result
126+
127+
# do @functools.wraps(func) manually since it doesn't work on cdef funcs
128+
wrapper.__name__ = func.__name__
129+
wrapper.__doc__ = func.__doc__
130+
try:
131+
wrapper.__module__ = func.__module__
132+
except AttributeError:
133+
# AttributeError: 'method_descriptor' object has no
134+
# attribute '__module__'
135+
pass
136+
return wrapper
137+
138+
115139
# ---------------------------------------------------------------------
116140
# Business Helpers
117141

118-
def _get_firstbday(wkday):
142+
cpdef int _get_firstbday(int wkday):
119143
"""
120144
wkday is the result of monthrange(year, month)
121145
@@ -194,6 +218,45 @@ def _validate_business_time(t_input):
194218
else:
195219
raise ValueError("time data must be string or datetime.time")
196220

221+
222+
# ---------------------------------------------------------------------
223+
# Constructor Helpers
224+
225+
_rd_kwds = set([
226+
'years', 'months', 'weeks', 'days',
227+
'year', 'month', 'week', 'day', 'weekday',
228+
'hour', 'minute', 'second', 'microsecond',
229+
'nanosecond', 'nanoseconds',
230+
'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds'])
231+
232+
233+
def _determine_offset(kwds):
234+
# timedelta is used for sub-daily plural offsets and all singular
235+
# offsets relativedelta is used for plural offsets of daily length or
236+
# more nanosecond(s) are handled by apply_wraps
237+
kwds_no_nanos = dict(
238+
(k, v) for k, v in kwds.items()
239+
if k not in ('nanosecond', 'nanoseconds')
240+
)
241+
# TODO: Are nanosecond and nanoseconds allowed somewhere?
242+
243+
_kwds_use_relativedelta = ('years', 'months', 'weeks', 'days',
244+
'year', 'month', 'week', 'day', 'weekday',
245+
'hour', 'minute', 'second', 'microsecond')
246+
247+
use_relativedelta = False
248+
if len(kwds_no_nanos) > 0:
249+
if any(k in _kwds_use_relativedelta for k in kwds_no_nanos):
250+
offset = relativedelta(**kwds_no_nanos)
251+
use_relativedelta = True
252+
else:
253+
# sub-daily offset - use timedelta (tz-aware)
254+
offset = timedelta(**kwds_no_nanos)
255+
else:
256+
offset = timedelta(1)
257+
return offset, use_relativedelta
258+
259+
197260
# ---------------------------------------------------------------------
198261
# Mixins & Singletons
199262

@@ -206,3 +269,109 @@ class ApplyTypeError(TypeError):
206269
# TODO: unused. remove?
207270
class CacheableOffset(object):
208271
_cacheable = True
272+
273+
274+
class BeginMixin(object):
275+
# helper for vectorized offsets
276+
277+
def _beg_apply_index(self, i, freq):
278+
"""Offsets index to beginning of Period frequency"""
279+
280+
off = i.to_perioddelta('D')
281+
282+
base, mult = get_freq_code(freq)
283+
base_period = i.to_period(base)
284+
if self.n <= 0:
285+
# when subtracting, dates on start roll to prior
286+
roll = np.where(base_period.to_timestamp() == i - off,
287+
self.n, self.n + 1)
288+
else:
289+
roll = self.n
290+
291+
base = (base_period + roll).to_timestamp()
292+
return base + off
293+
294+
295+
class EndMixin(object):
296+
# helper for vectorized offsets
297+
298+
def _end_apply_index(self, i, freq):
299+
"""Offsets index to end of Period frequency"""
300+
301+
off = i.to_perioddelta('D')
302+
303+
base, mult = get_freq_code(freq)
304+
base_period = i.to_period(base)
305+
if self.n > 0:
306+
# when adding, dates on end roll to next
307+
roll = np.where(base_period.to_timestamp(how='end') == i - off,
308+
self.n, self.n - 1)
309+
else:
310+
roll = self.n
311+
312+
base = (base_period + roll).to_timestamp(how='end')
313+
return base + off
314+
315+
316+
# ---------------------------------------------------------------------
317+
# Base Classes
318+
319+
class _BaseOffset(object):
320+
"""
321+
Base class for DateOffset methods that are not overriden by subclasses
322+
and will (after pickle errors are resolved) go into a cdef class.
323+
"""
324+
_typ = "dateoffset"
325+
_normalize_cache = True
326+
_cacheable = False
327+
328+
def __call__(self, other):
329+
return self.apply(other)
330+
331+
def __mul__(self, someInt):
332+
return self.__class__(n=someInt * self.n, normalize=self.normalize,
333+
**self.kwds)
334+
335+
def __neg__(self):
336+
# Note: we are defering directly to __mul__ instead of __rmul__, as
337+
# that allows us to use methods that can go in a `cdef class`
338+
return self * -1
339+
340+
def copy(self):
341+
# Note: we are defering directly to __mul__ instead of __rmul__, as
342+
# that allows us to use methods that can go in a `cdef class`
343+
return self * 1
344+
345+
# TODO: this is never true. fix it or get rid of it
346+
def _should_cache(self):
347+
return self.isAnchored() and self._cacheable
348+
349+
def __repr__(self):
350+
className = getattr(self, '_outputName', type(self).__name__)
351+
352+
if abs(self.n) != 1:
353+
plural = 's'
354+
else:
355+
plural = ''
356+
357+
n_str = ""
358+
if self.n != 1:
359+
n_str = "%s * " % self.n
360+
361+
out = '<%s' % n_str + className + plural + self._repr_attrs() + '>'
362+
return out
363+
364+
365+
class BaseOffset(_BaseOffset):
366+
# Here we add __rfoo__ methods that don't play well with cdef classes
367+
def __rmul__(self, someInt):
368+
return self.__mul__(someInt)
369+
370+
def __radd__(self, other):
371+
return self.__add__(other)
372+
373+
def __rsub__(self, other):
374+
if getattr(other, '_typ', None) in ['datetimeindex', 'series']:
375+
# i.e. isinstance(other, (ABCDatetimeIndex, ABCSeries))
376+
return other - self
377+
return -self + other

0 commit comments

Comments
 (0)