timestamp: Use localized formatting in format_datetime_to_string.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg
2025-10-02 14:31:02 -07:00
committed by Tim Abbott
parent 9ca788cbbc
commit 890ccec8d4
8 changed files with 77 additions and 15 deletions

View File

@@ -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

View File

@@ -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 = [

10
uv.lock generated
View File

@@ -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" },

View File

@@ -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

View File

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

View File

@@ -1426,9 +1426,9 @@ class TestMessageNotificationEmails(ZulipTestCase):
self.assertEqual(
"> Meeting at <time:2025-09-30T09:30:00-07:00>\n\n--\n\nReply", mail.outbox[0].body[:56]
)
self.assertIn(
'<p>Meeting at <time datetime="2025-09-30T16:30:00Z">Tuesday, September 30, 2025 at 04:30 PM UTC</time></p>',
self.assertRegex(
mail.outbox[0].alternatives[0][0],
r'<p>Meeting at <time datetime="2025-09-30T16:30:00Z">Tue, Sep 30, 2025, 4:30[ \u202f]PM UTC</time></p>',
)
# The timestamp is not formatted correctly.

View File

@@ -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:

View File

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