Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
45 changes: 39 additions & 6 deletions src/pendulum/formatting/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"\[([^\[]*)\]|\\(.)|"
Expand All @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions tests/datetime/test_from_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")