Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/pendulum/formatting/formatter.py: 18%
289 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 TYPE_CHECKING
7from typing import Any
8from typing import Callable
9from typing import ClassVar
10from typing import Match
11from typing import Sequence
12from typing import cast
14import pendulum
16from pendulum.locales.locale import Locale
19if TYPE_CHECKING:
20 from pendulum import Timezone
22_MATCH_1 = r"\d"
23_MATCH_2 = r"\d\d"
24_MATCH_3 = r"\d{3}"
25_MATCH_4 = r"\d{4}"
26_MATCH_6 = r"[+-]?\d{6}"
27_MATCH_1_TO_2 = r"\d\d?"
28_MATCH_1_TO_2_LEFT_PAD = r"[0-9 ]\d?"
29_MATCH_1_TO_3 = r"\d{1,3}"
30_MATCH_1_TO_4 = r"\d{1,4}"
31_MATCH_1_TO_6 = r"[+-]?\d{1,6}"
32_MATCH_3_TO_4 = r"\d{3}\d?"
33_MATCH_5_TO_6 = r"\d{5}\d?"
34_MATCH_UNSIGNED = r"\d+"
35_MATCH_SIGNED = r"[+-]?\d+"
36_MATCH_OFFSET = r"[Zz]|[+-]\d\d:?\d\d"
37_MATCH_SHORT_OFFSET = r"[Zz]|[+-]\d\d(?::?\d\d)?"
38_MATCH_TIMESTAMP = r"[+-]?\d+(\.\d{1,6})?"
39_MATCH_WORD = (
40 "(?i)[0-9]*"
41 "['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+"
42 r"|[\u0600-\u06FF/]+(\s*?[\u0600-\u06FF]+){1,2}"
43)
44_MATCH_TIMEZONE = "[A-Za-z0-9-+]+(/[A-Za-z0-9-+_]+)?"
47class Formatter:
48 _TOKENS: str = (
49 r"\[([^\[]*)\]|\\(.)|"
50 "("
51 "Mo|MM?M?M?"
52 "|Do|DDDo|DD?D?D?|ddd?d?|do?|eo?"
53 "|E{1,4}"
54 "|w[o|w]?|W[o|W]?|Qo?"
55 "|YYYY|YY|Y"
56 "|gg(ggg?)?|GG(GGG?)?"
57 "|a|A"
58 "|hh?|HH?|kk?"
59 "|mm?|ss?|S{1,9}"
60 "|x|X"
61 "|zz?|ZZ?"
62 "|LTS|LT|LL?L?L?"
63 ")"
64 )
66 _FORMAT_RE: re.Pattern[str] = re.compile(_TOKENS)
68 _FROM_FORMAT_RE: re.Pattern[str] = re.compile(r"(?<!\\\[)" + _TOKENS + r"(?!\\\])")
70 _LOCALIZABLE_TOKENS: ClassVar[
71 dict[str, str | Callable[[Locale], Sequence[str]] | None]
72 ] = {
73 "Qo": None,
74 "MMMM": "months.wide",
75 "MMM": "months.abbreviated",
76 "Mo": None,
77 "DDDo": None,
78 "Do": lambda locale: tuple(
79 rf"\d+{o}" for o in locale.get("custom.ordinal").values()
80 ),
81 "dddd": "days.wide",
82 "ddd": "days.abbreviated",
83 "dd": "days.short",
84 "do": None,
85 "e": None,
86 "eo": None,
87 "Wo": None,
88 "wo": None,
89 "A": lambda locale: (
90 locale.translation("day_periods.am"),
91 locale.translation("day_periods.pm"),
92 ),
93 "a": lambda locale: (
94 locale.translation("day_periods.am").lower(),
95 locale.translation("day_periods.pm").lower(),
96 ),
97 }
99 _TOKENS_RULES: ClassVar[dict[str, Callable[[pendulum.DateTime], str]]] = {
100 # Year
101 "YYYY": lambda dt: f"{dt.year:d}",
102 "YY": lambda dt: f"{dt.year:d}"[2:],
103 "Y": lambda dt: f"{dt.year:d}",
104 # Quarter
105 "Q": lambda dt: f"{dt.quarter:d}",
106 # Month
107 "MM": lambda dt: f"{dt.month:02d}",
108 "M": lambda dt: f"{dt.month:d}",
109 # Day
110 "DD": lambda dt: f"{dt.day:02d}",
111 "D": lambda dt: f"{dt.day:d}",
112 # Day of Year
113 "DDDD": lambda dt: f"{dt.day_of_year:03d}",
114 "DDD": lambda dt: f"{dt.day_of_year:d}",
115 # Day of Week
116 "d": lambda dt: f"{(dt.day_of_week + 1) % 7:d}",
117 # Day of ISO Week
118 "E": lambda dt: f"{dt.isoweekday():d}",
119 # Hour
120 "HH": lambda dt: f"{dt.hour:02d}",
121 "H": lambda dt: f"{dt.hour:d}",
122 "hh": lambda dt: f"{dt.hour % 12 or 12:02d}",
123 "h": lambda dt: f"{dt.hour % 12 or 12:d}",
124 # Minute
125 "mm": lambda dt: f"{dt.minute:02d}",
126 "m": lambda dt: f"{dt.minute:d}",
127 # Second
128 "ss": lambda dt: f"{dt.second:02d}",
129 "s": lambda dt: f"{dt.second:d}",
130 # Fractional second
131 "S": lambda dt: f"{dt.microsecond // 100000:01d}",
132 "SS": lambda dt: f"{dt.microsecond // 10000:02d}",
133 "SSS": lambda dt: f"{dt.microsecond // 1000:03d}",
134 "SSSS": lambda dt: f"{dt.microsecond // 100:04d}",
135 "SSSSS": lambda dt: f"{dt.microsecond // 10:05d}",
136 "SSSSSS": lambda dt: f"{dt.microsecond:06d}",
137 # Timestamp
138 "X": lambda dt: f"{dt.int_timestamp:d}",
139 "x": lambda dt: f"{dt.int_timestamp * 1000 + dt.microsecond // 1000:d}",
140 # Timezone
141 "zz": lambda dt: f'{dt.tzname() if dt.tzinfo is not None else ""}',
142 "z": lambda dt: f'{dt.timezone_name or ""}',
143 }
145 _DATE_FORMATS: ClassVar[dict[str, str]] = {
146 "LTS": "formats.time.full",
147 "LT": "formats.time.short",
148 "L": "formats.date.short",
149 "LL": "formats.date.long",
150 "LLL": "formats.datetime.long",
151 "LLLL": "formats.datetime.full",
152 }
154 _DEFAULT_DATE_FORMATS: ClassVar[dict[str, str]] = {
155 "LTS": "h:mm:ss A",
156 "LT": "h:mm A",
157 "L": "MM/DD/YYYY",
158 "LL": "MMMM D, YYYY",
159 "LLL": "MMMM D, YYYY h:mm A",
160 "LLLL": "dddd, MMMM D, YYYY h:mm A",
161 }
163 _REGEX_TOKENS: ClassVar[dict[str, str | Sequence[str] | None]] = {
164 "Y": _MATCH_SIGNED,
165 "YY": (_MATCH_1_TO_2, _MATCH_2),
166 "YYYY": (_MATCH_1_TO_4, _MATCH_4),
167 "Q": _MATCH_1,
168 "Qo": None,
169 "M": _MATCH_1_TO_2,
170 "MM": (_MATCH_1_TO_2, _MATCH_2),
171 "MMM": _MATCH_WORD,
172 "MMMM": _MATCH_WORD,
173 "D": _MATCH_1_TO_2,
174 "DD": (_MATCH_1_TO_2_LEFT_PAD, _MATCH_2),
175 "DDD": _MATCH_1_TO_3,
176 "DDDD": _MATCH_3,
177 "dddd": _MATCH_WORD,
178 "ddd": _MATCH_WORD,
179 "dd": _MATCH_WORD,
180 "d": _MATCH_1,
181 "e": _MATCH_1,
182 "E": _MATCH_1,
183 "Do": None,
184 "H": _MATCH_1_TO_2,
185 "HH": (_MATCH_1_TO_2, _MATCH_2),
186 "h": _MATCH_1_TO_2,
187 "hh": (_MATCH_1_TO_2, _MATCH_2),
188 "m": _MATCH_1_TO_2,
189 "mm": (_MATCH_1_TO_2, _MATCH_2),
190 "s": _MATCH_1_TO_2,
191 "ss": (_MATCH_1_TO_2, _MATCH_2),
192 "S": (_MATCH_1_TO_3, _MATCH_1),
193 "SS": (_MATCH_1_TO_3, _MATCH_2),
194 "SSS": (_MATCH_1_TO_3, _MATCH_3),
195 "SSSS": _MATCH_UNSIGNED,
196 "SSSSS": _MATCH_UNSIGNED,
197 "SSSSSS": _MATCH_UNSIGNED,
198 "x": _MATCH_SIGNED,
199 "X": _MATCH_TIMESTAMP,
200 "ZZ": _MATCH_SHORT_OFFSET,
201 "Z": _MATCH_OFFSET,
202 "z": _MATCH_TIMEZONE,
203 }
205 _PARSE_TOKENS: ClassVar[dict[str, Callable[[str], Any]]] = {
206 "YYYY": lambda year: int(year),
207 "YY": lambda year: int(year),
208 "Q": lambda quarter: int(quarter),
209 "MMMM": lambda month: month,
210 "MMM": lambda month: month,
211 "MM": lambda month: int(month),
212 "M": lambda month: int(month),
213 "DDDD": lambda day: int(day),
214 "DDD": lambda day: int(day),
215 "DD": lambda day: int(day),
216 "D": lambda day: int(day),
217 "dddd": lambda weekday: weekday,
218 "ddd": lambda weekday: weekday,
219 "dd": lambda weekday: weekday,
220 "d": lambda weekday: int(weekday),
221 "E": lambda weekday: int(weekday) - 1,
222 "HH": lambda hour: int(hour),
223 "H": lambda hour: int(hour),
224 "hh": lambda hour: int(hour),
225 "h": lambda hour: int(hour),
226 "mm": lambda minute: int(minute),
227 "m": lambda minute: int(minute),
228 "ss": lambda second: int(second),
229 "s": lambda second: int(second),
230 "S": lambda us: int(us) * 100000,
231 "SS": lambda us: int(us) * 10000,
232 "SSS": lambda us: int(us) * 1000,
233 "SSSS": lambda us: int(us) * 100,
234 "SSSSS": lambda us: int(us) * 10,
235 "SSSSSS": lambda us: int(us),
236 "a": lambda meridiem: meridiem,
237 "X": lambda ts: float(ts),
238 "x": lambda ts: float(ts) / 1e3,
239 "ZZ": str,
240 "Z": str,
241 "z": str,
242 }
244 def format(
245 self, dt: pendulum.DateTime, fmt: str, locale: str | Locale | None = None
246 ) -> str:
247 """
248 Formats a DateTime instance with a given format and locale.
250 :param dt: The instance to format
251 :param fmt: The format to use
252 :param locale: The locale to use
253 """
254 loaded_locale: Locale = Locale.load(locale or pendulum.get_locale())
256 result = self._FORMAT_RE.sub(
257 lambda m: m.group(1)
258 if m.group(1)
259 else m.group(2)
260 if m.group(2)
261 else self._format_token(dt, m.group(3), loaded_locale),
262 fmt,
263 )
265 return result
267 def _format_token(self, dt: pendulum.DateTime, token: str, locale: Locale) -> str:
268 """
269 Formats a DateTime instance with a given token and locale.
271 :param dt: The instance to format
272 :param token: The token to use
273 :param locale: The locale to use
274 """
275 if token in self._DATE_FORMATS:
276 fmt = locale.get(f"custom.date_formats.{token}")
277 if fmt is None:
278 fmt = self._DEFAULT_DATE_FORMATS[token]
280 return self.format(dt, fmt, locale)
282 if token in self._LOCALIZABLE_TOKENS:
283 return self._format_localizable_token(dt, token, locale)
285 if token in self._TOKENS_RULES:
286 return self._TOKENS_RULES[token](dt)
288 # Timezone
289 if token in ["ZZ", "Z"]:
290 if dt.tzinfo is None:
291 return ""
293 separator = ":" if token == "Z" else ""
294 offset = dt.utcoffset() or datetime.timedelta()
295 minutes = offset.total_seconds() / 60
297 sign = "+" if minutes >= 0 else "-"
299 hour, minute = divmod(abs(int(minutes)), 60)
301 return f"{sign}{hour:02d}{separator}{minute:02d}"
303 return token
305 def _format_localizable_token(
306 self, dt: pendulum.DateTime, token: str, locale: Locale
307 ) -> str:
308 """
309 Formats a DateTime instance
310 with a given localizable token and locale.
312 :param dt: The instance to format
313 :param token: The token to use
314 :param locale: The locale to use
315 """
316 if token == "MMM":
317 return cast(str, locale.get("translations.months.abbreviated")[dt.month])
318 elif token == "MMMM":
319 return cast(str, locale.get("translations.months.wide")[dt.month])
320 elif token == "dd":
321 return cast(str, locale.get("translations.days.short")[dt.day_of_week])
322 elif token == "ddd":
323 return cast(
324 str,
325 locale.get("translations.days.abbreviated")[dt.day_of_week],
326 )
327 elif token == "dddd":
328 return cast(str, locale.get("translations.days.wide")[dt.day_of_week])
329 elif token == "e":
330 first_day = cast(int, locale.get("translations.week_data.first_day"))
332 return str((dt.day_of_week % 7 - first_day) % 7)
333 elif token == "Do":
334 return locale.ordinalize(dt.day)
335 elif token == "do":
336 return locale.ordinalize((dt.day_of_week + 1) % 7)
337 elif token == "Mo":
338 return locale.ordinalize(dt.month)
339 elif token == "Qo":
340 return locale.ordinalize(dt.quarter)
341 elif token == "wo":
342 return locale.ordinalize(dt.week_of_year)
343 elif token == "DDDo":
344 return locale.ordinalize(dt.day_of_year)
345 elif token == "eo":
346 first_day = cast(int, locale.get("translations.week_data.first_day"))
348 return locale.ordinalize((dt.day_of_week % 7 - first_day) % 7 + 1)
349 elif token == "A":
350 key = "translations.day_periods"
351 if dt.hour >= 12:
352 key += ".pm"
353 else:
354 key += ".am"
356 return cast(str, locale.get(key))
357 else:
358 return token
360 def parse(
361 self,
362 time: str,
363 fmt: str,
364 now: pendulum.DateTime,
365 locale: str | None = None,
366 ) -> dict[str, Any]:
367 """
368 Parses a time string matching a given format as a tuple.
370 :param time: The timestring
371 :param fmt: The format
372 :param now: The datetime to use as "now"
373 :param locale: The locale to use
375 :return: The parsed elements
376 """
377 escaped_fmt = re.escape(fmt)
379 tokens = self._FROM_FORMAT_RE.findall(escaped_fmt)
380 if not tokens:
381 raise ValueError("The given time string does not match the given format")
383 if not locale:
384 locale = pendulum.get_locale()
386 loaded_locale: Locale = Locale.load(locale)
388 parsed = {
389 "year": None,
390 "month": None,
391 "day": None,
392 "hour": None,
393 "minute": None,
394 "second": None,
395 "microsecond": None,
396 "tz": None,
397 "quarter": None,
398 "day_of_week": None,
399 "day_of_year": None,
400 "meridiem": None,
401 "timestamp": None,
402 }
404 pattern = self._FROM_FORMAT_RE.sub(
405 lambda m: self._replace_tokens(m.group(0), loaded_locale), escaped_fmt
406 )
408 if not re.search("^" + pattern + "$", time):
409 raise ValueError(f"String does not match format {fmt}")
411 def _get_parsed_values(m: Match[str]) -> Any:
412 return self._get_parsed_values(m, parsed, loaded_locale, now)
414 re.sub(pattern, _get_parsed_values, time)
416 return self._check_parsed(parsed, now)
418 def _check_parsed(
419 self, parsed: dict[str, Any], now: pendulum.DateTime
420 ) -> dict[str, Any]:
421 """
422 Checks validity of parsed elements.
424 :param parsed: The elements to parse.
426 :return: The validated elements.
427 """
428 validated: dict[str, int | Timezone | None] = {
429 "year": parsed["year"],
430 "month": parsed["month"],
431 "day": parsed["day"],
432 "hour": parsed["hour"],
433 "minute": parsed["minute"],
434 "second": parsed["second"],
435 "microsecond": parsed["microsecond"],
436 "tz": None,
437 }
439 # If timestamp has been specified
440 # we use it and don't go any further
441 if parsed["timestamp"] is not None:
442 str_us = str(parsed["timestamp"])
443 if "." in str_us:
444 microseconds = int(f'{str_us.split(".")[1].ljust(6, "0")}')
445 else:
446 microseconds = 0
448 from pendulum.helpers import local_time
450 time = local_time(parsed["timestamp"], 0, microseconds)
451 validated["year"] = time[0]
452 validated["month"] = time[1]
453 validated["day"] = time[2]
454 validated["hour"] = time[3]
455 validated["minute"] = time[4]
456 validated["second"] = time[5]
457 validated["microsecond"] = time[6]
459 return validated
461 if parsed["quarter"] is not None:
462 if validated["year"] is not None:
463 dt = pendulum.datetime(cast(int, validated["year"]), 1, 1)
464 else:
465 dt = now
467 dt = dt.start_of("year")
469 while dt.quarter != parsed["quarter"]:
470 dt = dt.add(months=3)
472 validated["year"] = dt.year
473 validated["month"] = dt.month
474 validated["day"] = dt.day
476 if validated["year"] is None:
477 validated["year"] = now.year
479 if parsed["day_of_year"] is not None:
480 dt = cast(
481 pendulum.DateTime,
482 pendulum.parse(f'{validated["year"]}-{parsed["day_of_year"]:>03d}'),
483 )
485 validated["month"] = dt.month
486 validated["day"] = dt.day
488 if parsed["day_of_week"] is not None:
489 dt = pendulum.datetime(
490 cast(int, validated["year"]),
491 cast(int, validated["month"]) or now.month,
492 cast(int, validated["day"]) or now.day,
493 )
494 dt = dt.start_of("week").subtract(days=1)
495 dt = dt.next(parsed["day_of_week"])
496 validated["year"] = dt.year
497 validated["month"] = dt.month
498 validated["day"] = dt.day
500 # Meridiem
501 if parsed["meridiem"] is not None:
502 # If the time is greater than 13:00:00
503 # This is not valid
504 if validated["hour"] is None:
505 raise ValueError("Invalid Date")
507 t = (
508 validated["hour"],
509 validated["minute"],
510 validated["second"],
511 validated["microsecond"],
512 )
513 if t >= (13, 0, 0, 0):
514 raise ValueError("Invalid date")
516 pm = parsed["meridiem"] == "pm"
517 validated["hour"] %= 12 # type: ignore[operator]
518 if pm:
519 validated["hour"] += 12 # type: ignore[operator]
521 if validated["month"] is None:
522 if parsed["year"] is not None:
523 validated["month"] = parsed["month"] or 1
524 else:
525 validated["month"] = parsed["month"] or now.month
527 if validated["day"] is None:
528 if parsed["year"] is not None or parsed["month"] is not None:
529 validated["day"] = parsed["day"] or 1
530 else:
531 validated["day"] = parsed["day"] or now.day
533 for part in ["hour", "minute", "second", "microsecond"]:
534 if validated[part] is None:
535 validated[part] = 0
537 validated["tz"] = parsed["tz"]
539 return validated
541 def _get_parsed_values(
542 self,
543 m: Match[str],
544 parsed: dict[str, Any],
545 locale: Locale,
546 now: pendulum.DateTime,
547 ) -> None:
548 for token, index in m.re.groupindex.items():
549 if token in self._LOCALIZABLE_TOKENS:
550 self._get_parsed_locale_value(token, m.group(index), parsed, locale)
551 else:
552 self._get_parsed_value(token, m.group(index), parsed, now)
554 def _get_parsed_value(
555 self,
556 token: str,
557 value: str,
558 parsed: dict[str, Any],
559 now: pendulum.DateTime,
560 ) -> None:
561 parsed_token = self._PARSE_TOKENS[token](value)
563 if "Y" in token:
564 if token == "YY":
565 if parsed_token <= 68:
566 parsed_token += 2000
567 else:
568 parsed_token += 1900
570 parsed["year"] = parsed_token
571 elif token == "Q":
572 parsed["quarter"] = parsed_token
573 elif token in ["MM", "M"]:
574 parsed["month"] = parsed_token
575 elif token in ["DDDD", "DDD"]:
576 parsed["day_of_year"] = parsed_token
577 elif "D" in token:
578 parsed["day"] = parsed_token
579 elif "H" in token:
580 parsed["hour"] = parsed_token
581 elif token in ["hh", "h"]:
582 if parsed_token > 12:
583 raise ValueError("Invalid date")
585 parsed["hour"] = parsed_token
586 elif "m" in token:
587 parsed["minute"] = parsed_token
588 elif "s" in token:
589 parsed["second"] = parsed_token
590 elif "S" in token:
591 parsed["microsecond"] = parsed_token
592 elif token in ["d", "E"]:
593 parsed["day_of_week"] = parsed_token
594 elif token in ["X", "x"]:
595 parsed["timestamp"] = parsed_token
596 elif token in ["ZZ", "Z"]:
597 negative = bool(value.startswith("-"))
598 tz = value[1:]
599 if ":" not in tz:
600 if len(tz) == 2:
601 tz = f"{tz}00"
603 off_hour = tz[0:2]
604 off_minute = tz[2:4]
605 else:
606 off_hour, off_minute = tz.split(":")
608 offset = ((int(off_hour) * 60) + int(off_minute)) * 60
610 if negative:
611 offset = -1 * offset
613 parsed["tz"] = pendulum.timezone(offset)
614 elif token == "z":
615 # Full timezone
616 if value not in pendulum.timezones():
617 raise ValueError("Invalid date")
619 parsed["tz"] = pendulum.timezone(value)
621 def _get_parsed_locale_value(
622 self, token: str, value: str, parsed: dict[str, Any], locale: Locale
623 ) -> None:
624 if token == "MMMM":
625 unit = "month"
626 match = "months.wide"
627 elif token == "MMM":
628 unit = "month"
629 match = "months.abbreviated"
630 elif token == "Do":
631 parsed["day"] = int(cast(Match[str], re.match(r"(\d+)", value)).group(1))
633 return
634 elif token == "dddd":
635 unit = "day_of_week"
636 match = "days.wide"
637 elif token == "ddd":
638 unit = "day_of_week"
639 match = "days.abbreviated"
640 elif token == "dd":
641 unit = "day_of_week"
642 match = "days.short"
643 elif token in ["a", "A"]:
644 valid_values = [
645 locale.translation("day_periods.am"),
646 locale.translation("day_periods.pm"),
647 ]
649 if token == "a":
650 value = value.lower()
651 valid_values = [x.lower() for x in valid_values]
653 if value not in valid_values:
654 raise ValueError("Invalid date")
656 parsed["meridiem"] = ["am", "pm"][valid_values.index(value)]
658 return
659 else:
660 raise ValueError(f'Invalid token "{token}"')
662 parsed[unit] = locale.match_translation(match, value)
663 if value is None:
664 raise ValueError("Invalid date")
666 def _replace_tokens(self, token: str, locale: Locale) -> str:
667 if token.startswith("[") and token.endswith("]"):
668 return token[1:-1]
669 elif token.startswith("\\"):
670 if len(token) == 2 and token[1] in {"[", "]"}:
671 return ""
673 return token
674 elif token not in self._REGEX_TOKENS and token not in self._LOCALIZABLE_TOKENS:
675 raise ValueError(f"Unsupported token: {token}")
677 if token in self._LOCALIZABLE_TOKENS:
678 values = self._LOCALIZABLE_TOKENS[token]
679 if callable(values):
680 candidates = values(locale)
681 else:
682 candidates = tuple(
683 locale.translation(
684 cast(str, self._LOCALIZABLE_TOKENS[token])
685 ).values()
686 )
687 else:
688 candidates = cast(Sequence[str], self._REGEX_TOKENS[token])
690 if not candidates:
691 raise ValueError(f"Unsupported token: {token}")
693 if not isinstance(candidates, tuple):
694 candidates = (cast(str, candidates),)
696 pattern = f'(?P<{token}>{"|".join(candidates)})'
698 return pattern