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")