formatter.py 22 KB

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