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 1/2] 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") From db42e68924d8cba7e5a0bbbbaad5118bcdaef8c6 Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:07:23 +0000 Subject: [PATCH 2/2] Account for missing ordinal categories in from_format candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locales like Italian map only some plural categories (custom.ordinal has just 'other' -> '°'), so numbers in unmapped categories (8, 11, 80 use 'many') are formatted as plain digits by Locale.ordinalize(). from_format previously only accepted plain digits when the locale had no ordinal map at all, so parsing its own output raised ValueError. Always append a plain-digit fallback candidate alongside the suffixed forms so every ordinal token (Do, Mo, Qo, DDDo) accepts what format() emits. Add the Italian 'many' case from the review as a regression test. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- src/pendulum/formatting/formatter.py | 10 ++++------ tests/datetime/test_from_format.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/pendulum/formatting/formatter.py b/src/pendulum/formatting/formatter.py index 8cca86f82..ea77e3e99 100644 --- a/src/pendulum/formatting/formatter.py +++ b/src/pendulum/formatting/formatter.py @@ -48,17 +48,15 @@ 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. + Mirrors ``Locale.ordinalize()``: locales whose ordinal map does not + cover every plural category format those numbers as the plain + number, so the parse pattern must accept plain digits as well. """ candidates = tuple( rf"\d+{o}" for o in (locale.get("custom.ordinal") or {}).values() ) - if not candidates: - candidates = (r"\d+",) - return candidates + return (*candidates, r"\d+") class Formatter: diff --git a/tests/datetime/test_from_format.py b/tests/datetime/test_from_format.py index 316c25b2c..74088e8cc 100644 --- a/tests/datetime/test_from_format.py +++ b/tests/datetime/test_from_format.py @@ -241,6 +241,21 @@ def test_from_format_ordinal_month(): assert_datetime(d, 1982, 6, 1, 0, 0, 0) +def test_from_format_ordinal_with_partial_locale_ordinals(): + """ + Locales whose ordinal map does not cover every plural category + (Italian only maps ``other``; ``8`` falls in ``many``) format those + numbers without a suffix; ``from_format`` must accept what + ``format`` emits. + """ + d = pendulum.datetime(1982, 8, 1) + s = d.format("Mo YYYY", locale="it") + assert s == "8 1982" + + parsed = pendulum.from_format(s, "Mo YYYY", locale="it") + assert_datetime(parsed, 1982, 8, 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)