From 18012f1406856e15c1ceb34842ebf7faa5d376e0 Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:25:25 +0530 Subject: [PATCH] Fix from_format AttributeError for ordinal tokens from_format crashed with a raw AttributeError instead of a ValueError for every ordinal token: 'Do' in locales without ordinal suffixes (bg, de, ru, ...), because the Do pattern builder called .values() on locale.get('custom.ordinal') which is None there; and Qo/Mo/DDDo/do/ e/eo/Wo/wo in every locale, whose None entries went to locale.translation(None).values(). - Do now falls back to plain \d+ for locales without ordinals, mirroring Locale.ordinalize(), so format/from_format round-trips work in all locales. - Mo, Qo and DDDo get real parse support (month/quarter/day-of-year are all consumed by _check_parsed). - The remaining None tokens (do, e, eo, Wo, wo) raise the regular 'Unsupported token' ValueError. Fixes #1074 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- src/pendulum/formatting/formatter.py | 41 ++++++++++++++++++++++------ tests/datetime/test_from_format.py | 38 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/pendulum/formatting/formatter.py b/src/pendulum/formatting/formatter.py index b09977d0d..8cca86f82 100644 --- a/src/pendulum/formatting/formatter.py +++ b/src/pendulum/formatting/formatter.py @@ -45,6 +45,22 @@ _MATCH_TIMEZONE = "[A-Za-z0-9-+]+(/[A-Za-z0-9-+_]+)?" +def _ordinal_candidates(locale: Locale) -> tuple[str, ...]: + """Regex candidates matching an ordinal number in the given locale. + + Mirrors ``Locale.ordinalize()``: locales without ordinal suffixes + format ordinals as the plain number, so the parse pattern must + accept plain digits for them. + """ + candidates = tuple( + rf"\d+{o}" for o in (locale.get("custom.ordinal") or {}).values() + ) + if not candidates: + candidates = (r"\d+",) + + return candidates + + class Formatter: _TOKENS: str = ( r"\[([^\[]*)\]|\\(.)|" @@ -71,14 +87,12 @@ class Formatter: _LOCALIZABLE_TOKENS: ClassVar[ dict[str, str | Callable[[Locale], Sequence[str]] | None] ] = { - "Qo": None, + "Qo": _ordinal_candidates, "MMMM": "months.wide", "MMM": "months.abbreviated", - "Mo": None, - "DDDo": None, - "Do": lambda locale: tuple( - rf"\d+{o}" for o in locale.get("custom.ordinal").values() - ), + "Mo": _ordinal_candidates, + "DDDo": _ordinal_candidates, + "Do": _ordinal_candidates, "dddd": "days.wide", "ddd": "days.abbreviated", "dd": "days.short", @@ -627,8 +641,17 @@ def _get_parsed_locale_value( elif token == "MMM": unit = "month" match = "months.abbreviated" - elif token == "Do": - parsed["day"] = int(cast("Match[str]", re.match(r"(\d+)", value)).group(1)) + elif token in ("Do", "Mo", "Qo", "DDDo"): + number = int(cast("Match[str]", re.match(r"(\d+)", value)).group(1)) + + if token == "Do": + parsed["day"] = number + elif token == "Mo": + parsed["month"] = number + elif token == "Qo": + parsed["quarter"] = number + else: + parsed["day_of_year"] = number return elif token == "dddd": @@ -676,6 +699,8 @@ def _replace_tokens(self, token: str, locale: Locale) -> str: if token in self._LOCALIZABLE_TOKENS: values = self._LOCALIZABLE_TOKENS[token] + if values is None: + raise ValueError(f"Unsupported token: {token}") if callable(values): candidates = values(locale) else: diff --git a/tests/datetime/test_from_format.py b/tests/datetime/test_from_format.py index ecf59632a..316c25b2c 100644 --- a/tests/datetime/test_from_format.py +++ b/tests/datetime/test_from_format.py @@ -220,3 +220,41 @@ def test_from_format_2_digit_year(): d = pendulum.from_format("99", "YY") assert d.year == 1999 + + +def test_from_format_ordinal_day_without_locale_ordinals(): + """ + Locales without ordinal suffixes format ``Do`` as the plain day + number; ``from_format`` must accept what ``format`` emits. + """ + d = pendulum.from_format("7 Juni 1982", "Do MMMM YYYY", locale="de") + assert_datetime(d, 1982, 6, 7, 0, 0, 0) + + +def test_from_format_ordinal_day_with_ordinals(): + d = pendulum.from_format("7th June 1982", "Do MMMM YYYY") + assert_datetime(d, 1982, 6, 7, 0, 0, 0) + + +def test_from_format_ordinal_month(): + d = pendulum.from_format("6th 1982", "Mo YYYY") + assert_datetime(d, 1982, 6, 1, 0, 0, 0) + + +def test_from_format_ordinal_quarter(): + d = pendulum.from_format("2nd 1982", "Qo YYYY") + assert_datetime(d, 1982, 4, 1, 0, 0, 0) + + +def test_from_format_ordinal_day_of_year(): + d = pendulum.from_format("158th 1982", "DDDo YYYY") + assert_datetime(d, 1982, 6, 7, 0, 0, 0) + + +@pytest.mark.parametrize("token", ["Wo", "wo", "do", "e", "eo"]) +def test_from_format_unsupported_tokens_raise_value_error(token): + # These tokens used to raise AttributeError ("'NoneType' object has + # no attribute 'values'"); they must raise the regular + # unsupported-token ValueError. + with pytest.raises(ValueError, match=r"^Unsupported token"): + pendulum.from_format("1st 1982", f"{token} YYYY")