33Most 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
78from datetime import date , datetime , time , tzinfo
89from functools import lru_cache
910from pathlib import Path
11+ from typing import cast
1012
13+ import babel
1114import 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
1517from 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+
2637def 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
120156def format_phone_number (value : str ) -> str :
0 commit comments