← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:timezone-utc-prepare into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:timezone-utc-prepare into launchpad:master.

Commit message:
Prepare for use of non-pytz timezones

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/438651

It would be good to be able to port away from `pytz`; in particular, `pytz.UTC` can be replaced with the standard library's `datetime.timezone.utc` these days.  However, there were a few `pytz`-specific assumptions in the date widget, and we currently need to work around some slightly suboptimal behaviour of `datetime.timezone.utc` in Python 3.5.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:timezone-utc-prepare into launchpad:master.
diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
index ed832e8..0377cff 100644
--- a/lib/lp/app/browser/tales.py
+++ b/lib/lp/app/browser/tales.py
@@ -55,6 +55,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
+from lp.services.compat import tzname
 from lp.services.utils import round_half_up
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.canonicalurl import nearest_adapter
@@ -1292,7 +1293,8 @@ class PersonFormatterAPI(ObjectFormatterAPI):
     def local_time(self):
         """Return the local time for this person."""
         time_zone = self._context.time_zone
-        return datetime.now(pytz.timezone(time_zone)).strftime("%T %Z")
+        dt = datetime.now(pytz.timezone(time_zone))
+        return "%s %s" % (dt.strftime("%T"), tzname(dt))
 
     def url(self, view_name=None, rootsite="mainsite"):
         """See `ObjectFormatterAPI`.
@@ -2383,7 +2385,7 @@ class DateTimeFormatterAPI:
     def time(self):
         if self._datetime.tzinfo:
             value = self._datetime.astimezone(getUtility(ILaunchBag).time_zone)
-            return value.strftime("%T %Z")
+            return "%s %s" % (value.strftime("%T"), tzname(value))
         else:
             return self._datetime.strftime("%T")
 
diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
index 7925a3c..259007d 100644
--- a/lib/lp/app/widgets/date.py
+++ b/lib/lp/app/widgets/date.py
@@ -17,7 +17,7 @@ __all__ = [
     "DatetimeDisplayWidget",
 ]
 
-from datetime import datetime
+from datetime import datetime, timezone
 
 import pytz
 from zope.browserpage import ViewPageTemplateFile
@@ -32,6 +32,7 @@ from zope.formlib.textwidgets import TextWidget
 from zope.formlib.widget import DisplayWidget
 
 from lp.app.validators import LaunchpadValidationError
+from lp.services.compat import tzname
 from lp.services.utils import round_half_up
 from lp.services.webapp.escaping import html_escape
 from lp.services.webapp.interfaces import ILaunchBag
@@ -217,7 +218,13 @@ class DateTimeWidget(TextWidget):
     @property
     def time_zone_name(self):
         """The name of the widget time zone for display in the widget."""
-        return self.time_zone.zone
+        # XXX cjwatson 2023-03-09: In Python < 3.6, `timezone.utc.tzname`
+        # returns "UTC+00:00" rather than "UTC".  Drop this once we require
+        # Python >= 3.6.
+        if self.time_zone is timezone.utc:
+            return "UTC"
+        else:
+            return self.time_zone.tzname(None)
 
     def _align_date_constraints_with_time_zone(self):
         """Ensure that from_date and to_date use the widget time zone."""
@@ -225,14 +232,22 @@ class DateTimeWidget(TextWidget):
             if self.from_date.tzinfo is None:
                 # Timezone-naive constraint is interpreted as being in the
                 # widget time zone.
-                self.from_date = self.time_zone.localize(self.from_date)
+                if hasattr(self.time_zone, "localize"):  # pytz
+                    self.from_date = self.time_zone.localize(self.from_date)
+                else:
+                    self.from_date = self.from_date.replace(
+                        tzinfo=self.time_zone
+                    )
             else:
                 self.from_date = self.from_date.astimezone(self.time_zone)
         if isinstance(self.to_date, datetime):
             if self.to_date.tzinfo is None:
                 # Timezone-naive constraint is interpreted as being in the
                 # widget time zone.
-                self.to_date = self.time_zone.localize(self.to_date)
+                if hasattr(self.time_zone, "localize"):  # pytz
+                    self.to_date = self.time_zone.localize(self.to_date)
+                else:
+                    self.to_date = self.to_date.replace(tzinfo=self.time_zone)
             else:
                 self.to_date = self.to_date.astimezone(self.time_zone)
 
@@ -411,7 +426,10 @@ class DateTimeWidget(TextWidget):
             dt = datetime(year, month, day, hour, minute, int(second), micro)
         except (DateTimeError, ValueError, IndexError) as v:
             raise ConversionError("Invalid date value", v)
-        return self.time_zone.localize(dt)
+        if hasattr(self.time_zone, "localize"):  # pytz
+            return self.time_zone.localize(dt)
+        else:
+            return dt.replace(tzinfo=self.time_zone)
 
     def _toFormValue(self, value):
         """Convert a date to its string representation.
@@ -621,4 +639,6 @@ class DatetimeDisplayWidget(DisplayWidget):
         if value == self.context.missing_value:
             return ""
         value = value.astimezone(time_zone)
-        return html_escape(value.strftime("%Y-%m-%d %H:%M:%S %Z"))
+        return html_escape(
+            "%s %s" % (value.strftime("%Y-%m-%d %H:%M:%S", tzname(value)))
+        )
diff --git a/lib/lp/blueprints/browser/sprint.py b/lib/lp/blueprints/browser/sprint.py
index 9d66d8b..a89a362 100644
--- a/lib/lp/blueprints/browser/sprint.py
+++ b/lib/lp/blueprints/browser/sprint.py
@@ -61,6 +61,7 @@ from lp.registry.browser.menu import (
     RegistryCollectionActionMenuBase,
 )
 from lp.registry.interfaces.person import IPersonSet
+from lp.services.compat import tzname
 from lp.services.database.bulk import load_referencing
 from lp.services.helpers import shortlist
 from lp.services.propertycache import cachedproperty
@@ -228,30 +229,31 @@ class SprintView(HasSpecificationsView):
     def formatDateTime(self, dt):
         """Format a datetime value according to the sprint's time zone"""
         dt = dt.astimezone(self.tzinfo)
-        return dt.strftime("%Y-%m-%d %H:%M %Z")
+        return "%s %s" % (dt.strftime("%Y-%m-%d %H:%M"), tzname(dt))
 
     def formatDate(self, dt):
         """Format a date value according to the sprint's time zone"""
         dt = dt.astimezone(self.tzinfo)
         return dt.strftime("%Y-%m-%d")
 
-    _local_timeformat = "%H:%M %Z on %A, %Y-%m-%d"
+    def _formatLocal(self, dt):
+        return "%s %s on %s" % (
+            dt.strftime("%H:%M"),
+            tzname(dt),
+            dt.strftime("%A, %Y-%m-%d"),
+        )
 
     @property
     def local_start(self):
         """The sprint start time, in the local time zone, as text."""
         tz = pytz.timezone(self.context.time_zone)
-        return self.context.time_starts.astimezone(tz).strftime(
-            self._local_timeformat
-        )
+        return self._formatLocal(self.context.time_starts.astimezone(tz))
 
     @property
     def local_end(self):
         """The sprint end time, in the local time zone, as text."""
         tz = pytz.timezone(self.context.time_zone)
-        return self.context.time_ends.astimezone(tz).strftime(
-            self._local_timeformat
-        )
+        return self._formatLocal(self.context.time_ends.astimezone(tz))
 
 
 class SprintAddView(LaunchpadFormView):
diff --git a/lib/lp/bugs/scripts/uct/models.py b/lib/lp/bugs/scripts/uct/models.py
index fd77ea5..d9f92a2 100644
--- a/lib/lp/bugs/scripts/uct/models.py
+++ b/lib/lp/bugs/scripts/uct/models.py
@@ -42,6 +42,7 @@ from lp.registry.model.person import Person
 from lp.registry.model.product import Product
 from lp.registry.model.sourcepackage import SourcePackage
 from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.services.compat import tzname
 from lp.services.propertycache import cachedproperty
 
 __all__ = [
@@ -410,7 +411,7 @@ class UCTRecord:
 
     @classmethod
     def _format_datetime(cls, dt: datetime) -> str:
-        return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
+        return "%s %s" % (dt.strftime("%Y-%m-%d %H:%M:%S"), tzname(dt))
 
     @classmethod
     def _format_notes(cls, notes: List[Tuple[str, str]]) -> str:
diff --git a/lib/lp/services/compat.py b/lib/lp/services/compat.py
index 15a0f4c..86d833a 100644
--- a/lib/lp/services/compat.py
+++ b/lib/lp/services/compat.py
@@ -1,16 +1,19 @@
 # Copyright 2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Python 2/3 compatibility layer.
+"""Python compatibility layer.
 
 Use this for things that six doesn't provide.
 """
 
 __all__ = [
     "message_as_bytes",
+    "tzname",
 ]
 
 import io
+from datetime import datetime, time, timezone
+from typing import Union
 
 
 def message_as_bytes(message):
@@ -21,3 +24,18 @@ def message_as_bytes(message):
     g = BytesGenerator(fp, mangle_from_=False, maxheaderlen=0, policy=compat32)
     g.flatten(message)
     return fp.getvalue()
+
+
+def tzname(obj: Union[datetime, time]) -> str:
+    """Return this (date)time object's time zone name as a string.
+
+    Python 3.5's `timezone.utc.tzname` returns "UTC+00:00", rather than
+    "UTC" which is what we prefer.  Paper over this until we can rely on
+    Python >= 3.6 everywhere.
+    """
+    if obj.tzinfo is None:
+        return ""
+    elif obj.tzinfo is timezone.utc:
+        return "UTC"
+    else:
+        return obj.tzname()
diff --git a/lib/lp/translations/vocabularies.py b/lib/lp/translations/vocabularies.py
index ff69730..fa9d754 100644
--- a/lib/lp/translations/vocabularies.py
+++ b/lib/lp/translations/vocabularies.py
@@ -17,6 +17,7 @@ from storm.locals import Desc, Not, Or
 from zope.schema.vocabulary import SimpleTerm
 
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.services.compat import tzname
 from lp.services.database.sqlobject import AND
 from lp.services.webapp.vocabulary import (
     NamedStormVocabulary,
@@ -136,7 +137,10 @@ class FilteredLanguagePackVocabularyBase(StormVocabularyBase):
 
     def toTerm(self, obj):
         return SimpleTerm(
-            obj, obj.id, "%s" % obj.date_exported.strftime("%F %T %Z")
+            obj,
+            obj.id,
+            "%s %s"
+            % (obj.date_exported.strftime("%F %T"), tzname(obj.date_exported)),
         )
 
     @property
@@ -174,8 +178,12 @@ class FilteredLanguagePackVocabulary(FilteredLanguagePackVocabularyBase):
         return SimpleTerm(
             obj,
             obj.id,
-            "%s (%s)"
-            % (obj.date_exported.strftime("%F %T %Z"), obj.type.title),
+            "%s %s (%s)"
+            % (
+                obj.date_exported.strftime("%F %T"),
+                tzname(obj.date_exported),
+                obj.type.title,
+            ),
         )
 
     @property