Functional Weave
Code in Python

form.date-parts@1.0.0

impl/python.py

5,906 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional

from .dates_add_days import days_in_month, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .form_date_parts_types import DateParts, DatePartsCheck, DateRules

MONTH_NAMES = (
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
)
PARTS = ("day", "month", "year")

# ASCII whitespace only, so all three languages trim the same characters
# (str.strip() with no argument would also take Unicode spaces).
_SPACE = " \t\n\r\f\v"


def _is_digits(value: str) -> bool:
    # ASCII digits only: str.isdigit() accepts "٢", which is not a day in the
    # other two languages.
    return value != "" and all("0" <= ch <= "9" for ch in value)


def _small_number(value: str, largest: int) -> Optional[int]:
    # A day or month number: one or two digits.
    if not _is_digits(value) or len(value) > 2:
        return None
    n = int(value)
    return n if 1 <= n <= largest else None


def _ascii_lower(value: str) -> str:
    return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in value)


def _month_number(value: str) -> Optional[int]:
    # GOV.UK asks services to accept month names in full or short ("jan"), in
    # any case; "sept" is common enough to accept too.
    numeric = _small_number(value, 12)
    if numeric is not None:
        return numeric
    lower = _ascii_lower(value)
    if lower == "sept":
        return 9
    for i, full in enumerate(MONTH_NAMES):
        name = _ascii_lower(full)
        if lower == name or lower == name[:3]:
            return i + 1
    return None


def _lower_first(label: str) -> str:
    # "Date of birth" starts a sentence; after "Enter" it is "date of birth".
    # A label that starts with an acronym ("UK arrival date") is left alone.
    def upper(i: int) -> bool:
        return i < len(label) and "A" <= label[i] <= "Z"

    if upper(0) and not upper(1):
        return _ascii_lower(label[0]) + label[1:]
    return label


def _long_date(iso: str) -> str:
    d = parse_iso_date(iso)
    return "%d %s %d" % (d.day, MONTH_NAMES[d.month - 1], d.year)


def _invalid(message: str, fields: List[str]) -> DatePartsCheck:
    return DatePartsCheck(valid=False, date=None, message=message, fields=fields)


def validate_date_parts(parts: DateParts, label: str, rules: DateRules) -> DatePartsCheck:
    """A date typed into three boxes, checked the way the GOV.UK Design
    System's date input asks: missing or incomplete information first, then a
    date that cannot be real, then the service's own rules, each with GOV.UK's
    wording."""
    name = label.strip(_SPACE)
    if name == "":
        raise ValueError('a date needs a label, such as "Date of birth", to write its error messages')
    # Rules are the caller's, so a bad one fails loudly whatever was typed.
    timing = rules.timing if rules.timing is not None else "any"
    if timing != "any" and rules.today is None:
        raise ValueError("rules.today is needed to check that a date is %s" % timing.replace("-", " "))
    for bound in (rules.today, rules.not_before, rules.not_after):
        if bound is not None:
            parse_iso_date(bound)
    not_before, not_after = rules.not_before, rules.not_after
    if not_before is not None and not_after is not None and not_before > not_after:
        raise ValueError(
            "rules.notBefore (%s) must not be after rules.notAfter (%s)" % (not_before, not_after)
        )

    typed = {
        "day": parts.day.strip(_SPACE),
        "month": parts.month.strip(_SPACE),
        "year": parts.year.strip(_SPACE),
    }
    year = typed["year"]

    missing = [p for p in PARTS if typed[p] == ""]
    if len(missing) == 3:
        return _invalid("Enter %s" % _lower_first(name), list(PARTS))
    if missing:
        what = missing[0] if len(missing) == 1 else "%s and %s" % (missing[0], missing[1])
        return _invalid("%s must include a %s" % (name, what), missing)
    if _is_digits(year) and len(year) != 4:
        return _invalid("Year must include 4 numbers", ["year"])

    d = _small_number(typed["day"], 31)
    m = _month_number(typed["month"])
    y = int(year) if _is_digits(year) and int(year) >= 1 else None
    found = {"day": d, "month": m, "year": y}
    wrong = [p for p in PARTS if found[p] is None]
    if wrong:
        return _invalid("%s must be a real date" % name, wrong if len(wrong) == 1 else list(PARTS))
    assert d is not None and m is not None and y is not None
    # 31 April or 29 February 2023: the day is what does not exist.
    if d > days_in_month(y, m):
        return _invalid("%s must be a real date" % name, ["day"])

    iso = "%s-%02d-%02d" % (year, m, d)
    today = rules.today if rules.today is not None else ""
    if timing == "past" and iso >= today:
        return _invalid("%s must be in the past" % name, list(PARTS))
    if timing == "past-or-today" and iso > today:
        return _invalid("%s must be today or in the past" % name, list(PARTS))
    if timing == "future" and iso <= today:
        return _invalid("%s must be in the future" % name, list(PARTS))
    if timing == "future-or-today" and iso < today:
        return _invalid("%s must be today or in the future" % name, list(PARTS))

    if not_before is not None and not_after is not None:
        if iso < not_before or iso > not_after:
            return _invalid(
                "%s must be between %s and %s" % (name, _long_date(not_before), _long_date(not_after)),
                list(PARTS),
            )
    elif not_before is not None and iso < not_before:
        return _invalid("%s must be the same as or after %s" % (name, _long_date(not_before)), list(PARTS))
    elif not_after is not None and iso > not_after:
        return _invalid("%s must be the same as or before %s" % (name, _long_date(not_after)), list(PARTS))
    return DatePartsCheck(valid=True, date=iso, message=None, fields=[])