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

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