pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / nlp.py

23,052 bytes · last changed 2026-10-28 in Add exchange rates and synonyms, with a join filter for lists in sentences

1"""Language rules: how a sentence is read before a trigger sees it.
2
3This is the whole of Pyrtan's "natural language processing", and none of
4it is a model. Every function here is a fixed table or a fixed rule: a
5sentence goes in, a rewritten sentence (or a value) comes out, and the same
6input always gives the same output. Each rewrite that fires is named, so
7the trace can show exactly how a sentence was read.
8
9What the rules do:
10
11- :func:`clean` rewrites a sentence step by step: fold unicode, drop
12 greetings and politeness at the edges, expand contractions, turn number
13 words into digits. The same rules are applied to trigger patterns at
14 load time, so a pattern written as ``[what's the] weather`` still matches
15 a sentence written either way.
16- :func:`parse_date`, :func:`parse_time`, :func:`parse_duration`,
17 :func:`parse_number`, :func:`parse_host`, :func:`parse_url` and
18 :func:`parse_email` read one value each, for the typed slots a trigger
19 may declare (``{when:date}``, ``{for:duration}``, ``{site:host}``...).
20 Dates are relative to a ``now`` the caller passes in; the runtime passes
21 its clock, so a run is deterministic given its observations.
22- :func:`singular` is a conservative stemmer used so that a literal word in
23 a pattern matches its plural (``story``/``stories``), and nothing else.
24- :func:`distance` is edit distance, for the opt-in typo rule.
25
26What the rules refuse: anything that would need a guess. A date that could
27be read two ways is not read at all. A word not in any table is left alone.
28"""
29
30from __future__ import annotations
31
32import datetime as _dt
33import re
34import unicodedata
35from typing import Any, Callable
36
37# ---------------------------------------------------------------- tables
38
39CONTRACTIONS = {
40 "what's": "what is", "whats": "what is", "where's": "where is", "who's": "who is", "how's": "how is",
41 "when's": "when is", "why's": "why is", "it's": "it is", "that's": "that is", "there's": "there is",
42 "here's": "here is", "let's": "let us", "isn't": "is not", "aren't": "are not", "wasn't": "was not",
43 "weren't": "were not", "don't": "do not", "doesn't": "does not", "didn't": "did not", "can't": "can not",
44 "cannot": "can not", "couldn't": "could not", "won't": "will not", "wouldn't": "would not",
45 "shouldn't": "should not", "haven't": "have not", "hasn't": "has not", "hadn't": "had not",
46 "i'm": "i am", "i've": "i have", "i'd": "i would", "i'll": "i will", "you're": "you are",
47 "you've": "you have", "you'd": "you would", "you'll": "you will", "we're": "we are", "we've": "we have",
48 "they're": "they are", "they've": "they have", "gimme": "give me", "lemme": "let me", "wanna": "want to",
49 "gonna": "going to", "u": "you", "pls": "please", "plz": "please", "thx": "thanks",
50}
51
52# Said at the start of a sentence and meaning nothing to a tree.
53LEADING_FILLERS = [
54 "hey pyrtan", "hi pyrtan", "hello pyrtan", "ok pyrtan", "okay pyrtan", "pyrtan",
55 "hey", "hi", "hello", "yo", "ok", "okay", "so", "um", "uh", "well", "please", "kindly",
56 "can you", "could you", "would you", "will you", "can you please", "could you please", "would you please",
57 "i want you to", "i need you to", "i would like you to", "i'd like you to",
58 "quick question", "question",
59]
60# Said at the end, likewise.
61TRAILING_FILLERS = ["please", "thanks", "thank you", "for me", "if you can", "if you could", "if possible", "pyrtan"]
62
63UNITS = {
64 "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9,
65 "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, "sixteen": 16,
66 "seventeen": 17, "eighteen": 18, "nineteen": 19,
67}
68TENS = {"twenty": 20, "thirty": 30, "forty": 40, "fourty": 40, "fifty": 50, "sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90}
69SCALES = {"hundred": 100, "thousand": 1_000, "million": 1_000_000, "billion": 1_000_000_000}
70NUMBER_WORDS = set(UNITS) | set(TENS) | set(SCALES) | {"and", "a", "dozen"}
71
72# Words whose trailing s is not a plural, so :func:`singular` leaves them.
73NOT_PLURALS = {
74 "news", "is", "as", "us", "his", "this", "thus", "yes", "plus", "bus", "gas", "was", "has", "its",
75 "status", "bonus", "always", "perhaps", "physics", "series", "species", "means", "analysis", "basis",
76 "lens", "chess", "address", "process", "access", "class", "pass", "less", "unless", "across", "ssl",
77 "https", "dns", "tls", "os", "ios", "chaos", "canvas", "atlas", "virus", "campus", "census", "focus",
78 "iris", "oasis", "crisis", "thesis", "diagnosis", "axis", "texas", "paris", "mars", "venus",
79}
80
81WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
82WEEKDAY_ABBR = {d[:3]: i for i, d in enumerate(WEEKDAYS)}
83MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]
84MONTH_ABBR = {m[:3]: i + 1 for i, m in enumerate(MONTHS)}
85MONTH_ABBR["sept"] = 9
86
87DURATION_UNITS = {
88 "s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1,
89 "m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
90 "h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600,
91 "d": 86400, "day": 86400, "days": 86400,
92 "w": 604800, "wk": 604800, "wks": 604800, "week": 604800, "weeks": 604800,
93}
94
95DIGITS = "0123456789"
96WORD = re.compile(r"[A-Za-z][A-Za-z'’]*|\d+(?:[.,]\d+)*|\S")
97HOST_RE = re.compile(r"^(?:https?://)?((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:/\S*)?$", re.IGNORECASE)
98URL_RE = re.compile(r"^https?://[^\s<>\"']+$", re.IGNORECASE)
99EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$")
100
101
102# ---------------------------------------------------------------- cleaning
103
104
105def fold(text: str) -> str:
106 """Unicode to its plain form: curly quotes straight, full-width ASCII,
107 dashes to hyphens, whitespace to one space."""
108 text = unicodedata.normalize("NFKC", text)
109 text = text.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"')
110 text = re.sub(r"[‐-―−]", "-", text)
111 text = re.sub(r"\s+", " ", text).strip()
112 return text
113
114
115def trim(text: str) -> str:
116 """Drop punctuation that carries nothing: around the whole sentence, and
117 a comma or full stop between words. A comma between digits (1,000) is
118 part of a number and stays."""
119 text = re.sub(r"^[\s\W_]+|[\s?!.,;:]+$", "", text)
120 text = re.sub(r"\s*[,;!?]+\s*", lambda m: m.group(0) if _in_number(m) else " ", text)
121 text = re.sub(r"(?<=[A-Za-z])\.(?=\s|$)", "", text)
122 return re.sub(r"\s+", " ", text).strip()
123
124
125def _in_number(m: re.Match[str]) -> bool:
126 s, i, j = m.string, m.start(), m.end()
127 return m.group(0) == "," and 0 < i and j < len(s) and s[i - 1] in DIGITS and s[j] in DIGITS
128
129
130
131def strip_fillers(text: str, keep: frozenset[str] = frozenset()) -> tuple[str, list[str]]:
132 """Greetings, politeness and request wrappers at either edge, repeatedly.
133 A filler whose first word is in ``keep`` stays: a trigger somewhere
134 starts (or ends) with that word, so to that swarm it is not filler."""
135 dropped: list[str] = []
136 changed = True
137 while changed and text:
138 changed = False
139 low = text.lower()
140 for f in sorted(LEADING_FILLERS, key=len, reverse=True):
141 if (low == f or low.startswith(f + " ") or low.startswith(f + ",")) and f.split()[0] not in keep:
142 dropped.append(text[: len(f)])
143 text = text[len(f):].lstrip(" ,")
144 changed = True
145 break
146 low = text.lower()
147 for f in sorted(TRAILING_FILLERS, key=len, reverse=True):
148 if (low == f or low.endswith(" " + f) or low.endswith("," + f)) and f.split()[-1] not in keep:
149 dropped.append(text[len(text) - len(f):])
150 text = text[: len(text) - len(f)].rstrip(" ,")
151 changed = True
152 break
153 return text, dropped
154
155
156def expand_contractions(text: str) -> tuple[str, list[str]]:
157 expanded: list[str] = []
158
159 def one(m: re.Match[str]) -> str:
160 word = m.group(0)
161 full = CONTRACTIONS.get(word.lower().replace("’", "'"))
162 if full is None:
163 return word
164 expanded.append(f"{word}->{full}")
165 return full if word.islower() or not word[0].isupper() else full[0].upper() + full[1:]
166
167 return re.sub(r"[A-Za-z]+(?:'[A-Za-z]+)?", one, text), expanded
168
169
170def words_to_digits(text: str) -> tuple[str, list[str]]:
171 """``twenty-one`` -> ``21``, ``two hundred and five`` -> ``205``, ``a
172 dozen`` -> ``12``. Only runs of number words are touched."""
173 words = text.split(" ")
174 out: list[str] = []
175 changed: list[str] = []
176 i = 0
177 while i < len(words):
178 run_end = _number_run(words, i)
179 if run_end > i:
180 run = words[i:run_end]
181 value = _run_value(run)
182 if value is not None:
183 out.append(str(value))
184 changed.append(f"{' '.join(run)}->{value}")
185 i = run_end
186 continue
187 out.append(words[i])
188 i += 1
189 return " ".join(out), changed
190
191
192def _is_number_word(word: str) -> bool:
193 parts = word.lower().split("-")
194 return all(p in UNITS or p in TENS or p in SCALES or p == "dozen" for p in parts)
195
196
197def _number_run(words: list[str], start: int) -> int:
198 """The end (exclusive) of the longest run of number words at ``start``:
199 ``two hundred and five`` or ``a dozen``; ``and``/``a`` only inside or,
200 for ``a``, before a scale word. Returns ``start`` when there is none."""
201 i = start
202 first = words[i].lower()
203 if first == "a" and i + 1 < len(words) and words[i + 1].lower() in ("dozen", "hundred", "thousand", "million", "billion"):
204 i += 1
205 elif not _is_number_word(first):
206 return start
207 end = i
208 while i < len(words):
209 w = words[i].lower()
210 if _is_number_word(w):
211 i += 1
212 end = i
213 elif w in ("and", "a") and i + 1 < len(words) and _is_number_word(words[i + 1].lower()):
214 i += 1
215 else:
216 break
217 return end
218
219
220def _run_value(run: list[str]) -> int | None:
221 words: list[str] = []
222 for tok in run:
223 words.extend(tok.lower().split("-"))
224 total = 0
225 current = 0
226 seen_number = False
227 for w in words:
228 if w in ("and", "a"):
229 if w == "a":
230 current = 1
231 continue
232 if w in UNITS:
233 current += UNITS[w]
234 seen_number = True
235 elif w in TENS:
236 current += TENS[w]
237 seen_number = True
238 elif w == "dozen":
239 current = (current or 1) * 12
240 seen_number = True
241 elif w == "hundred":
242 current = (current or 1) * 100
243 seen_number = True
244 elif w in SCALES:
245 total += (current or 1) * SCALES[w]
246 current = 0
247 seen_number = True
248 else:
249 return None
250 return total + current if seen_number else None
251
252
253def clean(text: str, *, fillers: bool = True, numbers: bool = True, keep: frozenset[str] = frozenset()) -> tuple[str, list[str]]:
254 """The whole pipeline. Returns the rewritten sentence and the list of
255 rules that fired, each as ``rule: detail``, in order. ``keep`` names
256 words that are not filler here (see :func:`strip_fillers`)."""
257 steps: list[str] = []
258 folded = fold(text)
259 if folded != text.strip():
260 steps.append("fold")
261 text = trim(folded)
262 if fillers:
263 text, dropped = strip_fillers(text, keep)
264 if dropped:
265 steps.append("fillers: " + ", ".join(repr(d) for d in dropped))
266 text = trim(text)
267 text, expanded = expand_contractions(text)
268 if expanded:
269 steps.append("contractions: " + ", ".join(expanded))
270 if numbers:
271 text, changed = words_to_digits(text)
272 if changed:
273 steps.append("numbers: " + ", ".join(changed))
274 return re.sub(r"\s+", " ", text).strip(), steps
275
276
277# ---------------------------------------------------------------- words
278
279
280def singular(word: str) -> str:
281 """A conservative singular: ``stories`` -> ``story``, ``boxes`` ->
282 ``box``, ``items`` -> ``item``; ``news`` stays ``news``. Short words and
283 words ending in ``ss`` are left alone."""
284 w = word.lower()
285 if len(w) < 4 or w in NOT_PLURALS or w.endswith("ss") or not w.isalpha():
286 return w
287 if w.endswith("ies") and len(w) > 4:
288 return w[:-3] + "y"
289 if w.endswith(("ches", "shes", "sses", "xes", "zes")):
290 return w[:-2]
291 if w.endswith("s"):
292 return w[:-1]
293 return w
294
295
296def plural(word: str) -> str:
297 w = word.lower()
298 if not w.isalpha() or w in NOT_PLURALS:
299 return w
300 if w.endswith("y") and len(w) > 2 and w[-2] not in "aeiou":
301 return w[:-1] + "ies"
302 if w.endswith(("ch", "sh", "ss", "x", "z", "s")):
303 return w + "es"
304 return w + "s"
305
306
307def forms(word: str) -> set[str]:
308 """Every spelling a literal pattern word may be matched by: itself, its
309 singular and its plural."""
310 w = word.lower()
311 out = {w}
312 if w.isalpha() and len(w) >= 3:
313 base = singular(w)
314 out.add(base)
315 out.add(plural(base))
316 return out
317
318
319def distance(a: str, b: str, limit: int = 2) -> int:
320 """Edit distance with transpositions, stopping early past ``limit``."""
321 a, b = a.lower(), b.lower()
322 if abs(len(a) - len(b)) > limit:
323 return limit + 1
324 prev_prev: list[int] | None = None
325 prev = list(range(len(b) + 1))
326 for i, ca in enumerate(a, 1):
327 cur = [i] + [0] * len(b)
328 for j, cb in enumerate(b, 1):
329 cost = 0 if ca == cb else 1
330 cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
331 if prev_prev is not None and i > 1 and j > 1 and ca == b[j - 2] and a[i - 2] == cb:
332 cur[j] = min(cur[j], prev_prev[j - 2] + 1)
333 if min(cur) > limit:
334 return limit + 1
335 prev_prev, prev = prev, cur
336 return prev[-1]
337
338
339# ---------------------------------------------------------------- values
340
341
342class ParseError(ValueError):
343 pass
344
345
346def parse_number(text: str) -> int | float:
347 t, _ = words_to_digits(text.strip())
348 t = t.replace(",", "")
349 if re.fullmatch(r"-?\d+", t):
350 return int(t)
351 if re.fullmatch(r"-?\d+\.\d+", t):
352 return float(t)
353 raise ParseError(f"{text!r} is not a number")
354
355
356def parse_duration(text: str) -> int:
357 """``2 hours``, ``30 min``, ``1h30m``, ``1 hour and a half``, ``an hour`` -> seconds."""
358 t, _ = words_to_digits(text.strip().lower())
359 t = re.sub(r"\bhalf\s+an?\s+([a-z]+)", r"0.5 \1", t)
360 t = re.sub(r"\b(an?)\s+(?=(?:hour|minute|second|day|week)s?\b)", "1 ", t)
361 t = re.sub(r"(\d+(?:\.\d+)?)\s*([a-z]+)\s+and\s+a\s+half\b", lambda m: f"{float(m.group(1)) + 0.5} {m.group(2)}", t)
362 t = re.sub(r"\s+and\s+", " ", t)
363 total = 0.0
364 found = False
365 pos = 0
366 for m in re.finditer(r"(\d+(?:\.\d+)?)\s*([a-z]+)", t):
367 if t[pos:m.start()].strip():
368 raise ParseError(f"{text!r} is not a duration")
369 unit = m.group(2)
370 if unit not in DURATION_UNITS:
371 raise ParseError(f"{text!r}: unknown unit {unit!r}")
372 total += float(m.group(1)) * DURATION_UNITS[unit]
373 found = True
374 pos = m.end()
375 if not found or t[pos:].strip():
376 raise ParseError(f"{text!r} is not a duration")
377 return int(total)
378
379
380def parse_time(text: str) -> str:
381 """``3pm``, ``15:30``, ``3:05 pm``, ``noon``, ``midnight``, ``9 o'clock`` -> ``HH:MM``."""
382 t, _ = words_to_digits(text.strip().lower())
383 t = t.replace(".", ":").replace(" ", "")
384 if t in ("noon", "midday"):
385 return "12:00"
386 if t == "midnight":
387 return "00:00"
388 m = re.fullmatch(r"(\d{1,2})(?::(\d{2}))?(?:o'?clock)?(am|pm)?", t)
389 if not m:
390 raise ParseError(f"{text!r} is not a time")
391 hour, minute, half = int(m.group(1)), int(m.group(2) or 0), m.group(3)
392 if half:
393 if not 1 <= hour <= 12:
394 raise ParseError(f"{text!r}: {hour} with {half}")
395 hour = hour % 12 + (12 if half == "pm" else 0)
396 elif m.group(2) is None and not m.group(0).endswith("clock"):
397 raise ParseError(f"{text!r}: a bare number is not a time; say 15:00 or 3pm")
398 if not (0 <= hour <= 23 and 0 <= minute <= 59):
399 raise ParseError(f"{text!r} is not a time")
400 return f"{hour:02d}:{minute:02d}"
401
402
403def parse_date(text: str, now: _dt.date | _dt.datetime | None = None) -> str:
404 """A date, as ``YYYY-MM-DD``, relative to ``now`` where it has to be.
405
406 Reads ISO dates, ``25 oct [2026]``, ``oct 25[, 2026]``, ``25/10/2026``
407 (day first; a slash date that could be read either way is refused),
408 ``today``, ``tomorrow``, ``yesterday``, ``day after tomorrow``,
409 weekdays (``friday`` is the next one, today included; ``next friday`` is
410 strictly after today; ``last friday`` strictly before), ``in N days``,
411 ``N days ago``, ``next week``, ``last month``, ``end of month``.
412 """
413 today = (now or _dt.datetime.now(_dt.timezone.utc)).date() if isinstance(now, _dt.datetime) or now is None else now
414 t, _ = words_to_digits(text.strip().lower())
415 t = re.sub(r"^(on|at|for|the|this coming)\s+", "", t).strip()
416 t = re.sub(r"(\d)(st|nd|rd|th)\b", r"\1", t)
417 t = t.replace(",", " ").replace(" ", " ").strip()
418
419 m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", t)
420 if m:
421 return _date(int(m.group(1)), int(m.group(2)), int(m.group(3)), text).isoformat()
422 m = re.fullmatch(r"(\d{1,2})[/.](\d{1,2})(?:[/.](\d{2,4}))?", t)
423 if m:
424 d, mo = int(m.group(1)), int(m.group(2))
425 if d <= 12 and mo <= 12 and d != mo:
426 raise ParseError(f"{text!r} could be day/month or month/day; write it as {today.year}-{mo:02d}-{d:02d} or '{d} {MONTHS[mo - 1]}'")
427 year = int(m.group(3)) if m.group(3) else today.year
428 if year < 100:
429 year += 2000
430 return _date(year, mo, d, text).isoformat()
431 m = re.fullmatch(r"(\d{1,2})(?:\s+of)?\s+([a-z]+)(?:\s+(\d{4}))?", t) or re.fullmatch(r"([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?", t)
432 if m and (_month(m.group(1)) or _month(m.group(2))):
433 if m.group(1).isdigit():
434 day, month = int(m.group(1)), _month(m.group(2))
435 else:
436 month, day = _month(m.group(1)), int(m.group(2))
437 if not month:
438 raise ParseError(f"{text!r} is not a date")
439 year = int(m.group(3)) if m.group(3) else today.year
440 return _date(year, month, day, text).isoformat()
441
442 if t in ("today", "now"):
443 return today.isoformat()
444 if t in ("tomorrow", "tmrw", "tmr"):
445 return (today + _dt.timedelta(days=1)).isoformat()
446 if t in ("yesterday",):
447 return (today - _dt.timedelta(days=1)).isoformat()
448 if t in ("day after tomorrow", "the day after tomorrow"):
449 return (today + _dt.timedelta(days=2)).isoformat()
450 if t in ("day before yesterday", "the day before yesterday"):
451 return (today - _dt.timedelta(days=2)).isoformat()
452
453 m = re.fullmatch(r"(?:(this|next|last)\s+)?([a-z]+)", t)
454 if m and _weekday(m.group(2)) is not None:
455 target = _weekday(m.group(2))
456 which = m.group(1)
457 ahead = (target - today.weekday()) % 7
458 if which == "next":
459 ahead = ahead or 7
460 elif which == "last":
461 ahead = ahead - 7 if ahead else -7
462 return (today + _dt.timedelta(days=ahead)).isoformat()
463
464 m = re.fullmatch(r"in\s+(\d+)\s+(day|days|week|weeks|month|months|year|years)", t)
465 if m:
466 return _shift(today, int(m.group(1)), m.group(2)).isoformat()
467 m = re.fullmatch(r"(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago", t)
468 if m:
469 return _shift(today, -int(m.group(1)), m.group(2)).isoformat()
470 m = re.fullmatch(r"(next|last)\s+(week|month|year)", t)
471 if m:
472 return _shift(today, 1 if m.group(1) == "next" else -1, m.group(2)).isoformat()
473 m = re.fullmatch(r"end of (?:the |this )?(month|year)", t)
474 if m:
475 if m.group(1) == "year":
476 return _dt.date(today.year, 12, 31).isoformat()
477 nxt = _shift(_dt.date(today.year, today.month, 1), 1, "month")
478 return (nxt - _dt.timedelta(days=1)).isoformat()
479 if t in ("start of month", "start of the month", "beginning of month", "beginning of the month"):
480 return today.replace(day=1).isoformat()
481 raise ParseError(f"{text!r} is not a date this reads")
482
483
484def _month(word: str | None) -> int | None:
485 if not word or word.isdigit():
486 return None
487 if word in MONTHS:
488 return MONTHS.index(word) + 1
489 return MONTH_ABBR.get(word[:4] if word.startswith("sept") else word[:3]) if len(word) >= 3 and word[:3] in MONTH_ABBR and MONTHS[MONTH_ABBR[word[:3]] - 1].startswith(word) else None
490
491
492def _weekday(word: str) -> int | None:
493 if word in WEEKDAYS:
494 return WEEKDAYS.index(word)
495 if len(word) >= 3 and word[:3] in WEEKDAY_ABBR and WEEKDAYS[WEEKDAY_ABBR[word[:3]]].startswith(word):
496 return WEEKDAY_ABBR[word[:3]]
497 return None
498
499
500def _date(year: int, month: int, day: int, text: str) -> _dt.date:
501 # Checked here rather than left to datetime, whose wording changes
502 # between Python versions; pyrtan.js says the same words.
503 if not 1 <= year <= 9999:
504 why = f"year {year} is out of range"
505 elif not 1 <= month <= 12:
506 why = "month must be in 1..12"
507 elif not 1 <= day <= _days_in(year, month):
508 why = "day is out of range for month"
509 else:
510 return _dt.date(year, month, day)
511 raise ParseError(f"{text!r}: {why}")
512
513
514def _days_in(year: int, month: int) -> int:
515 return [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
516
517
518def _shift(day: _dt.date, n: int, unit: str) -> _dt.date:
519 unit = unit.rstrip("s")
520 if unit == "day":
521 return day + _dt.timedelta(days=n)
522 if unit == "week":
523 return day + _dt.timedelta(weeks=n)
524 months = day.month - 1 + (n if unit == "month" else 12 * n)
525 year, month = day.year + months // 12, months % 12 + 1
526 return _dt.date(year, month, min(day.day, _days_in(year, month)))
527
528
529def parse_host(text: str) -> str:
530 m = HOST_RE.fullmatch(text.strip())
531 if not m:
532 raise ParseError(f"{text!r} is not a host name")
533 return m.group(1).lower()
534
535
536def parse_url(text: str) -> str:
537 t = text.strip()
538 if not URL_RE.fullmatch(t):
539 raise ParseError(f"{text!r} is not a URL")
540 return t
541
542
543def parse_email(text: str) -> str:
544 t = text.strip()
545 if not EMAIL_RE.fullmatch(t):
546 raise ParseError(f"{text!r} is not an email address")
547 return t.lower()
548
549
550PARSERS: dict[str, Callable[..., Any]] = {
551 "number": parse_number,
552 "duration": parse_duration,
553 "time": parse_time,
554 "date": parse_date,
555 "host": parse_host,
556 "url": parse_url,
557 "email": parse_email,
558}