diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f84145c..059648c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## Unreleased + +### Fixed +- `from_format` now parses the ordinal tokens `Mo`, `Qo` and `DDDo`, and `Do` in locales without ordinal suffixes, matching what `format()` emits; the remaining unsupported ordinal tokens raise the regular `ValueError` instead of `AttributeError` [#1074](https://github.com/python-pendulum/pendulum/issues/1074) + ## [3.3.0] - 2026-10-12 ### Added diff --git a/src/pendulum/formatting/formatter.py b/src/pendulum/formatting/formatter.py index b09977d0d..2c980e7bc 100644 --- a/src/pendulum/formatting/formatter.py +++ b/src/pendulum/formatting/formatter.py @@ -45,6 +45,20 @@ _MATCH_TIMEZONE = "[A-Za-z0-9-+]+(/[A-Za-z0-9-+_]+)?" +def _ordinal_candidates(locale: Locale, plain_fallback: bool = True) -> tuple[str, ...]: + """Regex candidates matching `format()` output for ordinal tokens. + + Locales without a `custom.ordinal` table format ordinals as plain + numbers (see `Locale.ordinalize`), so the pattern must accept bare + digits there. + """ + ordinals = locale.get("custom.ordinal") + if not ordinals: + return (r"\d+",) if plain_fallback else () + + return tuple(rf"\d+{o}" for o in ordinals.values()) + + class Formatter: _TOKENS: str = ( r"\[([^\[]*)\]|\\(.)|" @@ -71,14 +85,12 @@ class Formatter: _LOCALIZABLE_TOKENS: ClassVar[ dict[str, str | Callable[[Locale], Sequence[str]] | None] ] = { - "Qo": None, + "Qo": lambda locale: _ordinal_candidates(locale), "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": lambda locale: _ordinal_candidates(locale), + "DDDo": lambda locale: _ordinal_candidates(locale), + "Do": lambda locale: _ordinal_candidates(locale), "dddd": "days.wide", "ddd": "days.abbreviated", "dd": "days.short", @@ -630,6 +642,24 @@ def _get_parsed_locale_value( elif token == "Do": parsed["day"] = int(cast("Match[str]", re.match(r"(\d+)", value)).group(1)) + return + elif token == "Qo": + parsed["quarter"] = int( + cast("Match[str]", re.match(r"(\d+)", value)).group(1) + ) + + return + elif token == "Mo": + parsed["month"] = int( + cast("Match[str]", re.match(r"(\d+)", value)).group(1) + ) + + return + elif token == "DDDo": + parsed["day_of_year"] = int( + cast("Match[str]", re.match(r"(\d+)", value)).group(1) + ) + return elif token == "dddd": unit = "day_of_week" @@ -676,6 +706,9 @@ 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..cd65cd4bc 100644 --- a/tests/datetime/test_from_format.py +++ b/tests/datetime/test_from_format.py @@ -220,3 +220,37 @@ def test_from_format_2_digit_year(): d = pendulum.from_format("99", "YY") assert d.year == 1999 + + +def test_from_format_do_ordinal(): + d = pendulum.from_format("7th June 1982", "Do MMMM YYYY", locale="en") + assert_datetime(d, 1982, 6, 7) + + +def test_from_format_do_without_locale_ordinals(): + # de has no custom.ordinal table; format() emits the plain number + d = pendulum.from_format("7 Juni 1982", "Do MMMM YYYY", locale="de") + assert_datetime(d, 1982, 6, 7) + + +def test_from_format_quarter_ordinal(): + d = pendulum.from_format("2nd 1982", "Qo YYYY", locale="en") + assert d.year == 1982 + assert d.quarter == 2 + + +def test_from_format_month_ordinal(): + d = pendulum.from_format("6th 1982", "Mo YYYY", locale="en") + assert_datetime(d, 1982, 6, 1) + + +def test_from_format_day_of_year_ordinal(): + d = pendulum.from_format("158th 1982", "DDDo YYYY", locale="en") + assert_datetime(d, 1982, 6, 7) + + +@pytest.mark.parametrize("token", ["do", "e", "eo", "Wo", "wo"]) +def test_from_format_unsupported_tokens_raise_value_error(token): + # previously a raw AttributeError leaked from the None entries + with pytest.raises(ValueError, match="Unsupported token"): + pendulum.from_format(f"1 1982", f"{token} YYYY", locale="en")