From 890ccec8d49ff2fcf78481c54c04680ac9e01793 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 2 Oct 2025 14:31:02 -0700 Subject: [PATCH] timestamp: Use localized formatting in format_datetime_to_string. Signed-off-by: Anders Kaseorg --- pyproject.toml | 4 ++ scripts/lib/setup_venv.py | 1 + uv.lock | 10 +++++ version.py | 2 +- zerver/lib/timestamp.py | 40 ++++++++++++++++--- .../tests/test_message_notification_emails.py | 4 +- zerver/tests/test_new_users.py | 8 +--- zerver/tests/test_timestamp.py | 23 ++++++++++- 8 files changed, 77 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 508f14b538..0dc7180efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,6 +210,9 @@ prod = [ # Better compression than zlib "zstd", + + # Internationalization + "pyicu", ] docs = [ # Needed to build RTD docs @@ -406,6 +409,7 @@ module = [ "fakeldap.*", "firebase_admin.*", "gitlint.*", + "icu.*", # https://gitlab.pyicu.org/main/pyicu/-/issues/156 "integrations.*", "jsonref.*", "ldap.*", # https://github.com/python-ldap/python-ldap/issues/368 diff --git a/scripts/lib/setup_venv.py b/scripts/lib/setup_venv.py index c083bc3e77..f002e7c72a 100644 --- a/scripts/lib/setup_venv.py +++ b/scripts/lib/setup_venv.py @@ -24,6 +24,7 @@ VENV_DEPENDENCIES = [ "libsasl2-dev", # For building python-ldap from source "libvips", # For thumbnailing "libvips-tools", + "libicu-dev", # For building pyicu ] COMMON_YUM_VENV_DEPENDENCIES = [ diff --git a/uv.lock b/uv.lock index 38b030fedb..854a68f2f0 100644 --- a/uv.lock +++ b/uv.lock @@ -3505,6 +3505,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyicu" +version = "2.15.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/b0/c8b61bac55424e2ff80e20d7251c3f002baff3c07c34cee3849e3505d8f5/pyicu-2.15.3.tar.gz", hash = "sha256:f32e78e1cb64d0aeb14f027e037a8944861d3114548818a6adf0081ef51aefc3", size = 267569, upload-time = "2025-09-15T20:58:50.936Z" } + [[package]] name = "pyinotify" version = "0.9.6" @@ -5830,6 +5836,7 @@ dev = [ { name = "pydantic" }, { name = "pydantic-partials" }, { name = "pygments" }, + { name = "pyicu" }, { name = "pyinotify" }, { name = "pyjwt" }, { name = "pymongo" }, @@ -5954,6 +5961,7 @@ prod = [ { name = "pydantic" }, { name = "pydantic-partials" }, { name = "pygments" }, + { name = "pyicu" }, { name = "pyjwt" }, { name = "pymongo" }, { name = "pynacl" }, @@ -6057,6 +6065,7 @@ dev = [ { name = "pydantic" }, { name = "pydantic-partials" }, { name = "pygments" }, + { name = "pyicu" }, { name = "pyinotify" }, { name = "pyjwt" }, { name = "pymongo" }, @@ -6182,6 +6191,7 @@ prod = [ { name = "pydantic" }, { name = "pydantic-partials" }, { name = "pygments" }, + { name = "pyicu" }, { name = "pyjwt" }, { name = "pymongo" }, { name = "pynacl" }, diff --git a/version.py b/version.py index b04437af30..1e2284f2b1 100644 --- a/version.py +++ b/version.py @@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 427 # historical commits sharing the same major version, in which case a # minor version bump suffices. -PROVISION_VERSION = (351, 0) # bumped 2025-10-01 to remove emoji-datasource-google-blob +PROVISION_VERSION = (351, 1) # bumped 2025-10-02 to add PyICU diff --git a/zerver/lib/timestamp.py b/zerver/lib/timestamp.py index 71c9283877..1268d4b4db 100644 --- a/zerver/lib/timestamp.py +++ b/zerver/lib/timestamp.py @@ -1,4 +1,8 @@ from datetime import datetime, timedelta, timezone +from functools import cache + +import icu +from django.utils.translation import get_language class TimeZoneNotUTCError(Exception): @@ -49,9 +53,35 @@ def datetime_to_timestamp(dt: datetime) -> int: return int(dt.timestamp()) +@cache +def get_date_time_pattern_generator(language: str) -> icu.DateTimePatternGenerator: + return icu.DateTimePatternGenerator.createInstance(icu.Locale(language)) + + +@cache +def get_icu_time_zone(time_zone: str) -> icu.TimeZone: + return icu.TimeZone.createTimeZone(time_zone) + + +@cache +def get_date_time_format(language: str, use_twenty_four_hour_time: bool) -> icu.SimpleDateFormat: + skeleton = f"yMMMEd{'H' if use_twenty_four_hour_time else 'h'}mz" + pattern = get_date_time_pattern_generator(language).getBestPattern(skeleton) + return icu.SimpleDateFormat(pattern, icu.Locale(language)) + + def format_datetime_to_string(dt: datetime, use_twenty_four_hour_time: bool) -> str: - if use_twenty_four_hour_time: - hhmm_string = dt.strftime("%H:%M") - else: - hhmm_string = dt.strftime("%I:%M %p") - return dt.strftime(f"%A, %B %d, %Y at {hhmm_string} %Z") + assert dt.tzinfo is not None + time_zone = getattr(dt.tzinfo, "key", None) + if time_zone is None: + offset = dt.tzinfo.utcoffset(dt) + assert offset is not None + sign = "-" if offset < timedelta(0) else "+" + hours, rest = divmod(abs(offset), timedelta(hours=1)) + minutes, rest = divmod(rest, timedelta(minutes=1)) + assert rest == timedelta(0) + time_zone = f"GMT{sign}{hours:02}:{minutes:02}" + language = get_language() + calendar = icu.Calendar.createInstance(get_icu_time_zone(time_zone), icu.Locale(language)) + calendar.setTime(dt) + return get_date_time_format(language, use_twenty_four_hour_time).format(calendar) diff --git a/zerver/tests/test_message_notification_emails.py b/zerver/tests/test_message_notification_emails.py index ba3e428336..e8b498c804 100644 --- a/zerver/tests/test_message_notification_emails.py +++ b/zerver/tests/test_message_notification_emails.py @@ -1426,9 +1426,9 @@ class TestMessageNotificationEmails(ZulipTestCase): self.assertEqual( "> Meeting at \n\n--\n\nReply", mail.outbox[0].body[:56] ) - self.assertIn( - '

Meeting at

', + self.assertRegex( mail.outbox[0].alternatives[0][0], + r'

Meeting at

', ) # The timestamp is not formatted correctly. diff --git a/zerver/tests/test_new_users.py b/zerver/tests/test_new_users.py index 2e182c887b..493c1cbe67 100644 --- a/zerver/tests/test_new_users.py +++ b/zerver/tests/test_new_users.py @@ -1,4 +1,3 @@ -import zoneinfo from collections.abc import Sequence from datetime import datetime, timedelta, timezone @@ -14,7 +13,6 @@ from zerver.actions.streams import do_set_stream_property from zerver.actions.user_settings import do_change_user_setting from zerver.lib.initial_password import initial_password from zerver.lib.test_classes import ZulipTestCase -from zerver.lib.timezone import canonicalize_timezone from zerver.models import Message, Recipient, Stream, UserProfile from zerver.models.realms import get_realm from zerver.models.recipients import get_direct_message_group_user_ids @@ -53,9 +51,7 @@ class SendLoginEmailTest(ZulipTestCase): firefox_windows = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0" ) - user_tz = zoneinfo.ZoneInfo(canonicalize_timezone(user.timezone)) mock_time = datetime(year=2018, month=1, day=1, tzinfo=timezone.utc) - reference_time = mock_time.astimezone(user_tz).strftime("%A, %B %d, %Y at %I:%M %p %Z") with time_machine.travel(mock_time, tick=False): self.client_post( "/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows @@ -66,7 +62,7 @@ class SendLoginEmailTest(ZulipTestCase): subject = "New login from Firefox on Windows" self.assertEqual(mail.outbox[0].subject, subject) # local time is correct and in email's body - self.assertIn(reference_time, mail.outbox[0].body) + self.assertRegex(str(mail.outbox[0].body), r"Sun, Dec 31, 2017, 4:00[ \u202f]PM PST") # Try again with the 24h time format setting enabled for this user self.logout() # We just logged in, we'd be redirected without this @@ -77,7 +73,7 @@ class SendLoginEmailTest(ZulipTestCase): "/accounts/login/", info=login_info, HTTP_USER_AGENT=firefox_windows ) - reference_time = mock_time.astimezone(user_tz).strftime("%A, %B %d, %Y at %H:%M %Z") + reference_time = "Sun, Dec 31, 2017, 16:00 PST" self.assertIn(reference_time, mail.outbox[1].body) def test_dont_send_login_emails_if_send_login_emails_is_false(self) -> None: diff --git a/zerver/tests/test_timestamp.py b/zerver/tests/test_timestamp.py index a2b5e981a2..f67a729c95 100644 --- a/zerver/tests/test_timestamp.py +++ b/zerver/tests/test_timestamp.py @@ -1,6 +1,8 @@ -from datetime import timedelta, timezone +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo from dateutil import parser +from django.utils.translation import override as override_language from zerver.lib.test_classes import ZulipTestCase from zerver.lib.timestamp import ( @@ -10,6 +12,7 @@ from zerver.lib.timestamp import ( datetime_to_timestamp, floor_to_day, floor_to_hour, + format_datetime_to_string, timestamp_to_datetime, ) @@ -45,3 +48,21 @@ class TestTimestamp(ZulipTestCase): for function in [floor_to_hour, floor_to_day, ceiling_to_hour, ceiling_to_hour]: with self.assertRaises(TimeZoneNotUTCError): function(non_utc_datetime) + + def test_format_datetime_to_string(self) -> None: + dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone.utc) + self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT") + dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone(timedelta(hours=7, minutes=8))) + self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT+7:08") + dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone(-timedelta(hours=7, minutes=8))) + self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 GMT-7:08") + dt = datetime(2001, 2, 3, 4, 5, 6, tzinfo=ZoneInfo("America/Los_Angeles")) + self.assertEqual(format_datetime_to_string(dt, True), "Sat, Feb 3, 2001, 04:05 PST") + self.assertRegex( + format_datetime_to_string(dt, False), r"^Sat, Feb 3, 2001, 4:05[ \u202f]AM PST$" + ) + with override_language("ja-JP"): + self.assertEqual(format_datetime_to_string(dt, True), "2001年2月3日(土) 4:05 GMT-8") + self.assertEqual( + format_datetime_to_string(dt, False), "2001年2月3日(土) 午前4:05 GMT-8" + )