Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/pendulum/parsing/iso8601.py: 97%
237 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-30 06:11 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-30 06:11 +0000
1from __future__ import annotations
3import datetime
4import re
6from typing import cast
8from pendulum.constants import HOURS_PER_DAY
9from pendulum.constants import MINUTES_PER_HOUR
10from pendulum.constants import MONTHS_OFFSETS
11from pendulum.constants import SECONDS_PER_MINUTE
12from pendulum.duration import Duration
13from pendulum.helpers import days_in_year
14from pendulum.helpers import is_leap
15from pendulum.helpers import is_long_year
16from pendulum.helpers import week_day
17from pendulum.parsing.exceptions import ParserError
18from pendulum.tz.timezone import UTC
19from pendulum.tz.timezone import FixedTimezone
20from pendulum.tz.timezone import Timezone
23ISO8601_DT = re.compile(
24 # Date (optional) # noqa: ERA001
25 "^"
26 "(?P<date>"
27 " (?P<classic>" # Classic date (YYYY-MM-DD) or ordinal (YYYY-DDD)
28 r" (?P<year>\d{4})" # Year
29 " (?P<monthday>"
30 r" (?P<monthsep>-)?(?P<month>\d{2})" # Month (optional)
31 r" ((?P<daysep>-)?(?P<day>\d{1,2}))?" # Day (optional)
32 " )?"
33 " )"
34 " |"
35 " (?P<isocalendar>" # Calendar date (2016-W05 or 2016-W05-5)
36 r" (?P<isoyear>\d{4})" # Year
37 " (?P<weeksep>-)?" # Separator (optional)
38 " W" # W separator
39 r" (?P<isoweek>\d{2})" # Week number
40 " (?P<weekdaysep>-)?" # Separator (optional)
41 r" (?P<isoweekday>\d)?" # Weekday (optional)
42 " )"
43 ")?"
44 # Time (optional) # noqa: ERA001
45 "(?P<time>" r" (?P<timesep>[T\ ])?" # Separator (T or space)
46 # HH:mm:ss (optional mm and ss)
47 r" (?P<hour>\d{1,2})(?P<minsep>:)?(?P<minute>\d{1,2})?(?P<secsep>:)?(?P<second>\d{1,2})?" # noqa: E501
48 # Subsecond part (optional)
49 " (?P<subsecondsection>"
50 " (?:[.,])" # Subsecond separator (optional)
51 r" (?P<subsecond>\d{1,9})" # Subsecond
52 " )?"
53 # Timezone offset
54 " (?P<tz>"
55 r" (?:[-+])\d{2}:?(?:\d{2})?|Z" # Offset (+HH:mm or +HHmm or +HH or Z)
56 " )?"
57 ")?"
58 "$",
59 re.VERBOSE,
60)
62ISO8601_DURATION = re.compile(
63 "^P" # Duration P indicator
64 # Years, months and days (optional) # noqa: ERA001
65 "(?P<w>"
66 r" (?P<weeks>\d+(?:[.,]\d+)?W)"
67 ")?"
68 "(?P<ymd>"
69 r" (?P<years>\d+(?:[.,]\d+)?Y)?"
70 r" (?P<months>\d+(?:[.,]\d+)?M)?"
71 r" (?P<days>\d+(?:[.,]\d+)?D)?"
72 ")?"
73 "(?P<hms>"
74 " (?P<timesep>T)" # Separator (T)
75 r" (?P<hours>\d+(?:[.,]\d+)?H)?"
76 r" (?P<minutes>\d+(?:[.,]\d+)?M)?"
77 r" (?P<seconds>\d+(?:[.,]\d+)?S)?"
78 ")?"
79 "$",
80 re.VERBOSE,
81)
84def parse_iso8601(
85 text: str,
86) -> datetime.datetime | datetime.date | datetime.time | Duration:
87 """
88 ISO 8601 compliant parser.
90 :param text: The string to parse
91 :type text: str
93 :rtype: datetime.datetime or datetime.time or datetime.date
94 """
95 parsed = _parse_iso8601_duration(text)
96 if parsed is not None:
97 return parsed
99 m = ISO8601_DT.match(text)
100 if not m:
101 raise ParserError("Invalid ISO 8601 string")
103 ambiguous_date = False
104 is_date = False
105 is_time = False
106 year = 0
107 month = 1
108 day = 1
109 minute = 0
110 second = 0
111 microsecond = 0
112 tzinfo: FixedTimezone | Timezone | None = None
114 if m.group("date"):
115 # A date has been specified
116 is_date = True
118 if m.group("isocalendar"):
119 # We have a ISO 8601 string defined
120 # by week number
121 if (
122 m.group("weeksep")
123 and not m.group("weekdaysep")
124 and m.group("isoweekday")
125 ):
126 raise ParserError(f"Invalid date string: {text}")
128 if not m.group("weeksep") and m.group("weekdaysep"):
129 raise ParserError(f"Invalid date string: {text}")
131 try:
132 date = _get_iso_8601_week(
133 m.group("isoyear"), m.group("isoweek"), m.group("isoweekday")
134 )
135 except ParserError:
136 raise
137 except ValueError:
138 raise ParserError(f"Invalid date string: {text}")
140 year = date["year"]
141 month = date["month"]
142 day = date["day"]
143 else:
144 # We have a classic date representation
145 year = int(m.group("year"))
147 if not m.group("monthday"):
148 # No month and day
149 month = 1
150 day = 1
151 else:
152 if m.group("month") and m.group("day"):
153 # Month and day
154 if not m.group("daysep") and len(m.group("day")) == 1:
155 # Ordinal day
156 ordinal = int(m.group("month") + m.group("day"))
157 leap = is_leap(year)
158 months_offsets = MONTHS_OFFSETS[leap]
160 if ordinal > months_offsets[13]:
161 raise ParserError("Ordinal day is out of range")
163 for i in range(1, 14):
164 if ordinal <= months_offsets[i]:
165 day = ordinal - months_offsets[i - 1]
166 month = i - 1
168 break
169 else:
170 month = int(m.group("month"))
171 day = int(m.group("day"))
172 else:
173 # Only month
174 if not m.group("monthsep"):
175 # The date looks like 201207
176 # which is invalid for a date
177 # But it might be a time in the form hhmmss
178 ambiguous_date = True
180 month = int(m.group("month"))
181 day = 1
183 if not m.group("time"):
184 # No time has been specified
185 if ambiguous_date:
186 # We can "safely" assume that the ambiguous date
187 # was actually a time in the form hhmmss
188 hhmmss = f"{year!s}{month!s:0>2}"
190 return datetime.time(int(hhmmss[:2]), int(hhmmss[2:4]), int(hhmmss[4:]))
192 return datetime.date(year, month, day)
194 if ambiguous_date:
195 raise ParserError(f"Invalid date string: {text}")
197 if is_date and not m.group("timesep"):
198 raise ParserError(f"Invalid date string: {text}")
200 if not is_date:
201 is_time = True
203 # Grabbing hh:mm:ss
204 hour = int(m.group("hour"))
205 minsep = m.group("minsep")
207 if m.group("minute"):
208 minute = int(m.group("minute"))
209 elif minsep:
210 raise ParserError("Invalid ISO 8601 time part")
212 secsep = m.group("secsep")
213 if secsep and not minsep and m.group("minute"):
214 # minute/second separator but no hour/minute separator
215 raise ParserError("Invalid ISO 8601 time part")
217 if m.group("second"):
218 if not secsep and minsep:
219 # No minute/second separator but hour/minute separator
220 raise ParserError("Invalid ISO 8601 time part")
222 second = int(m.group("second"))
223 elif secsep:
224 raise ParserError("Invalid ISO 8601 time part")
226 # Grabbing subseconds, if any
227 if m.group("subsecondsection"):
228 # Limiting to 6 chars
229 subsecond = m.group("subsecond")[:6]
231 microsecond = int(f"{subsecond:0<6}")
233 # Grabbing timezone, if any
234 tz = m.group("tz")
235 if tz:
236 if tz == "Z":
237 tzinfo = UTC
238 else:
239 negative = bool(tz.startswith("-"))
240 tz = tz[1:]
241 if ":" not in tz:
242 if len(tz) == 2:
243 tz = f"{tz}00"
245 off_hour = tz[0:2]
246 off_minute = tz[2:4]
247 else:
248 off_hour, off_minute = tz.split(":")
250 offset = ((int(off_hour) * 60) + int(off_minute)) * 60
252 if negative:
253 offset = -1 * offset
255 tzinfo = FixedTimezone(offset)
257 if is_time:
258 return datetime.time(hour, minute, second, microsecond, tzinfo=tzinfo)
260 return datetime.datetime(
261 year, month, day, hour, minute, second, microsecond, tzinfo=tzinfo
262 )
265def _parse_iso8601_duration(text: str, **options: str) -> Duration | None:
266 m = ISO8601_DURATION.match(text)
267 if not m:
268 return None
270 years = 0
271 months = 0
272 weeks = 0
273 days: int | float = 0
274 hours: int | float = 0
275 minutes: int | float = 0
276 seconds: int | float = 0
277 microseconds: int | float = 0
278 fractional = False
280 _days: str | float
281 _hour: str | int | None
282 _minutes: str | int | None
283 _seconds: str | int | None
284 if m.group("w"):
285 # Weeks
286 if m.group("ymd") or m.group("hms"):
287 # Specifying anything more than weeks is not supported
288 raise ParserError("Invalid duration string")
290 _weeks = m.group("weeks")
291 if not _weeks:
292 raise ParserError("Invalid duration string")
294 _weeks = _weeks.replace(",", ".").replace("W", "")
295 if "." in _weeks:
296 _weeks, portion = _weeks.split(".")
297 weeks = int(_weeks)
298 _days = int(portion) / 10 * 7
299 days, hours = int(_days // 1), int(_days % 1 * HOURS_PER_DAY)
300 else:
301 weeks = int(_weeks)
303 if m.group("ymd"):
304 # Years, months and/or days
305 _years = m.group("years")
306 _months = m.group("months")
307 _days = m.group("days")
309 # Checking order
310 years_start = m.start("years") if _years else -3
311 months_start = m.start("months") if _months else years_start + 1
312 days_start = m.start("days") if _days else months_start + 1
314 # Check correct order
315 if not (years_start < months_start < days_start):
316 raise ParserError("Invalid duration")
318 if _years:
319 _years = _years.replace(",", ".").replace("Y", "")
320 if "." in _years:
321 raise ParserError("Float years in duration are not supported")
322 else:
323 years = int(_years)
325 if _months:
326 if fractional:
327 raise ParserError("Invalid duration")
329 _months = _months.replace(",", ".").replace("M", "")
330 if "." in _months:
331 raise ParserError("Float months in duration are not supported")
332 else:
333 months = int(_months)
335 if _days:
336 if fractional:
337 raise ParserError("Invalid duration")
339 _days = _days.replace(",", ".").replace("D", "")
341 if "." in _days:
342 fractional = True
344 _days, _hours = _days.split(".")
345 days = int(_days)
346 hours = int(_hours) / 10 * HOURS_PER_DAY
347 else:
348 days = int(_days)
350 if m.group("hms"):
351 # Hours, minutes and/or seconds
352 _hours = m.group("hours") or 0
353 _minutes = m.group("minutes") or 0
354 _seconds = m.group("seconds") or 0
356 # Checking order
357 hours_start = m.start("hours") if _hours else -3
358 minutes_start = m.start("minutes") if _minutes else hours_start + 1
359 seconds_start = m.start("seconds") if _seconds else minutes_start + 1
361 # Check correct order
362 if not (hours_start < minutes_start < seconds_start):
363 raise ParserError("Invalid duration")
365 if _hours:
366 if fractional:
367 raise ParserError("Invalid duration")
369 _hours = cast(str, _hours).replace(",", ".").replace("H", "")
371 if "." in _hours:
372 fractional = True
374 _hours, _mins = _hours.split(".")
375 hours += int(_hours)
376 minutes += int(_mins) / 10 * MINUTES_PER_HOUR
377 else:
378 hours += int(_hours)
380 if _minutes:
381 if fractional:
382 raise ParserError("Invalid duration")
384 _minutes = cast(str, _minutes).replace(",", ".").replace("M", "")
386 if "." in _minutes:
387 fractional = True
389 _minutes, _secs = _minutes.split(".")
390 minutes += int(_minutes)
391 seconds += int(_secs) / 10 * SECONDS_PER_MINUTE
392 else:
393 minutes += int(_minutes)
395 if _seconds:
396 if fractional:
397 raise ParserError("Invalid duration")
399 _seconds = cast(str, _seconds).replace(",", ".").replace("S", "")
401 if "." in _seconds:
402 _seconds, _microseconds = _seconds.split(".")
403 seconds += int(_seconds)
404 microseconds += int(f"{_microseconds[:6]:0<6}")
405 else:
406 seconds += int(_seconds)
408 return Duration(
409 years=years,
410 months=months,
411 weeks=weeks,
412 days=days,
413 hours=hours,
414 minutes=minutes,
415 seconds=seconds,
416 microseconds=microseconds,
417 )
420def _get_iso_8601_week(
421 year: int | str, week: int | str, weekday: int | str
422) -> dict[str, int]:
423 weekday = 1 if not weekday else int(weekday)
425 year = int(year)
426 week = int(week)
428 if week > 53 or week > 52 and not is_long_year(year):
429 raise ParserError("Invalid week for week date")
431 if weekday > 7:
432 raise ParserError("Invalid weekday for week date")
434 # We can't rely on strptime directly here since
435 # it does not support ISO week date
436 ordinal = week * 7 + weekday - (week_day(year, 1, 4) + 3)
438 if ordinal < 1:
439 # Previous year
440 ordinal += days_in_year(year - 1)
441 year -= 1
443 if ordinal > days_in_year(year):
444 # Next year
445 ordinal -= days_in_year(year)
446 year += 1
448 fmt = "%Y-%j"
449 string = f"{year}-{ordinal}"
451 dt = datetime.datetime.strptime(string, fmt)
453 return {"year": dt.year, "month": dt.month, "day": dt.day}