-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparse_humanize.py
More file actions
146 lines (113 loc) · 5.13 KB
/
Copy pathparse_humanize.py
File metadata and controls
146 lines (113 loc) · 5.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""Parse humanized / relative time strings into datetime objects.
Handles the shapes that human-facing UIs and scraped pages tend to emit:
relative offsets ("5 minutes ago"), day references ("today at 4pm",
"monday at 09:00"), and absolute dates in a range of common layouts.
The single entry point is :func:`parse_humanize`. It never raises on
unrecognized input; it returns ``None`` so callers can decide what to do.
"""
from __future__ import annotations
__all__ = ("parse_humanize",)
import re
from datetime import datetime, timedelta
MONTHS = {
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
MONTHS.update({name[:3]: num for name, num in MONTHS.items()})
MONTHS["sept"] = 9
WEEKDAYS = {
"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
"friday": 4, "saturday": 5, "sunday": 6,
}
WEEKDAYS.update({name[:3]: num for name, num in WEEKDAYS.items()})
RELATIVE_UNITS = {
"second": "seconds", "sec": "seconds",
"minute": "minutes", "min": "minutes",
"hour": "hours", "hr": "hours",
"day": "days",
"week": "weeks",
}
_TIME = r"(\d{1,2}):(\d{2})\s*(am|pm)?"
_SEP = r"[\s,]*(?:at)?[\s,]*"
def _to_24h(hour: int, meridiem: str | None) -> int:
"""Convert a clock hour to 24h form given an optional am/pm marker."""
if meridiem is None:
return hour
meridiem = meridiem.lower()
if meridiem == "am":
return 0 if hour == 12 else hour
return hour if hour == 12 else hour + 12
def _at_time(base: datetime, hour: str, minute: str, meridiem: str | None) -> datetime:
return base.replace(
hour=_to_24h(int(hour), meridiem),
minute=int(minute),
second=0,
microsecond=0,
)
def _at_midnight(base: datetime) -> datetime:
return base.replace(hour=0, minute=0, second=0, microsecond=0)
def _on_date(base: datetime, year: int, month: int, day: int) -> datetime:
"""Set an absolute date on ``base`` without tripping over day overflow.
Replacing the day before the month can raise (day=31 while the current
month has 30). Setting day=1 first sidesteps that.
"""
return base.replace(year=year, month=month, day=1).replace(day=day)
def parse_humanize(text: str, *, now: datetime | None = None) -> datetime | None:
"""Parse a humanized time string into a datetime.
Args:
text: the string to parse, e.g. ``"5 minutes ago"`` or ``"4 Jan 2024"``.
now: reference point for relative expressions. Defaults to
``datetime.now()``. Its tzinfo is preserved on the result.
Returns:
A ``datetime`` on success, or ``None`` if nothing matched. Numeric
``dd/mm/yyyy`` dates are read day-first.
"""
if now is None:
now = datetime.now()
if not text:
return None
low = " ".join(text.strip().split()).lower()
if low in ("just now", "now", "few seconds ago", "a few seconds ago"):
return now
if m := re.fullmatch(r"(?:an?|\d+)\s+(\w+?)s?\s+ago", low):
head = low.split()[0]
amount = 1 if head in ("a", "an") else int(head)
unit = RELATIVE_UNITS.get(m.group(1))
return now - timedelta(**{unit: amount}) if unit else None
if m := re.fullmatch(r"today" + _SEP + _TIME, low):
return _at_time(now, *m.groups())
if m := re.fullmatch(r"yesterday" + _SEP + _TIME, low):
return _at_time(now - timedelta(days=1), *m.groups())
if m := re.fullmatch(r"(\w+)" + _SEP + _TIME, low):
if (target := WEEKDAYS.get(m.group(1))) is not None:
delta = (now.weekday() - target) % 7
return _at_time(now - timedelta(days=delta), *m.groups()[1:])
# 4 Jan | 29 September at 18:00 | 25 October 2023 at 01:44 PM
if m := re.fullmatch(
r"(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?(?:" + _SEP + _TIME + r")?", low
):
day, month_name, year, hour, minute, meridiem = m.groups()
if month_name in MONTHS:
base = _on_date(now, int(year) if year else now.year, MONTHS[month_name], int(day))
return _at_time(base, hour, minute, meridiem) if hour else _at_midnight(base)
# Jan 19, 2024, 2:21 PM | August 15 2024
if m := re.fullmatch(
r"([a-z]+)\s+(\d{1,2})(?:\s*,?\s*(\d{4}))?(?:" + _SEP + _TIME + r")?", low
):
month_name, day, year, hour, minute, meridiem = m.groups()
if month_name in MONTHS:
base = _on_date(now, int(year) if year else now.year, MONTHS[month_name], int(day))
return _at_time(base, hour, minute, meridiem) if hour else _at_midnight(base)
# 25/10/2023 at 01:44 PM | 25/10/23 (day-first)
if m := re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{2,4})(?:" + _SEP + _TIME + r")?", low):
day, month, year, hour, minute, meridiem = m.groups()
year_i = int(year)
if year_i < 100:
year_i += 2000
base = _on_date(now, year_i, int(month), int(day))
return _at_time(base, hour, minute, meridiem) if hour else _at_midnight(base)
# bare clock: 01:44 PM | 13:05 -> today
if m := re.fullmatch(_TIME, low):
return _at_time(now, *m.groups())
return None