from __future__ import annotations
import calendar
from datetime import datetime
from typing import List
from policyengine_core import periods
from policyengine_core.periods import config, helpers
from policyengine_core.periods.instant_ import Instant
[docs]class Period(tuple):
"""
Toolbox to handle date intervals.
A period is a triple (unit, start, size), where unit is either "month" or "year", where start format is a
(year, month, day) triple, and where size is an integer > 1.
Since a period is a triple it can be used as a dictionary key.
"""
def __repr__(self) -> str:
"""
Transform period to to its Python representation as a string.
>>> repr(period('year', 2014))
"Period(('year', Instant((2014, 1, 1)), 1))"
>>> repr(period('month', '2014-2'))
"Period(('month', Instant((2014, 2, 1)), 1))"
>>> repr(period('day', '2014-2-3'))
"Period(('day', Instant((2014, 2, 3)), 1))"
"""
return "{}({})".format(
self.__class__.__name__, super(Period, self).__repr__()
)
def __str__(self) -> str:
"""
Transform period to a string.
>>> str(period(YEAR, 2014))
'2014'
>>> str(period(YEAR, '2014-2'))
'year:2014-02'
>>> str(period(MONTH, '2014-2'))
'2014-02'
>>> str(period(YEAR, 2012, size = 2))
'year:2012:2'
>>> str(period(MONTH, 2012, size = 2))
'month:2012-01:2'
>>> str(period(MONTH, 2012, size = 12))
'2012'
>>> str(period(YEAR, '2012-3', size = 2))
'year:2012-03:2'
>>> str(period(MONTH, '2012-3', size = 2))
'month:2012-03:2'
>>> str(period(MONTH, '2012-3', size = 12))
'year:2012-03'
"""
unit, start_instant, size = self
if unit == config.ETERNITY:
return "ETERNITY"
year, month, day = start_instant
# 1 year long period
if (
unit == config.MONTH
and size == 12
or unit == config.YEAR
and size == 1
):
if month == 1:
# civil year starting from january
return str(year)
else:
# rolling year
return "{}:{}-{:02d}".format(config.YEAR, year, month)
# simple month
if unit == config.MONTH and size == 1:
return "{}-{:02d}".format(year, month)
# several civil years
if unit == config.YEAR and month == 1:
return "{}:{}:{}".format(unit, year, size)
if unit == config.DAY:
if size == 1:
return "{}-{:02d}-{:02d}".format(year, month, day)
else:
return "{}:{}-{:02d}-{:02d}:{}".format(
unit, year, month, day, size
)
# complex period
return "{}:{}-{:02d}:{}".format(unit, year, month, size)
@property
def date(self) -> datetime.date:
assert (
self.size == 1
), '"date" is undefined for a period of size > 1: {}'.format(self)
return self.start.date
@property
def days(self) -> int:
"""
Count the number of days in period.
>>> period('day', 2014).days
365
>>> period('month', 2014).days
365
>>> period('year', 2014).days
365
>>> period('day', '2014-2').days
28
>>> period('month', '2014-2').days
28
>>> period('year', '2014-2').days
365
>>> period('day', '2014-2-3').days
1
>>> period('month', '2014-2-3').days
28
>>> period('year', '2014-2-3').days
365
"""
return (self.stop.date - self.start.date).days + 1
[docs] def intersection(self, start: Instant, stop: Instant):
if start is None and stop is None:
return self
period_start = self[1]
period_stop = self.stop
if start is None:
start = period_start
if stop is None:
stop = period_stop
if stop < period_start or period_stop < start:
return None
intersection_start = max(period_start, start)
intersection_stop = min(period_stop, stop)
if (
intersection_start == period_start
and intersection_stop == period_stop
):
return self
if (
intersection_start.day == 1
and intersection_start.month == 1
and intersection_stop.day == 31
and intersection_stop.month == 12
):
return self.__class__(
(
"year",
intersection_start,
intersection_stop.year - intersection_start.year + 1,
)
)
if (
intersection_start.day == 1
and intersection_stop.day
== calendar.monthrange(
intersection_stop.year, intersection_stop.month
)[1]
):
return self.__class__(
(
"month",
intersection_start,
(
(intersection_stop.year - intersection_start.year) * 12
+ intersection_stop.month
- intersection_start.month
+ 1
),
)
)
return self.__class__(
(
"day",
intersection_start,
(intersection_stop.date - intersection_start.date).days + 1,
)
)
[docs] def get_subperiods(self, unit: str) -> List["Period"]:
"""
Return the list of all the periods of unit ``unit`` contained in self.
Examples:
>>> period('2017').get_subperiods(MONTH)
>>> [period('2017-01'), period('2017-02'), ... period('2017-12')]
>>> period('year:2014:2').get_subperiods(YEAR)
>>> [period('2014'), period('2015')]
"""
if helpers.unit_weight(self.unit) < helpers.unit_weight(unit):
raise ValueError(
"Cannot subdivide {0} into {1}".format(self.unit, unit)
)
if unit == config.YEAR:
return [
self.this_year.offset(i, config.YEAR) for i in range(self.size)
]
if unit == config.MONTH:
return [
self.first_month.offset(i, config.MONTH)
for i in range(self.size_in_months)
]
if unit == config.DAY:
return [
self.first_day.offset(i, config.DAY)
for i in range(self.size_in_days)
]
[docs] def offset(self, offset: int, unit: str = None) -> "Period":
"""
Increment (or decrement) the given period with offset units.
>>> period('day', 2014).offset(1)
Period(('day', Instant((2014, 1, 2)), 365))
>>> period('day', 2014).offset(1, 'day')
Period(('day', Instant((2014, 1, 2)), 365))
>>> period('day', 2014).offset(1, 'month')
Period(('day', Instant((2014, 2, 1)), 365))
>>> period('day', 2014).offset(1, 'year')
Period(('day', Instant((2015, 1, 1)), 365))
>>> period('month', 2014).offset(1)
Period(('month', Instant((2014, 2, 1)), 12))
>>> period('month', 2014).offset(1, 'day')
Period(('month', Instant((2014, 1, 2)), 12))
>>> period('month', 2014).offset(1, 'month')
Period(('month', Instant((2014, 2, 1)), 12))
>>> period('month', 2014).offset(1, 'year')
Period(('month', Instant((2015, 1, 1)), 12))
>>> period('year', 2014).offset(1)
Period(('year', Instant((2015, 1, 1)), 1))
>>> period('year', 2014).offset(1, 'day')
Period(('year', Instant((2014, 1, 2)), 1))
>>> period('year', 2014).offset(1, 'month')
Period(('year', Instant((2014, 2, 1)), 1))
>>> period('year', 2014).offset(1, 'year')
Period(('year', Instant((2015, 1, 1)), 1))
>>> period('day', '2011-2-28').offset(1)
Period(('day', Instant((2011, 3, 1)), 1))
>>> period('month', '2011-2-28').offset(1)
Period(('month', Instant((2011, 3, 28)), 1))
>>> period('year', '2011-2-28').offset(1)
Period(('year', Instant((2012, 2, 28)), 1))
>>> period('day', '2011-3-1').offset(-1)
Period(('day', Instant((2011, 2, 28)), 1))
>>> period('month', '2011-3-1').offset(-1)
Period(('month', Instant((2011, 2, 1)), 1))
>>> period('year', '2011-3-1').offset(-1)
Period(('year', Instant((2010, 3, 1)), 1))
>>> period('day', '2014-1-30').offset(3)
Period(('day', Instant((2014, 2, 2)), 1))
>>> period('month', '2014-1-30').offset(3)
Period(('month', Instant((2014, 4, 30)), 1))
>>> period('year', '2014-1-30').offset(3)
Period(('year', Instant((2017, 1, 30)), 1))
>>> period('day', 2014).offset(-3)
Period(('day', Instant((2013, 12, 29)), 365))
>>> period('month', 2014).offset(-3)
Period(('month', Instant((2013, 10, 1)), 12))
>>> period('year', 2014).offset(-3)
Period(('year', Instant((2011, 1, 1)), 1))
>>> period('day', '2014-2-3').offset('first-of', 'month')
Period(('day', Instant((2014, 2, 1)), 1))
>>> period('day', '2014-2-3').offset('first-of', 'year')
Period(('day', Instant((2014, 1, 1)), 1))
>>> period('day', '2014-2-3', 4).offset('first-of', 'month')
Period(('day', Instant((2014, 2, 1)), 4))
>>> period('day', '2014-2-3', 4).offset('first-of', 'year')
Period(('day', Instant((2014, 1, 1)), 4))
>>> period('month', '2014-2-3').offset('first-of')
Period(('month', Instant((2014, 2, 1)), 1))
>>> period('month', '2014-2-3').offset('first-of', 'month')
Period(('month', Instant((2014, 2, 1)), 1))
>>> period('month', '2014-2-3').offset('first-of', 'year')
Period(('month', Instant((2014, 1, 1)), 1))
>>> period('month', '2014-2-3', 4).offset('first-of')
Period(('month', Instant((2014, 2, 1)), 4))
>>> period('month', '2014-2-3', 4).offset('first-of', 'month')
Period(('month', Instant((2014, 2, 1)), 4))
>>> period('month', '2014-2-3', 4).offset('first-of', 'year')
Period(('month', Instant((2014, 1, 1)), 4))
>>> period('year', 2014).offset('first-of')
Period(('year', Instant((2014, 1, 1)), 1))
>>> period('year', 2014).offset('first-of', 'month')
Period(('year', Instant((2014, 1, 1)), 1))
>>> period('year', 2014).offset('first-of', 'year')
Period(('year', Instant((2014, 1, 1)), 1))
>>> period('year', '2014-2-3').offset('first-of')
Period(('year', Instant((2014, 1, 1)), 1))
>>> period('year', '2014-2-3').offset('first-of', 'month')
Period(('year', Instant((2014, 2, 1)), 1))
>>> period('year', '2014-2-3').offset('first-of', 'year')
Period(('year', Instant((2014, 1, 1)), 1))
>>> period('day', '2014-2-3').offset('last-of', 'month')
Period(('day', Instant((2014, 2, 28)), 1))
>>> period('day', '2014-2-3').offset('last-of', 'year')
Period(('day', Instant((2014, 12, 31)), 1))
>>> period('day', '2014-2-3', 4).offset('last-of', 'month')
Period(('day', Instant((2014, 2, 28)), 4))
>>> period('day', '2014-2-3', 4).offset('last-of', 'year')
Period(('day', Instant((2014, 12, 31)), 4))
>>> period('month', '2014-2-3').offset('last-of')
Period(('month', Instant((2014, 2, 28)), 1))
>>> period('month', '2014-2-3').offset('last-of', 'month')
Period(('month', Instant((2014, 2, 28)), 1))
>>> period('month', '2014-2-3').offset('last-of', 'year')
Period(('month', Instant((2014, 12, 31)), 1))
>>> period('month', '2014-2-3', 4).offset('last-of')
Period(('month', Instant((2014, 2, 28)), 4))
>>> period('month', '2014-2-3', 4).offset('last-of', 'month')
Period(('month', Instant((2014, 2, 28)), 4))
>>> period('month', '2014-2-3', 4).offset('last-of', 'year')
Period(('month', Instant((2014, 12, 31)), 4))
>>> period('year', 2014).offset('last-of')
Period(('year', Instant((2014, 12, 31)), 1))
>>> period('year', 2014).offset('last-of', 'month')
Period(('year', Instant((2014, 1, 31)), 1))
>>> period('year', 2014).offset('last-of', 'year')
Period(('year', Instant((2014, 12, 31)), 1))
>>> period('year', '2014-2-3').offset('last-of')
Period(('year', Instant((2014, 12, 31)), 1))
>>> period('year', '2014-2-3').offset('last-of', 'month')
Period(('year', Instant((2014, 2, 28)), 1))
>>> period('year', '2014-2-3').offset('last-of', 'year')
Period(('year', Instant((2014, 12, 31)), 1))
"""
return self.__class__(
(
self[0],
self[1].offset(offset, self[0] if unit is None else unit),
self[2],
)
)
[docs] def contains(self, other: Period) -> bool:
"""
Returns ``True`` if the period contains ``other``. For instance, ``period(2015)`` contains ``period(2015-01)``
"""
return self.start <= other.start and self.stop >= other.stop
@property
def size(self) -> int:
"""
Return the size of the period.
>>> period('month', '2012-2-29', 4).size
4
"""
return self[2]
@property
def size_in_months(self) -> int:
"""
Return the size of the period in months.
>>> period('month', '2012-2-29', 4).size_in_months
4
>>> period('year', '2012', 1).size_in_months
12
"""
if self[0] == config.MONTH:
return self[2]
if self[0] == config.YEAR:
return self[2] * 12
raise ValueError(
"Cannot calculate number of months in {0}".format(self[0])
)
@property
def size_in_days(self) -> int:
"""
Return the size of the period in days.
>>> period('month', '2012-2-29', 4).size_in_days
28
>>> period('year', '2012', 1).size_in_days
366
"""
unit, instant, length = self
if unit == config.DAY:
return length
if unit in [config.MONTH, config.YEAR]:
last_day = self.start.offset(length, unit).offset(-1, config.DAY)
return (last_day.date - self.start.date).days + 1
raise ValueError("Cannot calculate number of days in {0}".format(unit))
@property
def start(self) -> periods.Instant:
"""
Return the first day of the period as an Instant instance.
>>> period('month', '2012-2-29', 4).start
Instant((2012, 2, 29))
"""
return self[1]
@property
def stop(self) -> periods.Instant:
"""
Return the last day of the period as an Instant instance.
>>> period('year', 2014).stop
Instant((2014, 12, 31))
>>> period('month', 2014).stop
Instant((2014, 12, 31))
>>> period('day', 2014).stop
Instant((2014, 12, 31))
>>> period('year', '2012-2-29').stop
Instant((2013, 2, 28))
>>> period('month', '2012-2-29').stop
Instant((2012, 3, 28))
>>> period('day', '2012-2-29').stop
Instant((2012, 2, 29))
>>> period('year', '2012-2-29', 2).stop
Instant((2014, 2, 28))
>>> period('month', '2012-2-29', 2).stop
Instant((2012, 4, 28))
>>> period('day', '2012-2-29', 2).stop
Instant((2012, 3, 1))
"""
unit, start_instant, size = self
year, month, day = start_instant
if unit == config.ETERNITY:
return periods.Instant((float("inf"), float("inf"), float("inf")))
if unit == "day":
if size > 1:
day += size - 1
month_last_day = calendar.monthrange(year, month)[1]
while day > month_last_day:
month += 1
if month == 13:
year += 1
month = 1
day -= month_last_day
month_last_day = calendar.monthrange(year, month)[1]
else:
if unit == "month":
month += size
while month > 12:
year += 1
month -= 12
else:
assert unit == "year", "Invalid unit: {} of type {}".format(
unit, type(unit)
)
year += size
day -= 1
if day < 1:
month -= 1
if month == 0:
year -= 1
month = 12
day += calendar.monthrange(year, month)[1]
else:
month_last_day = calendar.monthrange(year, month)[1]
if day > month_last_day:
month += 1
if month == 13:
year += 1
month = 1
day -= month_last_day
return periods.Instant((year, month, day))
@property
def unit(self) -> str:
return self[0]
# Reference periods
@property
def last_3_months(self) -> "Period":
return self.first_month.start.period("month", 3).offset(-3)
@property
def last_month(self) -> "Period":
return self.first_month.offset(-1)
@property
def last_year(self) -> "Period":
return self.start.offset("first-of", "year").period("year").offset(-1)
@property
def n_2(self) -> "Period":
return self.start.offset("first-of", "year").period("year").offset(-2)
@property
def this_year(self) -> "Period":
return self.start.offset("first-of", "year").period("year")
@property
def first_month(self) -> "Period":
return self.start.offset("first-of", "month").period("month")
@property
def first_day(self) -> "Period":
return self.start.period("day")