Functional Weave
Code in Rust

dates.add-days@1.0.0

impl/python.py

5,846 bytes · the Python implementation · view raw

"""Civil-date arithmetic on ISO "YYYY-MM-DD" strings.

This deliberately does not use the ``datetime`` module. The point of the
registry is that one algorithm runs in three languages and the vectors prove
they agree; handing the work to each language's own date library would prove
only that three different libraries were consulted. Doing the arithmetic
explicitly also keeps the Python readable next to the TypeScript and the Rust,
which is how a reviewer checks that they are the same function.

The kernel is the days-from-civil / civil-from-days pair: a calendar date is
converted to a day number counted from 1970-01-01, shifted, and converted back.
Every division below has non-negative operands inside the supported year range,
so Python's floor division agrees exactly with Rust's truncating division and
with Math.floor in TypeScript.
"""

from .dates_add_days_types import CivilDate


# 0001-01-01 and 9999-12-31 as epoch days: the range a 4-digit ISO year can express.
MIN_EPOCH_DAY = -719162
MAX_EPOCH_DAY = 2932896

MONTH_LENGTHS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)


def is_leap_year(year: int) -> bool:
    """The Gregorian rule in full: every 4th year, except every 100th, except
    every 400th. 1900 was not a leap year and 2000 was, and code that only
    tests ``year % 4`` gets one of those two wrong.
    """
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)


def days_in_month(year: int, month: int) -> int:
    if month < 1 or month > 12:
        raise ValueError("month must be 1-12, received %s" % (month,))
    if month == 2 and is_leap_year(year):
        return 29
    return MONTH_LENGTHS[month - 1]


def _is_digits(value: str, start: int, stop: int) -> bool:
    for i in range(start, stop):
        if not ("0" <= value[i] <= "9"):
            return False
    return True


def parse_iso_date(iso: str) -> CivilDate:
    """Parse and validate an ISO date.

    The two shapes of bad input are rejected separately: a string that is not
    an ISO date at all, and a well-formed string naming a day that never
    existed (2026-02-30). The second is the dangerous one, because a permissive
    parser turns it into 2026-03-02 and nobody notices.
    """
    if (
        not isinstance(iso, str)
        or len(iso) != 10
        or iso[4] != "-"
        or iso[7] != "-"
        or not _is_digits(iso, 0, 4)
        or not _is_digits(iso, 5, 7)
        or not _is_digits(iso, 8, 10)
    ):
        raise ValueError('"%s" is not an ISO date (YYYY-MM-DD)' % (iso,))
    year = int(iso[0:4])
    month = int(iso[5:7])
    day = int(iso[8:10])
    if year < 1:
        raise ValueError('"%s" is outside the supported range 0001-01-01 to 9999-12-31' % (iso,))
    if month < 1 or month > 12 or day < 1 or day > days_in_month(year, month):
        raise ValueError('"%s" is not a real calendar date' % (iso,))
    return CivilDate(year=year, month=month, day=day)


def format_iso_date(date: CivilDate) -> str:
    return "%04d-%02d-%02d" % (date.year, date.month, date.day)


def days_from_civil(year: int, month: int, day: int) -> int:
    """Days from 1970-01-01 to a civil date. Assumes the date has been validated."""
    # March-based years put the leap day last, so the month-length pattern
    # becomes a simple linear formula and no special case for February is needed.
    y = year - (1 if month <= 2 else 0)
    era = y // 400
    year_of_era = y - era * 400
    day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
    day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year
    return era * 146097 + day_of_era - 719468


def civil_from_days(epoch_day: int) -> CivilDate:
    """The exact inverse of days_from_civil."""
    # 146097 days is exactly 400 years, which is why the Gregorian calendar
    # repeats on that cycle and why this conversion needs no lookup table.
    z = epoch_day + 719468
    era = z // 146097
    day_of_era = z - era * 146097
    year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096) // 365
    y = year_of_era + era * 400
    day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - year_of_era // 100)
    month_prime = (5 * day_of_year + 2) // 153
    day = day_of_year - (153 * month_prime + 2) // 5 + 1
    month = month_prime + (3 if month_prime < 10 else -9)
    return CivilDate(year=y + (1 if month <= 2 else 0), month=month, day=day)


def epoch_day_from_iso(iso: str) -> int:
    """An ISO date as a day number counted from 1970-01-01. Negative before then."""
    date = parse_iso_date(iso)
    return days_from_civil(date.year, date.month, date.day)


def iso_from_epoch_day(epoch_day: int) -> str:
    """The inverse: a day number back to an ISO date, refusing years outside 0001-9999."""
    if isinstance(epoch_day, bool) or not isinstance(epoch_day, int) or epoch_day < MIN_EPOCH_DAY or epoch_day > MAX_EPOCH_DAY:
        raise ValueError("day %s is outside the supported range 0001-01-01 to 9999-12-31" % (epoch_day,))
    return format_iso_date(civil_from_days(epoch_day))


def add_days(iso: str, days: int) -> str:
    """Shift an ISO date by a whole number of days, forwards or backwards.

    Month ends and leap days need no special handling: the shift happens on the
    day number, so 2024-02-28 + 1 is 2024-02-29 and 2026-02-28 + 1 is
    2026-03-01 for the same reason, without a branch for either.
    """
    if isinstance(days, bool) or not isinstance(days, int):
        raise TypeError("days must be an integer, received %r" % (days,))
    return iso_from_epoch_day(epoch_day_from_iso(iso) + days)


def days_between(start_iso: str, end_iso: str) -> int:
    """Whole days from one date to another, negative when the second is earlier."""
    return epoch_day_from_iso(end_iso) - epoch_day_from_iso(start_iso)