# -*- 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