1"""Functions to parse datetime objects."""
2
3# We're using regular expressions rather than time.strptime because:
4# - They provide both validation and parsing.
5# - They're more flexible for datetimes.
6# - The date/datetime/time constructors produce friendlier error messages.
7import datetime
8
9from plain.utils.regex_helper import _lazy_re_compile
10from plain.utils.timezone import get_fixed_timezone
11
12date_re = _lazy_re_compile(r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})$")
13
14time_re = _lazy_re_compile(
15 r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
16 r"(?::(?P<second>\d{1,2})(?:[\.,](?P<microsecond>\d{1,6})\d{0,6})?)?$"
17)
18
19datetime_re = _lazy_re_compile(
20 r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
21 r"[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
22 r"(?::(?P<second>\d{1,2})(?:[\.,](?P<microsecond>\d{1,6})\d{0,6})?)?"
23 r"\s*(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$"
24)
25
26standard_duration_re = _lazy_re_compile(
27 r"^"
28 r"(?:(?P<days>-?\d+) (days?, )?)?"
29 r"(?P<sign>-?)"
30 r"((?:(?P<hours>\d+):)(?=\d+:\d+))?"
31 r"(?:(?P<minutes>\d+):)?"
32 r"(?P<seconds>\d+)"
33 r"(?:[\.,](?P<microseconds>\d{1,6})\d{0,6})?"
34 r"$"
35)
36
37# Support the sections of ISO 8601 date representation that are accepted by
38# timedelta
39iso8601_duration_re = _lazy_re_compile(
40 r"^(?P<sign>[-+]?)"
41 r"P"
42 r"(?:(?P<days>\d+([\.,]\d+)?)D)?"
43 r"(?:T"
44 r"(?:(?P<hours>\d+([\.,]\d+)?)H)?"
45 r"(?:(?P<minutes>\d+([\.,]\d+)?)M)?"
46 r"(?:(?P<seconds>\d+([\.,]\d+)?)S)?"
47 r")?"
48 r"$"
49)
50
51# Support PostgreSQL's day-time interval format, e.g. "3 days 04:05:06". The
52# year-month and mixed intervals cannot be converted to a timedelta and thus
53# aren't accepted.
54postgres_interval_re = _lazy_re_compile(
55 r"^"
56 r"(?:(?P<days>-?\d+) (days? ?))?"
57 r"(?:(?P<sign>[-+])?"
58 r"(?P<hours>\d+):"
59 r"(?P<minutes>\d\d):"
60 r"(?P<seconds>\d\d)"
61 r"(?:\.(?P<microseconds>\d{1,6}))?"
62 r")?$"
63)
64
65
66def parse_date(value: str) -> datetime.date | None:
67 """Parse a string and return a datetime.date.
68
69 Raise ValueError if the input is well formatted but not a valid date.
70 Return None if the input isn't well formatted.
71 """
72 try:
73 return datetime.date.fromisoformat(value)
74 except ValueError:
75 if match := date_re.match(value):
76 kw = {k: int(v) for k, v in match.groupdict().items()}
77 return datetime.date(**kw)
78 return None
79
80
81def parse_time(value: str) -> datetime.time | None:
82 """Parse a string and return a datetime.time.
83
84 This function doesn't support time zone offsets.
85
86 Raise ValueError if the input is well formatted but not a valid time.
87 Return None if the input isn't well formatted, in particular if it
88 contains an offset.
89 """
90 try:
91 # The fromisoformat() method takes time zone info into account and
92 # returns a time with a tzinfo component, if possible. However, there
93 # are no circumstances where aware datetime.time objects make sense, so
94 # remove the time zone offset.
95 return datetime.time.fromisoformat(value).replace(tzinfo=None)
96 except ValueError:
97 if match := time_re.match(value):
98 kw = match.groupdict()
99 kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
100 kw = {k: int(v) for k, v in kw.items() if v is not None}
101 return datetime.time(**kw) # ty: ignore[invalid-argument-type]
102
103
104def parse_datetime(value: str) -> datetime.datetime | None:
105 """Parse a string and return a datetime.datetime.
106
107 This function supports time zone offsets. When the input contains one,
108 the output uses a timezone with a fixed offset from UTC.
109
110 Raise ValueError if the input is well formatted but not a valid datetime.
111 Return None if the input isn't well formatted.
112 """
113 try:
114 return datetime.datetime.fromisoformat(value)
115 except ValueError:
116 if match := datetime_re.match(value):
117 kw = match.groupdict()
118 kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
119 tzinfo = kw.pop("tzinfo")
120 if tzinfo == "Z":
121 tzinfo = datetime.UTC
122 elif tzinfo is not None:
123 offset_mins = int(tzinfo[-2:]) if len(tzinfo) > 3 else 0
124 offset = 60 * int(tzinfo[1:3]) + offset_mins
125 if tzinfo[0] == "-":
126 offset = -offset
127 tzinfo = get_fixed_timezone(offset)
128 kw = {k: int(v) for k, v in kw.items() if v is not None}
129 return datetime.datetime(**kw, tzinfo=tzinfo)
130 return None
131
132
133def parse_duration(value: str) -> datetime.timedelta | None:
134 """Parse a duration string and return a datetime.timedelta.
135
136 The preferred format for durations in Plain is '%d %H:%M:%S.%f'.
137
138 Also supports ISO 8601 representation and PostgreSQL's day-time interval
139 format.
140 """
141 match = (
142 standard_duration_re.match(value)
143 or iso8601_duration_re.match(value)
144 or postgres_interval_re.match(value)
145 )
146 if match:
147 kw = match.groupdict()
148 sign = -1 if kw.pop("sign", "+") == "-" else 1
149 if kw.get("microseconds"):
150 kw["microseconds"] = kw["microseconds"].ljust(6, "0")
151 kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None}
152 days = datetime.timedelta(kw.pop("days", 0.0) or 0.0)
153 if match.re == iso8601_duration_re:
154 days *= sign
155 return days + sign * datetime.timedelta(**kw)