Skip to content

Commit 9e1d0b4

Browse files
committed
Fix from_format crash on multi-character literal blocks
Formatter.parse() runs re.escape() on the format string first, so a literal block like [de] arrives as \[de\]. The token regex only suppressed the characters immediately adjacent to the brackets, so token letters in the middle of a multi-character literal (for example the d and e in [del]) were still tokenized, raising AttributeError or a "redefinition of group name" re.error. Match the whole escaped literal block as a single token in _FROM_FORMAT_RE and unwrap it in _replace_tokens, keeping the inner text as a literal. Fixes #971
1 parent b99bd14 commit 9e1d0b4

2 files changed

Lines changed: 22 additions & 1 deletion

File tree

src/pendulum/formatting/formatter.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class Formatter:
6666

6767
_FORMAT_RE: re.Pattern[str] = re.compile(_TOKENS)
6868

69-
_FROM_FORMAT_RE: re.Pattern[str] = re.compile(r"(?<!\\\[)" + _TOKENS + r"(?!\\\])")
69+
_FROM_FORMAT_RE: re.Pattern[str] = re.compile(r"\\\[.*?\\\]|" + _TOKENS)
7070

7171
_LOCALIZABLE_TOKENS: ClassVar[
7272
dict[str, str | Callable[[Locale], Sequence[str]] | None]
@@ -664,6 +664,11 @@ def _get_parsed_locale_value(
664664
raise ValueError("Invalid date")
665665

666666
def _replace_tokens(self, token: str, locale: Locale) -> str:
667+
if token.startswith("\\[") and token.endswith("\\]"):
668+
# parse() runs re.escape() on the format first, so a literal block such
669+
# as "[de]" arrives here as "\\[de\\]". Drop the (escaped) brackets and
670+
# keep the already-escaped inner text as a literal.
671+
return token[2:-2]
667672
if token.startswith("[") and token.endswith("]"):
668673
return token[1:-1]
669674
elif token.startswith("\\"):

tests/datetime/test_from_format.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ def test_from_format_with_escaped_elements_valid_tokens():
5757
assert d.timezone_name == "UTC"
5858

5959

60+
def test_from_format_with_multi_character_escaped_elements():
61+
# GH #971: literal blocks longer than one character that contain token letters
62+
# (e.g. "the" holds the h/e tokens, "del" holds the d/e tokens) were
63+
# mis-tokenized and raised instead of being treated as literal text.
64+
d = pendulum.from_format("the year 2023", "[the year] YYYY")
65+
assert_datetime(d, 2023, 1, 1, 0, 0, 0)
66+
d = pendulum.from_format(
67+
"21 de noviembre del 2023", "DD [de] MMMM [del] YYYY", locale="es"
68+
)
69+
assert_datetime(d, 2023, 11, 21, 0, 0, 0)
70+
d = pendulum.from_format(
71+
"21 de noviembre de 2023", "DD [de] MMMM [de] YYYY", locale="es"
72+
)
73+
assert_datetime(d, 2023, 11, 21, 0, 0, 0)
74+
75+
6076
def test_from_format_with_millis():
6177
d = pendulum.from_format("1975-05-21 22:32:11.123456", "YYYY-MM-DD HH:mm:ss.SSSSSS")
6278
assert_datetime(d, 1975, 5, 21, 22, 32, 11, 123456)

0 commit comments

Comments
 (0)