1from __future__ import annotations
2
3import contextlib
4import copy
5import os
6import re
7import struct
8
9from datetime import date
10from datetime import datetime
11from datetime import time
12from typing import Any
13from typing import Optional
14from typing import cast
15
16from dateutil import parser
17
18from pendulum.parsing.exceptions import ParserError
19
20
21with_extensions = os.getenv("PENDULUM_EXTENSIONS", "1") == "1"
22
23try:
24 if not with_extensions or struct.calcsize("P") == 4:
25 raise ImportError()
26
27 from pendulum._pendulum import Duration
28 from pendulum._pendulum import parse_iso8601
29except ImportError:
30 from pendulum.duration import Duration # type: ignore[assignment]
31 from pendulum.parsing.iso8601 import parse_iso8601 # type: ignore[assignment]
32
33
34COMMON = re.compile(
35 # Date (optional) # noqa: ERA001
36 "^"
37 "(?P<date>"
38 " (?P<classic>" # Classic date (YYYY-MM-DD)
39 r" (?P<year>\d{4})" # Year
40 " (?P<monthday>"
41 r" (?P<monthsep>[/:])?(?P<month>\d{2})" # Month (optional)
42 r" ((?P<daysep>[/:])?(?P<day>\d{2}))" # Day (optional)
43 " )?"
44 " )"
45 ")?"
46 # Time (optional) # noqa: ERA001
47 "(?P<time>" r" (?P<timesep>\ )?" # Separator (space)
48 # HH:mm:ss (optional mm and ss)
49 r" (?P<hour>\d{1,2}):(?P<minute>\d{1,2})?(?::(?P<second>\d{1,2}))?"
50 # Subsecond part (optional)
51 " (?P<subsecondsection>"
52 " (?:[.|,])" # Subsecond separator (optional)
53 r" (?P<subsecond>\d{1,9})" # Subsecond
54 " )?"
55 ")?"
56 "$",
57 re.VERBOSE,
58)
59
60DEFAULT_OPTIONS = {
61 "day_first": False,
62 "year_first": True,
63 "strict": True,
64 "exact": False,
65 "now": None,
66}
67
68
69def parse(text: str, **options: Any) -> datetime | date | time | _Interval | Duration:
70 """
71 Parses a string with the given options.
72
73 :param text: The string to parse.
74 """
75 _options: dict[str, Any] = copy.copy(DEFAULT_OPTIONS)
76 _options.update(options)
77
78 return _normalize(_parse(text, **_options), **_options)
79
80
81def _normalize(
82 parsed: datetime | date | time | _Interval | Duration, **options: Any
83) -> datetime | date | time | _Interval | Duration:
84 """
85 Normalizes the parsed element.
86
87 :param parsed: The parsed elements.
88 """
89 if options.get("exact"):
90 return parsed
91
92 if isinstance(parsed, time):
93 now = cast(Optional[datetime], options["now"]) or datetime.now()
94
95 return datetime(
96 now.year,
97 now.month,
98 now.day,
99 parsed.hour,
100 parsed.minute,
101 parsed.second,
102 parsed.microsecond,
103 )
104 elif isinstance(parsed, date) and not isinstance(parsed, datetime):
105 return datetime(parsed.year, parsed.month, parsed.day)
106
107 return parsed
108
109
110def _parse(text: str, **options: Any) -> datetime | date | time | _Interval | Duration:
111 # Trying to parse ISO8601
112 with contextlib.suppress(ValueError):
113 return parse_iso8601(text)
114
115 with contextlib.suppress(ValueError):
116 return _parse_iso8601_interval(text)
117
118 with contextlib.suppress(ParserError):
119 return _parse_common(text, **options)
120
121 # We couldn't parse the string
122 # so we fallback on the dateutil parser
123 # If not strict
124 if options.get("strict", True):
125 raise ParserError(f"Unable to parse string [{text}]")
126
127 try:
128 dt = parser.parse(
129 text, dayfirst=options["day_first"], yearfirst=options["year_first"]
130 )
131 except ValueError:
132 raise ParserError(f"Invalid date string: {text}")
133
134 return dt
135
136
137def _parse_common(text: str, **options: Any) -> datetime | date | time:
138 """
139 Tries to parse the string as a common datetime format.
140
141 :param text: The string to parse.
142 """
143 m = COMMON.match(text)
144 has_date = False
145 year = 0
146 month = 1
147 day = 1
148
149 if not m:
150 raise ParserError("Invalid datetime string")
151
152 if m.group("date"):
153 # A date has been specified
154 has_date = True
155
156 year = int(m.group("year"))
157
158 if not m.group("monthday"):
159 # No month and day
160 month = 1
161 day = 1
162 else:
163 if options["day_first"]:
164 month = int(m.group("day"))
165 day = int(m.group("month"))
166 else:
167 month = int(m.group("month"))
168 day = int(m.group("day"))
169
170 if not m.group("time"):
171 return date(year, month, day)
172
173 # Grabbing hh:mm:ss
174 hour = int(m.group("hour"))
175
176 minute = int(m.group("minute"))
177
178 second = int(m.group("second")) if m.group("second") else 0
179
180 # Grabbing subseconds, if any
181 microsecond = 0
182 if m.group("subsecondsection"):
183 # Limiting to 6 chars
184 subsecond = m.group("subsecond")[:6]
185
186 microsecond = int(f"{subsecond:0<6}")
187
188 if has_date:
189 return datetime(year, month, day, hour, minute, second, microsecond)
190
191 return time(hour, minute, second, microsecond)
192
193
194class _Interval:
195 """
196 Special class to handle ISO 8601 intervals
197 """
198
199 def __init__(
200 self,
201 start: datetime | None = None,
202 end: datetime | None = None,
203 duration: Duration | None = None,
204 ) -> None:
205 self.start = start
206 self.end = end
207 self.duration = duration
208
209
210def _parse_iso8601_interval(text: str) -> _Interval:
211 if "/" not in text:
212 raise ParserError("Invalid interval")
213
214 first, last = text.split("/")
215 start = end = duration = None
216
217 if first[0] == "P":
218 # duration/end
219 duration = parse_iso8601(first)
220 end = parse_iso8601(last)
221 elif last[0] == "P":
222 # start/duration
223 start = parse_iso8601(first)
224 duration = parse_iso8601(last)
225 else:
226 # start/end
227 start = parse_iso8601(first)
228 end = parse_iso8601(last)
229
230 return _Interval(
231 cast(datetime, start), cast(datetime, end), cast(Duration, duration)
232 )
233
234
235__all__ = ["parse", "parse_iso8601"]