Skip to content

Commit 85a283a

Browse files
Backend/i18n: Support localized datetime formats (#8574)
1 parent f37beac commit 85a283a

8 files changed

Lines changed: 234 additions & 99 deletions

File tree

app/backend/src/couchers/i18n/context.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
1-
from dataclasses import dataclass
1+
from dataclasses import FrozenInstanceError
22
from datetime import UTC, date, datetime, time, tzinfo
3+
from typing import Any
34
from zoneinfo import ZoneInfo
45

6+
import babel
57
from google.protobuf.timestamp_pb2 import Timestamp
68

79
from couchers.i18n.i18next import I18Next
8-
from couchers.i18n.locales import DEFAULT_LOCALE
10+
from couchers.i18n.locales import DEFAULT_LOCALE, get_locale_fallbacks
911
from couchers.i18n.localize import (
12+
get_babel_locale,
1013
get_main_i18next,
1114
localize_date,
12-
localize_date_from_iso,
1315
localize_datetime,
1416
localize_time,
1517
localize_timezone,
1618
)
1719
from couchers.models.users import User
20+
from couchers.utils import to_timezone
1821

1922

20-
@dataclass(frozen=True, slots=True, kw_only=True)
2123
class LocalizationContext:
2224
"""
2325
Specifies regional settings used for localization of strings and date/times.
@@ -28,34 +30,79 @@ class LocalizationContext:
2830
# Note that a locale doesn't necessarily specify a region.
2931
locale: str
3032

33+
# The locale code and all of its fallbacks.
34+
locale_list: list[str]
35+
3136
# The timezone to use when formatting date-times and instants.
3237
timezone: tzinfo
3338

39+
# The Babel locale used for datetime formatting and other Unicode CLDR usage.
40+
babel_locale: babel.Locale
41+
42+
def __init__(self, locale: str, timezone: tzinfo) -> None:
43+
self.locale = locale
44+
self.locale_list = [self.locale] + get_locale_fallbacks(self.locale)
45+
self.timezone = timezone
46+
self.babel_locale = get_babel_locale(self.locale_list)
47+
48+
def __setattr__(self, name: str, value: Any) -> None:
49+
# Freeze after initialization. We can't use @dataclass(frozen=True) because then
50+
# we need the default initializer and some of our fields shouldn't be parameters.
51+
if hasattr(self, "babel_locale"):
52+
raise FrozenInstanceError(f"Cannot modify attribute {name}.")
53+
return object.__setattr__(self, name, value)
54+
3455
@property
3556
def localized_timezone(self) -> str:
36-
return localize_timezone(self.timezone, self.locale)
57+
return localize_timezone(self.timezone, self.babel_locale)
3758

3859
def localize_string(
3960
self, key: str, *, i18next: I18Next | None = None, substitutions: dict[str, str | int] | None = None
4061
) -> str:
4162
i18next = i18next or get_main_i18next()
4263
return i18next.localize(key, self.locale, substitutions=substitutions)
4364

44-
def localize_date(self, value: date | datetime) -> str:
65+
def localize_date(
66+
self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
67+
) -> str:
4568
if isinstance(value, datetime):
46-
value = value.astimezone(self.timezone).date()
47-
return localize_date(value, self.locale)
69+
value = to_timezone(value, self.timezone).date()
70+
return localize_date(
71+
value, self.babel_locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week
72+
)
4873

49-
def localize_date_from_iso(self, value: str) -> str:
50-
return localize_date_from_iso(value, self.locale)
74+
def localize_date_from_iso(
75+
self, value: str, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
76+
) -> str:
77+
return self.localize_date(
78+
date.fromisoformat(value),
79+
abbrev=abbrev,
80+
with_year=with_year,
81+
with_day_of_week=with_day_of_week,
82+
)
5183

52-
def localize_datetime(self, value: datetime | Timestamp) -> str:
53-
return localize_datetime(value, self.timezone, self.locale)
84+
def localize_datetime(
85+
self,
86+
value: datetime | Timestamp,
87+
*,
88+
abbrev: bool = False,
89+
with_year: bool = True,
90+
with_day_of_week: bool = False,
91+
with_seconds: bool = False,
92+
) -> str:
93+
return localize_datetime(
94+
to_timezone(value, self.timezone),
95+
self.babel_locale,
96+
abbrev=abbrev,
97+
with_year=with_year,
98+
with_day_of_week=with_day_of_week,
99+
with_seconds=with_seconds,
100+
)
54101

55-
def localize_time(self, value: datetime | time) -> str:
102+
def localize_time(self, value: datetime | time, *, with_seconds: bool = False) -> str:
56103
if isinstance(value, datetime):
57-
value = value.astimezone(self.timezone).time()
58-
return localize_time(value, self.locale)
104+
value = to_timezone(value, self.timezone).time()
105+
return localize_time(value, self.babel_locale, with_seconds=with_seconds)
59106

60107
@staticmethod
61108
def en_utc() -> LocalizationContext:

app/backend/src/couchers/i18n/localize.py

Lines changed: 98 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
Most code should use the higher-level couchers.i18n.LocalizationContext object.
44
"""
55

6-
from collections.abc import Callable, Mapping
6+
import re
7+
from collections.abc import Mapping
78
from datetime import date, datetime, time, tzinfo
89
from functools import lru_cache
910
from pathlib import Path
11+
from typing import cast
1012

13+
import babel
1114
import phonenumbers
12-
from babel.dates import format_date, format_datetime, format_time, get_timezone_name
13-
from google.protobuf.timestamp_pb2 import Timestamp
15+
from babel.dates import get_datetime_format, get_timezone_name, match_skeleton, parse_pattern
1416

1517
from couchers.i18n.i18next import I18Next
16-
from couchers.i18n.locales import DEFAULT_LOCALE, get_locale_fallbacks, load_locales
17-
from couchers.utils import to_aware_datetime
18+
from couchers.i18n.locales import DEFAULT_LOCALE, load_locales
1819

1920

2021
@lru_cache(maxsize=1)
@@ -23,6 +24,16 @@ def get_main_i18next() -> I18Next:
2324
return load_locales(Path(__file__).parent / "locales")
2425

2526

27+
def get_babel_locale(locale_list: list[str]) -> babel.Locale:
28+
"""Resolves a babel.Locale object from a list of locale candidates."""
29+
for locale in locale_list:
30+
try:
31+
return babel.Locale.parse(locale)
32+
except babel.UnknownLocaleError:
33+
continue
34+
raise LookupError(f"No babel locale found for locales {', '.join(locale_list)}")
35+
36+
2637
def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str:
2738
"""
2839
Retrieves a translated string and performs substitutions.
@@ -38,83 +49,108 @@ def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, s
3849
return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions)
3950

4051

41-
def localize_date(value: date, locale: str) -> str:
52+
def localize_date(
53+
value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
54+
) -> str:
4255
"""Formats a time- and timezone-agnostic date for the given locale."""
43-
return _localize_with_fallbacks(
56+
pattern = _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week)
57+
return parse_pattern(pattern).apply(value, locale)
58+
59+
60+
def localize_time(value: time, locale: babel.Locale, *, with_seconds: bool = False) -> str:
61+
"""Formats a date- and timezone-agnostic time for the given locale."""
62+
pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds)
63+
return parse_pattern(pattern).apply(value, locale)
64+
65+
66+
def localize_datetime(
67+
value: datetime,
68+
locale: babel.Locale,
69+
*,
70+
abbrev: bool = False,
71+
with_year: bool = True,
72+
with_day_of_week: bool = False,
73+
with_seconds: bool = False,
74+
) -> str:
75+
"""Formats a date and time for the given locale."""
76+
# A timezone-unaware datetime is almost certainly a bug, so we don't support it.
77+
assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
78+
79+
pattern = _combine_cldr_date_time_patterns(
4480
locale,
45-
lambda candidate_locale: format_date(value, locale=candidate_locale),
46-
lambda: value.strftime("%A %-d %B %Y"),
81+
_get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week),
82+
_get_cldr_time_pattern(locale, with_seconds=with_seconds),
4783
)
84+
return parse_pattern(pattern).apply(value, locale)
4885

4986

50-
def localize_date_from_iso(value: str, locale: str) -> str:
51-
"""Formats a date in ISO YYYY-MM-DD format for the given locale."""
52-
return localize_date(date.fromisoformat(value), locale)
87+
def _get_cldr_date_pattern(
88+
locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
89+
) -> str:
90+
# First build a Unicode CLDR datetime pattern skeleton, which is locale and order-agnostic,
91+
# and only indicates the components we're interested in formatting.
92+
# This is similar to Intl.DateTimeFormat in Javascript.
93+
# See https://cldr.unicode.org/translation/date-time/date-time-symbols.
94+
requested_skeleton = ""
5395

96+
if with_year:
97+
requested_skeleton += "y"
98+
requested_skeleton += "MMM" if abbrev else "MMMM"
99+
requested_skeleton += "d"
100+
if with_day_of_week:
101+
requested_skeleton += "EEE" if abbrev else "EEEE"
54102

55-
def localize_time(value: time, locale: str) -> str:
56-
"""Formats a date- and timezone-agnostic time for the given locale."""
57-
return _localize_with_fallbacks(
58-
locale,
59-
lambda candidate_locale: format_time(value, locale=candidate_locale),
60-
lambda: value.strftime("%-I:%M %p (%H:%M)"),
61-
)
103+
# Next, match that skeleton to a similar locale-supported skeleton,
104+
# which allows us to lower it to a datetime pattern (locale and order-specific).
105+
matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
106+
if not matched_skeleton:
107+
raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
62108

109+
pattern: str = locale.datetime_skeletons[matched_skeleton].pattern
63110

64-
def localize_datetime(value: datetime | Timestamp, timezone: tzinfo | None, locale: str) -> str:
65-
"""
66-
Formats a date and time for the given locale.
111+
# By CLDR rules, skeleton matching might return a pattern with abbreviations where
112+
# we asked for non-abbreviated forms, in which case we can update the returned pattern.
113+
if not abbrev:
114+
# Abbreviated to non-abbreviated month (MMM = abbreviated)
115+
pattern = re.sub(r"(?<!M)MMM(?!M)", "MMMM", pattern)
116+
if with_day_of_week:
117+
# Abbreviated to non-abbreviated day of week (E = EEE = abbreviated)
118+
pattern = re.sub(r"(?<!E)E{1,3}(?!E)", "EEEE", pattern)
67119

68-
Args:
69-
datetime: The datetime or timestamp to be formatted.
70-
timezone: An optional timezone in which to interpret the date. If None, uses datetime's timezone.
71-
locale: The locale for which to format the date.
120+
return pattern
72121

73-
Returns:
74-
The localized date and time string.
75-
"""
76-
if isinstance(value, Timestamp):
77-
value = to_aware_datetime(value)
78122

79-
# A timezone-unaware datetime is almost certainly a bug, so we don't support it.
80-
assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
123+
def _get_cldr_time_pattern(locale: babel.Locale, *, with_seconds: bool = False) -> str:
124+
# Use a reference format pattern to figure out if it's using 24h clock
125+
reference_time_pattern: str = locale.time_formats["medium"].pattern
81126

82-
if timezone is not None:
83-
value = value.astimezone(timezone)
127+
# Remove literals like 'of'
128+
reference_time_pattern = re.sub("'[^']*'", "", reference_time_pattern)
84129

85-
return _localize_with_fallbacks(
86-
locale,
87-
lambda candidate_locale: format_datetime(value, locale=candidate_locale),
88-
lambda: localize_date(value.date(), locale) + " " + localize_time(value.time(), locale),
89-
)
130+
# Extract only the hours, minutes and am/pm patterns.
131+
requested_skeleton = re.sub("[^hHkKma]+", "", reference_time_pattern)
132+
if with_seconds:
133+
requested_skeleton += "ss"
90134

135+
# Next, match that skeleton to a similar locale-supported skeleton,
136+
# which allows us to lower it to a datetime pattern (locale and order-specific).
137+
matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
138+
if not matched_skeleton:
139+
raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
91140

92-
def localize_timezone(timezone: tzinfo, locale: str) -> str:
93-
return _localize_with_fallbacks(
94-
locale,
95-
lambda candidate_locale: get_timezone_name(timezone, locale=candidate_locale),
96-
lambda: datetime.now(tz=timezone).strftime("%Z/UTC%z"),
97-
)
141+
return cast(str, locale.datetime_skeletons[matched_skeleton].pattern) # "pattern" is Any-typed
98142

99143

100-
def _localize_with_fallbacks(
101-
locale: str, localize: Callable[[str], str], fallback: Callable[[], str] | None = None
102-
) -> str:
103-
"""
104-
Attempts to localize a value using fallback locales if the locale is unavailable, or a final unlocalized fallback.
105-
"""
106-
exception: Exception | None
107-
for candidate_locale in [locale] + get_locale_fallbacks(locale):
108-
try:
109-
return localize(candidate_locale.replace("-", "_")) # Babel expects "en_US", not "en-US".
110-
except Exception as e:
111-
exception = e
112-
pass
144+
def _combine_cldr_date_time_patterns(locale: babel.Locale, date_pattern: str, time_pattern: str) -> str:
145+
# get_datetime_format's return value is statically mistyped
146+
combining_format = cast(str, get_datetime_format(locale=locale))
147+
148+
# CLDR defines {0} to be the time and {1} to be the date
149+
return combining_format.replace("{1}", date_pattern).replace("{0}", time_pattern)
113150

114-
if fallback:
115-
return fallback()
116151

117-
raise exception or Exception(f"Failed to localize to {locale}.")
152+
def localize_timezone(timezone: tzinfo, locale: babel.Locale, *, short: bool = False) -> str:
153+
return get_timezone_name(timezone, width="short" if short else "long", locale=locale)
118154

119155

120156
def format_phone_number(value: str) -> str:

app/backend/src/couchers/notifications/background.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import dataclasses
21
import logging
32

43
from google.protobuf import empty_pb2
@@ -45,7 +44,7 @@ def _send_email_notification(session: Session, user: User, notification: Notific
4544

4645
loc_context = LocalizationContext.from_user(user)
4746
if not context.get_boolean_value("notification_translations_enabled", default=False):
48-
loc_context = dataclasses.replace(loc_context, locale="en")
47+
loc_context = LocalizationContext(locale="en", timezone=loc_context.timezone)
4948

5049
rendered = render_email_notification(
5150
user,

0 commit comments

Comments
 (0)