You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
11 KiB
356 lines
11 KiB
"""Class file for a Month object.
|
|
Provides various utilities for generating, manipulating, and displaying
|
|
months.
|
|
import months
|
|
>>> month = months.Month(2015, 4)
|
|
>>> print(month.full_display)
|
|
'April 2015'
|
|
>>> print(month.month_abbr)
|
|
'Apr'
|
|
>>> print(month + 9)
|
|
'2016-01'
|
|
>>> print(month.start_date)
|
|
datetime.date(2015, 4, 1)
|
|
>>> print(month.n_days)
|
|
30
|
|
>>> print(month.dates[-1])
|
|
datetime.date(2015, 4, 30)
|
|
>>> print(month.nth(-1))
|
|
datetime.date(2015, 4, 30)
|
|
>>> print(month.to(2015, 5))
|
|
[Month(2015, 4), Month(2015, 5)]
|
|
>>> print(month.distance(month + 3))
|
|
3
|
|
>>> print(month.gregorian_month_number)
|
|
24172
|
|
>>> print(int(month))
|
|
201504
|
|
>>> print(float(month))
|
|
201504.0
|
|
"""
|
|
|
|
import calendar
|
|
import datetime
|
|
from collections import namedtuple
|
|
from functools import wraps
|
|
|
|
|
|
def __utctoday():
|
|
"""Return today's date in UTC time."""
|
|
return datetime.datetime.utcnow().date()
|
|
|
|
|
|
def _get(other):
|
|
"""Coerce an arbitrary object into a Month type.
|
|
This is designed to be used in functions accepting arbitrary type
|
|
arguments in an *args situation, in which the value is expected to be
|
|
comparable to a Month.
|
|
Accepted types:
|
|
1. Month
|
|
2. Date / Datetime
|
|
3. Two-value tuples (in *args or as a single argument).
|
|
4. Single-value lists/tuples containing one of the above.
|
|
>>> __get((2018, 1))
|
|
Month(2018, 1)
|
|
"""
|
|
# check for valid types, return if its easy
|
|
if isinstance(other, Month):
|
|
return other
|
|
if isinstance(other, (datetime.date, datetime.datetime)):
|
|
return Month.from_date(other)
|
|
elif not isinstance(other, (list, tuple)):
|
|
raise TypeError('Cannot coerce %s to Month.' % type(other))
|
|
|
|
# at this point other must be a list or tuple.
|
|
# if it only has one value then try to get the first value,
|
|
# it could be a valid type stuffed in *args.
|
|
#
|
|
# otherwise create from a two-value tuple
|
|
if len(other) == 1:
|
|
return _get(other[0])
|
|
elif len(other) == 2:
|
|
return Month(*other)
|
|
else:
|
|
raise ValueError(
|
|
'Cannot coerce list/tuple of length %d to Month.' % len(other)
|
|
)
|
|
|
|
|
|
def _handle_other_decorator(func):
|
|
"""Decorate functions to handle "other" Month-like arguments.
|
|
The arguments are assumed to be within *args, while only a single
|
|
value (the Month) is actually desired for the function's execution.
|
|
The __get function coerces the value to a month and passes it to the
|
|
decorated function.
|
|
"""
|
|
@wraps(func)
|
|
def wrapper(self, *args, **kwargs):
|
|
return func(self, _get(args), **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
class Month(namedtuple('Month', ['year', 'month'])):
|
|
"""Represent a specific month of a year.
|
|
Provides various utilities for generating, manipulating, and displaying
|
|
months.
|
|
"""
|
|
|
|
def __init__(self, year, month):
|
|
"""Validate params."""
|
|
if year == 0:
|
|
raise ValueError('Year 0 is not valid in the Gregorian calendar.')
|
|
if month < 1 or month > 12:
|
|
raise ValueError('Month number must be 1-12.')
|
|
|
|
def __repr__(self):
|
|
"""Return repr."""
|
|
return (
|
|
"%s(%d, %d)" % (self.__class__.__name__, self.year, self.month)
|
|
)
|
|
|
|
def __str__(self):
|
|
"""Return month in canonical YYYY-MM string format."""
|
|
return self.start_date.strftime("%Y-%m")
|
|
|
|
def __int__(self):
|
|
"""Return month in canonical YYYYMM integer format."""
|
|
return int(self.start_date.strftime("%Y%m"))
|
|
|
|
def __float__(self):
|
|
"""Return month in canonical YYYYMM format as a float."""
|
|
return float(int(self))
|
|
|
|
@property
|
|
def month_name(self):
|
|
"""Return the calendar name of the month.
|
|
>>> Month(2015, 4).month_name
|
|
'April'
|
|
"""
|
|
return calendar.month_name[self.month]
|
|
|
|
@property
|
|
def month_abbr(self):
|
|
"""Return the abbreviated calendar name of the month.
|
|
>>> Month(2015, 4).month_abbr
|
|
'Apr'
|
|
"""
|
|
return calendar.month_abbr[self.month]
|
|
|
|
@property
|
|
def full_display(self):
|
|
"""Return the calendar name of the month along with the year.
|
|
>>> Month(2015, 4).full_display
|
|
'April 2015'
|
|
"""
|
|
return "%s %d" % (self.month_name, self.year)
|
|
|
|
@property
|
|
def abbr_display(self):
|
|
"""Return the abbreviated calendar name of the month and the year.
|
|
>>> Month(2015, 4).full_display
|
|
'Apr 2015'
|
|
"""
|
|
return "%s %d" % (self.month_abbr, self.year)
|
|
|
|
@property
|
|
def n_days(self):
|
|
"""Return the number of days in the month.
|
|
>>> Month(2018, 1).n_days
|
|
31
|
|
"""
|
|
return calendar.monthrange(self.year, self.month)[1]
|
|
|
|
@property
|
|
def gregorian_month_number(self):
|
|
"""Return the number of months since the start of Gregorian year 1.
|
|
Year 0 and month 0 are invalid. So the first month of year 1 is 1, and
|
|
the first month of year -1 is -1.
|
|
>>> Month(1, 1).gregorian_month_number
|
|
1
|
|
>>> Month(2, 2).gregorian_month_number
|
|
14
|
|
>>> Month(-1, 2).gregorian_month_number
|
|
-2
|
|
"""
|
|
if self.year > 0:
|
|
return (self.year - 1) * 12 + self.month
|
|
else:
|
|
return (self.year + 1) * 12 - self.month
|
|
|
|
@property
|
|
def dates(self):
|
|
"""Return a tuple of all days in the month.
|
|
>>> Month(2018, 1).dates[:2]
|
|
(datetime.date(2018, 1, 1), datetime.date(2018, 1, 2))
|
|
"""
|
|
return tuple(map(self.nth, range(1, self.n_days + 1)))
|
|
|
|
@classmethod
|
|
def from_date(cls, date):
|
|
"""Return a Month instance from given a date or datetime object.
|
|
Parameters
|
|
----------
|
|
date : date or datetime
|
|
A date/datetime object as implemented via the standard lib module.
|
|
Returns
|
|
-------
|
|
month : Month
|
|
The month object for that date.
|
|
"""
|
|
try:
|
|
date = date.date()
|
|
except AttributeError:
|
|
pass
|
|
return cls(date.year, date.month)
|
|
|
|
@classmethod
|
|
def from_today(cls):
|
|
"""Return a Month instance from today's date (local time)."""
|
|
return cls.from_date(datetime.date.today())
|
|
|
|
@classmethod
|
|
def from_utc_today(cls):
|
|
"""Return a Month instance from today's date (UTC time)."""
|
|
return cls.from_date(__utctoday())
|
|
|
|
def __add__(self, other):
|
|
"""Offset a number of months into the future.
|
|
>>> Month(2015, 4) + 9
|
|
Month(2016, 1)
|
|
Parameters
|
|
----------
|
|
other : int
|
|
Integer number of months to add.
|
|
Returns
|
|
-------
|
|
month : Month
|
|
The month object offset N months.
|
|
"""
|
|
if not isinstance(other, int):
|
|
raise TypeError("Only ints can be added to months")
|
|
|
|
year_change, month = divmod(self.month + other - 1, 12)
|
|
return type(self)(self.year + year_change, month + 1)
|
|
|
|
def __sub__(self, other):
|
|
"""Offset a number of months into the past.
|
|
>>> Month(2015, 4) - 9
|
|
Month(2014, 7)
|
|
Parameters
|
|
----------
|
|
other : int
|
|
Integer number of months to subtract.
|
|
Returns
|
|
-------
|
|
month : Month
|
|
The month object offset -N months.
|
|
"""
|
|
# if not isinstance(other, int):
|
|
# raise TypeError("Only ints can be subtracted from months")
|
|
#
|
|
# return self + (-other)
|
|
if isinstance(other, int):
|
|
return self + (-other)
|
|
|
|
return self.gregorian_month_number - other.gregorian_month_number
|
|
|
|
@property
|
|
def start_date(self):
|
|
"""Return a datetime.date object for the first day of the month."""
|
|
return datetime.date(self.year, self.month, 1)
|
|
|
|
@property
|
|
def end_date(self):
|
|
"""Return a datetime.date object for the last day of the month."""
|
|
return (self + 1).start_date - datetime.timedelta(1)
|
|
|
|
@property
|
|
def range(self):
|
|
"""Return a tuple of the first and last days of the month."""
|
|
return (self.start_date, self.end_date)
|
|
|
|
def nth(self, day):
|
|
"""Get date object for nth day of month.
|
|
Accepts nonzero integer values between +- ``month.n_days``.
|
|
>>> Month(2018, 1).nth(1) == Month(2018, 1).start_date
|
|
True
|
|
>>> Month(2018, 1).nth(8)
|
|
datetime.date(2018, 1, 8)
|
|
>>> Month(2018, 1).nth(-2)
|
|
datetime.date(2018, 1, 30)
|
|
Parameters
|
|
----------
|
|
day : int
|
|
Day of the month.
|
|
Returns
|
|
-------
|
|
date : datetime.date
|
|
Date object for the day of the month.
|
|
"""
|
|
|
|
# validate param
|
|
if day == 0:
|
|
raise ValueError('Day of month must be nonzero!')
|
|
if abs(day) > self.n_days:
|
|
raise ValueError(
|
|
'Day of month must be within +- %s for %s!' %
|
|
(self.n_days, self.full_display)
|
|
)
|
|
|
|
if day < 0:
|
|
day = self.n_days + 1 + day
|
|
return datetime.date(self.year, self.month, day)
|
|
|
|
@_handle_other_decorator
|
|
def to(self, other):
|
|
"""Generate a list of all months between two months, inclusively.
|
|
Accepts two-element lists/tuples, date-like objects, or Month objects.
|
|
If months are provided out of order (like ``june_18.to.march_18``) then
|
|
the list will also be in reverse order.
|
|
>>> Month(2018, 1).to(Month(2018, 2))
|
|
[Month(2018, 1), Month(2018, 2)]
|
|
>>> Month(2018, 3).to(2018, 1)
|
|
[Month(2018, 3), Month(2018, 2), Month(2018, 1)]
|
|
Parameters
|
|
----------
|
|
other : Month, date, datetime, tuple
|
|
A Month-like object.
|
|
Returns
|
|
-------
|
|
months : list
|
|
List of months spanning the two objects, inclusively.
|
|
"""
|
|
def walk(first, second):
|
|
"""TODO: Something more efficient than iterative walking."""
|
|
assert first <= second
|
|
months = [first]
|
|
while months[-1] < second:
|
|
months.append(months[-1] + 1)
|
|
return months
|
|
|
|
if self >= other:
|
|
return walk(other, self)[::-1]
|
|
else:
|
|
return walk(self, other)
|
|
|
|
@_handle_other_decorator
|
|
def distance(self, other):
|
|
"""Return the number of months distance between months.
|
|
This will always be a positive number. Accepts two-element lists/tuples
|
|
or Month objects.
|
|
>>> Month(2018, 1).distance(Month(2018, 12))
|
|
11
|
|
>>> Month(2018, 5).distance(2018, 1)
|
|
4
|
|
Parameters
|
|
----------
|
|
other : Month, date, datetime, tuple
|
|
A Month-like object.
|
|
Returns
|
|
-------
|
|
n_months : int
|
|
Integer number of months distance.
|
|
"""
|
|
return abs(self.gregorian_month_number - other.gregorian_month_number)
|