Source code for months.months

# -*- coding: utf-8 -*-

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

from collections import namedtuple
import calendar
import datetime
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


[docs]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)))
[docs] @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)
[docs] @classmethod def from_today(cls): """Return a Month instance from today's date (local time).""" return cls.from_date(datetime.date.today())
[docs] @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) @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)
[docs] 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)
[docs] @_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)
[docs] @_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)