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=[])