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