Source code for greatday.dates

"""Greatday date utilities."""

from __future__ import annotations

from dataclasses import dataclass
import datetime as dt
from typing import Final, Protocol

from dateutil.relativedelta import relativedelta
import magodo


# metatags (i.e. key-value tags) that accept relative date strings (e.g. '1d')
RELATIVE_DATE_METATAGS: Final = ["snooze", "until", "due"]

# days of the week
MONDAY: Final[int] = 0
SUNDAY: Final[int] = 6

_DEFAULT_YEAR: Final[int] = -1
_FRIDAY: Final[int] = 4
_SATURDAY: Final[int] = 5


[docs]@dataclass(frozen=True) class DateRange: """Represents a range of dates.""" start: dt.date end: dt.date | None = None
[docs] @classmethod def from_strings(cls, start_str: str, end_str: str = None) -> DateRange: """Constructs a DateRange from two strings.""" start = magodo.dates.to_date(start_str) end = magodo.dates.to_date(end_str) if end_str else None return cls(start, end)
[docs]class DayMaker(Protocol): """Signature for a function that returns Mondays.""" def __call__( self, *, day_of_week: int = MONDAY, year: int = _DEFAULT_YEAR ) -> list[dt.date]: """The function's call signature."""
[docs]def get_all_days( *, day_of_week: int = MONDAY, year: int = _DEFAULT_YEAR ) -> list[dt.date]: """Returns all days of a particular name (i.e. Monday) of a given year. Examples: >>> mondays = get_all_days(year=2020) >>> len(mondays) 52 >>> mondays[0] datetime.date(2020, 1, 6) >>> mondays[-1] datetime.date(2020, 12, 28) >>> mondays = get_all_days(year=2021) >>> len(mondays) 52 >>> mondays[0] datetime.date(2021, 1, 4) >>> mondays[-1] datetime.date(2021, 12, 27) """ if year == _DEFAULT_YEAR: year = dt.date.today().year d = dt.date(year, 1, 1) while d.weekday() != day_of_week: d += dt.timedelta(days=1) days = [] while d.year == year: days.append(d) d += dt.timedelta(weeks=1) return days
[docs]def get_quarter_days( *, day_of_week: int = MONDAY, year: int = _DEFAULT_YEAR ) -> list[dt.date]: """Returns every first Monday of every quarter from `year`. Examples: >>> quarter_mondays = get_quarter_days(year=2020) >>> len(quarter_mondays) 4 >>> quarter_mondays[0] datetime.date(2020, 1, 6) >>> quarter_mondays[1] datetime.date(2020, 4, 6) >>> quarter_mondays[2] datetime.date(2020, 7, 6) >>> quarter_mondays[3] datetime.date(2020, 10, 5) >>> quarter_mondays = get_quarter_days(year=2021) >>> len(quarter_mondays) 4 >>> quarter_mondays[0] datetime.date(2021, 1, 4) >>> quarter_mondays[1] datetime.date(2021, 4, 5) >>> quarter_mondays[2] datetime.date(2021, 7, 5) >>> quarter_mondays[3] datetime.date(2021, 10, 4) """ days = [] for i, monday in enumerate( get_all_days(day_of_week=day_of_week, year=year) ): if i % 13 == 0: days.append(monday) return days
[docs]def get_month_days( *, day_of_week: int = MONDAY, year: int = _DEFAULT_YEAR ) -> list[dt.date]: """Returns a list of Mondays that begin a month. By month here, we are referring to a "greatday month", which begins on either the first Monday of the quarter, 4 weeks from that day, or 8 weeks from that day. Examples: >>> month_mondays = get_month_days(year=2020) >>> len(month_mondays) 12 >>> month_mondays[0] datetime.date(2020, 1, 6) >>> month_mondays[1] datetime.date(2020, 2, 3) >>> month_mondays[2] datetime.date(2020, 3, 2) >>> month_mondays[3] datetime.date(2020, 4, 6) >>> month_mondays[4] datetime.date(2020, 5, 4) >>> month_mondays[5] datetime.date(2020, 6, 1) >>> month_mondays[6] datetime.date(2020, 7, 6) >>> month_mondays[7] datetime.date(2020, 8, 3) >>> month_mondays[8] datetime.date(2020, 8, 31) >>> month_mondays[9] datetime.date(2020, 10, 5) >>> month_mondays[10] datetime.date(2020, 11, 2) >>> month_mondays[11] datetime.date(2020, 11, 30) >>> month_mondays = get_month_days(year=2021) >>> len(month_mondays) 12 >>> month_mondays[0] datetime.date(2021, 1, 4) >>> month_mondays[1] datetime.date(2021, 2, 1) >>> month_mondays[2] datetime.date(2021, 3, 1) >>> month_mondays[3] datetime.date(2021, 4, 5) >>> month_mondays[4] datetime.date(2021, 5, 3) >>> month_mondays[5] datetime.date(2021, 5, 31) >>> month_mondays[6] datetime.date(2021, 7, 5) >>> month_mondays[7] datetime.date(2021, 8, 2) >>> month_mondays[8] datetime.date(2021, 8, 30) >>> month_mondays[9] datetime.date(2021, 10, 4) >>> month_mondays[10] datetime.date(2021, 11, 1) >>> month_mondays[11] datetime.date(2021, 11, 29) """ month_indices = [0, 4, 8, 13, 17, 21, 26, 30, 34, 39, 43, 47] days = [] for i, day in enumerate(get_all_days(day_of_week=day_of_week, year=year)): if i in month_indices: days.append(day) return days
[docs]def get_next_day( date: dt.date = None, *, day_of_week: int = MONDAY, day_maker: DayMaker = get_all_days, ) -> dt.date: """Returns next Monday relative to `date`. Examples: >>> get_next_day(dt.date(2020, 1, 1)) datetime.date(2020, 1, 6) >>> get_next_day(dt.date(2020, 1, 2)) datetime.date(2020, 1, 6) >>> get_next_day(dt.date(2020, 1, 3)) datetime.date(2020, 1, 6) >>> get_next_day(dt.date(2020, 1, 4)) datetime.date(2020, 1, 6) >>> get_next_day(dt.date(2020, 1, 5)) datetime.date(2020, 1, 6) >>> get_next_day(dt.date(2020, 1, 6)) datetime.date(2020, 1, 13) >>> get_next_day(dt.date(2020, 12, 28)) datetime.date(2021, 1, 4) >>> get_next_day( ... dt.date(2020, 1, 1), ... day_maker=get_quarter_days) datetime.date(2020, 1, 6) >>> get_next_day( ... dt.date(2020, 2, 1), ... day_maker=get_quarter_days) datetime.date(2020, 4, 6) >>> get_next_day( ... dt.date(2020, 3, 1), ... day_maker=get_quarter_days) datetime.date(2020, 4, 6) >>> get_next_day( ... dt.date(2020, 4, 1), ... day_maker=get_quarter_days) datetime.date(2020, 4, 6) >>> get_next_day( ... dt.date(2020, 5, 1), ... day_maker=get_quarter_days) datetime.date(2020, 7, 6) >>> get_next_day( ... dt.date(2020, 6, 1), ... day_maker=get_quarter_days) datetime.date(2020, 7, 6) """ if date is None: date = dt.date.today() for d in day_maker(day_of_week=day_of_week, year=date.year): if d > date: return d # If no Monday in this year works, the first Monday of next year MUST work. monday = day_maker(day_of_week=day_of_week, year=date.year + 1)[0] assert monday > date, ( "No next Monday found! This should not be possible!" f" (monday={monday} > date={date})" ) return monday
[docs]def get_relative_date( spec: str, *, start_date: dt.date = None, past: bool = False ) -> dt.date: """Converts `spec` to a timedelta and adds it to `date`. Args: spec: A timedelta specification string (e.g. '1d', '2m', '3y', 'weekdays'). start_date: The return value is a function of this argument and the timedelta constructed from `spec`. Defaults to today's date. past: If set, we use a relative date from the past instead of the future (e.g. '1d' will yield yesterday's date instead of today's). Examples: # Imports >>> import datetime as dt # Helper Functions >>> to_date = lambda x: dt.datetime.strptime(x, "%Y-%m-%d") >>> from_date = lambda x: x.strftime("%Y-%m-%d") >>> grd = lambda x, y: from_date( ... get_relative_date(x, start_date=to_date(y)) ... ) >>> past_grd = lambda x, y: from_date( ... get_relative_date(x, start_date=to_date(y), past=True) ... ) # Default start date. >>> D = "2000-01-31" # Tests >>> grd("7d", D) '2000-02-07' >>> grd("7D", D) '2000-02-07' >>> grd("1m", D) '2000-02-29' >>> grd("1m", "2001-01-31") '2001-02-28' >>> grd("2M", D) '2000-03-31' >>> grd("3m", D) '2000-04-30' >>> grd("20y", D) '2020-01-31' >>> grd("weekdays", "2022-02-11") '2022-02-14' >>> past_grd("1d", D) '2000-01-30' """ spec = spec.lower() if start_date is None: start_date = dt.date.today() delta: dt.timedelta | relativedelta if spec == "weekdays": weekday = start_date.weekday() days = {_FRIDAY: 3, _SATURDAY: 2}.get(weekday, 1) delta = dt.timedelta(days=days) else: ch = spec[-1] N = int(spec[:-1]) if ch == "d": delta = dt.timedelta(days=N) elif ch == "m": delta = relativedelta(months=N) else: assert ch == "y" delta = relativedelta(years=N) if past: return start_date - delta else: return start_date + delta
[docs]def dt_from_date_and_hhmm(date: dt.date, hhmm: str) -> dt.datetime: """Given a date and a string of the form HHMM, construct a datetime.""" spec = f"{date.year}-{date.month}-{date.day} {hhmm}" result = dt.datetime.strptime(spec, "%Y-%m-%d %H%M") return result
[docs]def matches_date_fmt(spec: str) -> bool: """Returns True iff spec matches the magodo date format..""" return len(spec) == 10 and spec.count("-") == 2
[docs]def matches_relative_date_fmt(spec: str) -> bool: """Returns True iff spec appears to be a relative date (e.g. 1d).""" return ( len(spec) > 1 and spec[:-1].isdigit() and spec[-1].lower() in ["d", "m", "y"] )
[docs]def to_great_date(spec: str, past: bool = False) -> dt.date: """Converts a date string into a date. Args: spec: The date string specification (use a supported date format). past: Treat relative dates (e.g. when `spec == "1d"`) as dates in the past instead of the future. NOTE: `spec` must match a date string specification supported by greatday (e.g. 'YYYY-MM-DD'). """ if matches_date_fmt(spec): return magodo.dates.to_date(spec) else: assert matches_relative_date_fmt(spec) return get_relative_date(spec, past=past)
[docs]def get_date_range(spec: str) -> DateRange: """Constructs a date range from a `spec`. Args: spec: date specification which MUST use a format of START:END where START and END are valid date specs (e.g. `2000-01-01`; '1d'; '5m:0d'). Examples: # setup >>> a = "2000-01-01" >>> b = "2000-01-31" # tests >>> a_range = get_date_range(a) >>> a_range.start datetime.date(2000, 1, 1) >>> a_range.end is None True >>> ab_range = get_date_range(f"{a}:{b}") >>> ab_range.start datetime.date(2000, 1, 1) >>> ab_range.end datetime.date(2000, 1, 31) """ start_and_end = [to_great_date(x, past=True) for x in spec.split(":")] if len(start_and_end) > 1: assert len(start_and_end) == 2 start, end = start_and_end else: start = start_and_end[0] end = None return DateRange(start, end)