sonntagsgesicht/businessdate

View on GitHub
businessdate/businessperiod.py

Summary

Maintainability
F
5 days
Test Coverage
# -*- 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


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

    @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)

    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

    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