Source code for businessdate.businessperiod

# -*- coding: utf-8 -*-

# businessdate
# ------------
# Python library for generating business dates for fast date operations
# and rich functionality.
# 
# Author:   sonntagsgesicht, based on a fork of Deutsche Postbank [pbrisk]
# Version:  0.5, copyright Wednesday, 18 September 2019
# Website:  https://github.com/sonntagsgesicht/businessdate
# License:  Apache License 2.0 (see LICENSE file)


from datetime import timedelta


[docs]class BusinessPeriod(object): def __init__(self, period='', years=0, quarters=0, months=0, weeks=0, days=0, businessdays=0): """ class to store and calculate date periods as combinations of days, weeks, years etc. :param str period: encoding a business period. Such is given by a sequence of digits as :class:`int` followed by a :class:`char` - indicating the number of years **Y**, quarters **Q** (which is equivalent to 3 month), month **M**, weeks **W** (which is equivalent to 7 days), days **D**, business days **B**. E.g. **1Y2W3D** what gives a period of 1 year plus 2 weeks and 3 days (see :doc:`tutorial <tutorial>` for details). :param int years: number of years in the period (equivalent to 12 months) :param int quarters: number of quarters in the period (equivalent to 3 months) :param int months: number of month in the period :param int weeks: number of weeks in the period (equivalent to 7 days) :param int days: number of days in the period :param int businessdays: number of business days, i.e. days which are neither weekend nor :class:`holidays <BusinessHolidays>`, in the period. Only either `businessdays` or the others can be given. Both at the same time is not allowed. """ if period and any((years, months, days, businessdays)): raise ValueError( "Either string or argument input only for %s" % self.__class__.__name__) super(BusinessPeriod, self).__init__() if isinstance(period, BusinessPeriod): years = period.years months = period.months days = period.days businessdays = period.businessdays elif isinstance(period, timedelta): days = period.days elif period is None: pass elif isinstance(period, str): if period.upper() == '': pass elif period.upper() == '0D': pass elif period.upper() == 'ON': businessdays = 1 elif period.upper() == 'TN': businessdays = 2 elif period.upper() == 'DD': businessdays = 3 else: s, y, q, m, w, d, f = BusinessPeriod._parse_ymd(period) # no final businesdays allowed if f: raise ValueError("Unable to parse %s as %s" % (period, self.__class__.__name__)) # except the first non vanishing of y,q,m,w,d must have positive sign sgn = [int(x / abs(x)) for x in (y, q, m, w, d) if x] if [x for x in sgn[1:] if x < 0]: raise ValueError( "Except at the beginning no signs allowed in %s as %s" % (str(period), self.__class__.__name__)) y, q, m, w, d = (abs(x) for x in (y, q, m, w, d)) # use sign of first non vanishing of y,q,m,w,d sgn = sgn[0] if sgn else 1 businessdays, years, quarters, months, weeks, days = s, sgn * y, sgn * q, sgn * m, sgn * w, sgn * d else: raise TypeError( "%s of Type %s not valid to create BusinessPeriod." %(str(period), period.__class__.__name__)) self._months = 12 * years + 3 * quarters + months self._days = 7 * weeks + days self._businessdays = businessdays if businessdays and (self._months or self._days): raise ValueError( "Either (years,months,days) or businessdays must be zero for %s" % self.__class__.__name__) if self._months and not self._days / self._months >= 0: ymd = self.years, self.months, self.days raise ValueError( "(years, months, days)=%s must have equal sign for %s" % (str(ymd), self.__class__.__name__)) @property def years(self): return int(-1 * (-1 * self._months // 12) if self._months < 0 else self._months // 12) @property def months(self): return int(-1 * (-1 * self._months % 12) if self._months < 0 else self._months % 12) @property def days(self): return int(self._days) @property def businessdays(self): return int(self._businessdays) # --- validation and information methods --------------------------------- @classmethod def _parse_ymd(cls, period): # can even parse strings like '-1B-2Y-4Q+5M' but also '0B', '-1Y2M3D' as well. period = period.upper().replace(' ', '') period = period.replace('BUSINESSDAYS', 'B') period = period.replace('YEARS', 'Y') period = period.replace('QUARTERS', 'Q') period = period.replace('MONTHS', 'M') period = period.replace('WEEKS', 'W') period = period.replace('DAYS', 'D') def _parse(p, letter): if p.find(letter) >= 0: s, p = p.split(letter, 1) s = s[1:] if s.startswith('+') else s sgn, s = (-1, s[1:]) if s.startswith('-') else (1, s) if not s.isdigit(): raise ValueError("Unable to parse %s in %s as %s" % (s, p, cls.__name__)) return sgn * int(s), p return 0, p p = period.upper() # p[-1] is not 'B', p.strip('0123456789+-B')=='' s, p = _parse(p, 'B') if not p[-1]=='B' else (0, p) s, p = _parse(p, 'B') if not p.strip('0123456789+-B') else (s, p) s, p = _parse(p, 'B') if p.count('B') > 1 else (s, p) y, p = _parse(p, 'Y') q, p = _parse(p, 'Q') m, p = _parse(p, 'M') w, p = _parse(p, 'W') d, p = _parse(p, 'D') f, p = _parse(p, 'B') if not p == '': raise ValueError("Unable to parse %s as %s" % (p, cls.__name__)) return s, y, q, m, w, d, f
[docs] @classmethod def is_businessperiod(cls, period): """ returns true if the argument can be understood as :class:`BusinessPeriod` """ if period is None: return False if isinstance(period, (int, float, list, set, dict, tuple)): return False if isinstance(period, (timedelta, BusinessPeriod)): return True if period in ('', '0D', 'ON', 'TN', 'DD'): return True if isinstance(period, str): if period.isdigit(): return False #if period.upper().strip('+-0123456789BYQMWD'): # return False try: # to be removed BusinessPeriod._parse_ymd(period) except ValueError: return False return True return False
# --- operator methods --------------------------------------------------- def __repr__(self): return self.__class__.__name__ + "('%s')" % str(self) def __str__(self): if self.businessdays: period_str = str(self.businessdays) + 'B' else: period_str = '-' if self.years < 0 or self.months < 0 or self.days < 0 else '' if self.years: period_str += str(abs(self.years)) + 'Y' if self.months: period_str += str(abs(self.months)) + 'M' if self.days: period_str += str(abs(self.days)) + 'D' if not period_str: period_str = '0D' return period_str def __abs__(self): ymdb = self.years, self.months, self.days, self.businessdays y,m,d,b = tuple(map(abs, ymdb)) return self.__class__(years=y, months=m, days=d, businessdays=b) def __cmp__(self, other): other = self.__class__() if other == 0 else other if not isinstance(other, BusinessPeriod): other = BusinessPeriod(other) if self.businessdays: if other and not other.businessdays: # log warning on non compatible pair return None return self.businessdays - other.businessdays m = 12 * (self.years - other.years) + self.months - other.months d = self.days - other.days if m * 28 < -d < m * 31: p = self.__class__(months=m) if p.min_days() <= -d <= p.max_days(): # log warning on non orderable pair return None return m * 30.5 + d def __eq__(self, other): if isinstance(other, type(self)): attr = 'years', 'months', 'days', 'businessdays' return all(getattr(self, a) == getattr(other, a) for a in attr) return False def __ne__(self, other): return not self.__eq__(other) def __le__(self, other): cmp = self.__cmp__(other) cmp = self.__cmp__(other + '1D') if cmp is None else cmp return cmp if cmp is None else cmp <= 0 def __lt__(self, other): cmp = self.__cmp__(other) return cmp if cmp is None else cmp < 0 def __ge__(self, other): lt = self.__lt__(other) return None if lt is None else not lt def __gt__(self, other): le = self.__le__(other) return None if le is None else not le def __hash__(self): return hash(repr(self)) def __nonzero__(self): # return any((self.years, self.months, self.days, self.businessdays)) return self.__bool__() def __bool__(self): # return self.__nonzero__() return any((self._months, self._days, self._businessdays)) def __add__(self, other): if isinstance(other, (list, tuple)): return [self + o for o in other] if BusinessPeriod.is_businessperiod(other): p = BusinessPeriod(other) y = self.years + p.years m = self.months + p.months d = self.days + p.days b = self.businessdays + p.businessdays return self.__class__(years=y, months=m, days=d, businessdays=b) raise TypeError('addition of BusinessPeriod cannot handle objects of type %s.' % other.__class__.__name__) def __sub__(self, other): if isinstance(other, (list, tuple)): return [self - o for o in other] if BusinessPeriod.is_businessperiod(other): return self + (-1 * BusinessPeriod(other)) raise TypeError('subtraction of BusinessPeriod cannot handle objects of type %s.' % other.__class__.__name__) def __mul__(self, other): if isinstance(other, (list, tuple)): return [self * o for o in other] if isinstance(other, int): m = other * self._months d = other * self._days b = other * self._businessdays return BusinessPeriod(months=m, days=d, businessdays=b) raise TypeError("expected int type but got %s" % other.__class__.__name__) def __rmul__(self, other): return self.__mul__(other)
[docs] def max_days(self): if self._months < 0: sgn = -1 days_in_month = 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 28 # days from mar to feb forwards else: sgn = 1 days_in_month = 31, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 28 m = sgn * self._months # days from jan to feb backwards days = 0 for i in range(m): days += days_in_month[int(i % 12)] days += 1 if int(i % 48) == 11 else 0 return sgn * days + self._days
[docs] def min_days(self): if self._months < 0 : sgn = -1 days_in_month = 28, 31, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 # days from feb to jan backwards else: sgn = 1 days_in_month = 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31 # days from feb to jan forwards m = sgn * self._months days = 0 for i in range(m): days += days_in_month[int(i % 12)] days += 1 if int(i % 48) == 36 else 0 return sgn * days + self._days