Merge ~cjwatson/launchpad:dateutil.tz into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: c22c48bb6327ebe213ae61c44b00e689c1a012c5
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:dateutil.tz
Merge into: launchpad:master
Diff against target: 504 lines (+108/-69)
15 files modified
lib/lp/app/browser/tales.py (+2/-2)
lib/lp/app/widgets/date.py (+16/-18)
lib/lp/blueprints/browser/sprint.py (+10/-6)
lib/lp/blueprints/browser/sprintattendance.py (+6/-6)
lib/lp/bugs/doc/bugnotification-email.rst (+3/-3)
lib/lp/bugs/doc/externalbugtracker.rst (+2/-2)
lib/lp/bugs/tests/bugs-emailinterface.rst (+5/-5)
lib/lp/registry/browser/person.py (+5/-7)
lib/lp/services/webapp/doc/launchbag.rst (+1/-1)
lib/lp/services/webapp/launchbag.py (+2/-2)
lib/lp/services/worlddata/doc/vocabularies.rst (+2/-4)
lib/lp/services/worlddata/vocabularies.py (+49/-8)
lib/lp/snappy/tests/test_snapbuildbehaviour.py (+3/-3)
requirements/types.txt (+1/-1)
setup.cfg (+1/-1)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+444821@code.launchpad.net

Commit message

Remove direct dependencies on pytz

Description of the change

`dateutil.tz` is a better fit for Python's modern timezone provider interface, and has fewer footguns as a result.

The only significant downside is that we have to reimplement something similar to `pytz.common_timezones` for use by our timezone vocabulary. Fortunately this isn't too difficult.

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
2index 0e0b0e5..44781e8 100644
3--- a/lib/lp/app/browser/tales.py
4+++ b/lib/lp/app/browser/tales.py
5@@ -12,7 +12,7 @@ from email.utils import formatdate, mktime_tz
6 from textwrap import dedent
7 from urllib.parse import quote
8
9-import pytz
10+from dateutil import tz
11 from lazr.restful.utils import get_current_browser_request
12 from lazr.uri import URI
13 from zope.browserpage import ViewPageTemplateFile
14@@ -1293,7 +1293,7 @@ class PersonFormatterAPI(ObjectFormatterAPI):
15 def local_time(self):
16 """Return the local time for this person."""
17 time_zone = self._context.time_zone
18- dt = datetime.now(pytz.timezone(time_zone))
19+ dt = datetime.now(tz.gettz(time_zone))
20 return "%s %s" % (dt.strftime("%T"), tzname(dt))
21
22 def url(self, view_name=None, rootsite="mainsite"):
23diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
24index e024dfa..ce27881 100644
25--- a/lib/lp/app/widgets/date.py
26+++ b/lib/lp/app/widgets/date.py
27@@ -19,7 +19,7 @@ __all__ = [
28
29 from datetime import datetime, timezone, tzinfo
30
31-import pytz
32+from dateutil import tz
33 from zope.browserpage import ViewPageTemplateFile
34 from zope.component import getUtility
35 from zope.datetime import DateTimeError, parse
36@@ -217,14 +217,23 @@ class DateTimeWidget(TextWidget):
37 >>> widget.required_time_zone_name = "Africa/Maseru"
38 >>> print(widget.time_zone_name)
39 Africa/Maseru
40- >>> print(widget.time_zone)
41- Africa/Maseru
42+ >>> print(widget.time_zone) # doctest: +ELLIPSIS
43+ tzfile('.../Africa/Maseru')
44+
45+ When the required_time_zone_name is invalid, we fall back to UTC.
46+
47+ >>> widget.required_time_zone_name = "Some/Nonsense"
48+ >>> print(widget.time_zone_name)
49+ Some/Nonsense
50+ >>> print(repr(widget.time_zone))
51+ datetime.timezone.utc
52
53 """
54 if self.time_zone_name == "UTC":
55 return timezone.utc
56 else:
57- return pytz.timezone(self.time_zone_name)
58+ zone = tz.gettz(self.time_zone_name)
59+ return zone if zone is not None else timezone.utc
60
61 def _align_date_constraints_with_time_zone(self):
62 """Ensure that from_date and to_date use the widget time zone."""
63@@ -232,22 +241,14 @@ class DateTimeWidget(TextWidget):
64 if self.from_date.tzinfo is None:
65 # Timezone-naive constraint is interpreted as being in the
66 # widget time zone.
67- if hasattr(self.time_zone, "localize"): # pytz
68- self.from_date = self.time_zone.localize(self.from_date)
69- else:
70- self.from_date = self.from_date.replace(
71- tzinfo=self.time_zone
72- )
73+ self.from_date = self.from_date.replace(tzinfo=self.time_zone)
74 else:
75 self.from_date = self.from_date.astimezone(self.time_zone)
76 if isinstance(self.to_date, datetime):
77 if self.to_date.tzinfo is None:
78 # Timezone-naive constraint is interpreted as being in the
79 # widget time zone.
80- if hasattr(self.time_zone, "localize"): # pytz
81- self.to_date = self.time_zone.localize(self.to_date)
82- else:
83- self.to_date = self.to_date.replace(tzinfo=self.time_zone)
84+ self.to_date = self.to_date.replace(tzinfo=self.time_zone)
85 else:
86 self.to_date = self.to_date.astimezone(self.time_zone)
87
88@@ -426,10 +427,7 @@ class DateTimeWidget(TextWidget):
89 dt = datetime(year, month, day, hour, minute, int(second), micro)
90 except (DateTimeError, ValueError, IndexError) as v:
91 raise ConversionError("Invalid date value", v)
92- if hasattr(self.time_zone, "localize"): # pytz
93- return self.time_zone.localize(dt)
94- else:
95- return dt.replace(tzinfo=self.time_zone)
96+ return dt.replace(tzinfo=self.time_zone)
97
98 def _toFormValue(self, value):
99 """Convert a date to its string representation.
100diff --git a/lib/lp/blueprints/browser/sprint.py b/lib/lp/blueprints/browser/sprint.py
101index 41148c0..debe896 100644
102--- a/lib/lp/blueprints/browser/sprint.py
103+++ b/lib/lp/blueprints/browser/sprint.py
104@@ -27,7 +27,7 @@ import io
105 from collections import defaultdict
106 from typing import List
107
108-import pytz
109+from dateutil import tz
110 from lazr.restful.utils import smartquote
111 from zope.component import getUtility
112 from zope.formlib.widget import CustomWidgetFactory
113@@ -190,7 +190,7 @@ class SprintView(HasSpecificationsView):
114 def initialize(self):
115 self.notices = []
116 self.latest_specs_limit = 5
117- self.tzinfo = pytz.timezone(self.context.time_zone)
118+ self.tzinfo = tz.gettz(self.context.time_zone)
119
120 def attendance(self):
121 """establish if this user is attending"""
122@@ -246,14 +246,18 @@ class SprintView(HasSpecificationsView):
123 @property
124 def local_start(self):
125 """The sprint start time, in the local time zone, as text."""
126- tz = pytz.timezone(self.context.time_zone)
127- return self._formatLocal(self.context.time_starts.astimezone(tz))
128+ return self._formatLocal(
129+ self.context.time_starts.astimezone(
130+ tz.gettz(self.context.time_zone)
131+ )
132+ )
133
134 @property
135 def local_end(self):
136 """The sprint end time, in the local time zone, as text."""
137- tz = pytz.timezone(self.context.time_zone)
138- return self._formatLocal(self.context.time_ends.astimezone(tz))
139+ return self._formatLocal(
140+ self.context.time_ends.astimezone(tz.gettz(self.context.time_zone))
141+ )
142
143
144 class SprintAddView(LaunchpadFormView):
145diff --git a/lib/lp/blueprints/browser/sprintattendance.py b/lib/lp/blueprints/browser/sprintattendance.py
146index 194f340..baeea2f 100644
147--- a/lib/lp/blueprints/browser/sprintattendance.py
148+++ b/lib/lp/blueprints/browser/sprintattendance.py
149@@ -10,7 +10,7 @@ __all__ = [
150
151 from datetime import timedelta
152
153-import pytz
154+from dateutil import tz
155 from zope.formlib.widget import CustomWidgetFactory
156
157 from lp import _
158@@ -56,7 +56,7 @@ class BaseSprintAttendanceAddView(LaunchpadFormView):
159 # after the sprint. We will accept a time just before or just after
160 # and map those to the beginning and end times, respectively, in
161 # self.getDates().
162- time_zone = pytz.timezone(self.context.time_zone)
163+ time_zone = tz.gettz(self.context.time_zone)
164 from_date = self.context.time_starts.astimezone(time_zone)
165 to_date = self.context.time_ends.astimezone(time_zone)
166 self.starts_widget.from_date = from_date - timedelta(days=1)
167@@ -142,16 +142,16 @@ class BaseSprintAttendanceAddView(LaunchpadFormView):
168 @property
169 def local_start(self):
170 """The sprint start time, in the local time zone, as text."""
171- tz = pytz.timezone(self.context.time_zone)
172- return self.context.time_starts.astimezone(tz).strftime(
173+ time_zone = tz.gettz(self.context.time_zone)
174+ return self.context.time_starts.astimezone(time_zone).strftime(
175 self._local_timeformat
176 )
177
178 @property
179 def local_end(self):
180 """The sprint end time, in the local time zone, as text."""
181- tz = pytz.timezone(self.context.time_zone)
182- return self.context.time_ends.astimezone(tz).strftime(
183+ time_zone = tz.gettz(self.context.time_zone)
184+ return self.context.time_ends.astimezone(time_zone).strftime(
185 self._local_timeformat
186 )
187
188diff --git a/lib/lp/bugs/doc/bugnotification-email.rst b/lib/lp/bugs/doc/bugnotification-email.rst
189index 9f07a1e..94a7a2b 100644
190--- a/lib/lp/bugs/doc/bugnotification-email.rst
191+++ b/lib/lp/bugs/doc/bugnotification-email.rst
192@@ -585,12 +585,12 @@ method requires a from address, a to person, a body, a subject and a sending
193 date for the mail.
194
195 >>> from datetime import datetime
196- >>> import pytz
197+ >>> from dateutil import tz
198
199 >>> from_address = get_bugmail_from_address(lp_janitor, bug_four)
200 >>> to_person = getUtility(IPersonSet).getByEmail("foo.bar@canonical.com")
201- >>> sending_date = pytz.timezone("Europe/Prague").localize(
202- ... datetime(2008, 5, 20, 11, 5, 47)
203+ >>> sending_date = datetime(
204+ ... 2008, 5, 20, 11, 5, 47, tzinfo=tz.gettz("Europe/Prague")
205 ... )
206
207 >>> notification_email = bug_four_notification_builder.build(
208diff --git a/lib/lp/bugs/doc/externalbugtracker.rst b/lib/lp/bugs/doc/externalbugtracker.rst
209index 84a1835..b2288ef 100644
210--- a/lib/lp/bugs/doc/externalbugtracker.rst
211+++ b/lib/lp/bugs/doc/externalbugtracker.rst
212@@ -361,8 +361,8 @@ the time is.
213 If the difference between what we and the remote system think the time
214 is, an error is raised.
215
216- >>> import pytz
217 >>> from datetime import datetime, timedelta, timezone
218+ >>> from dateutil import tz
219 >>> utc_now = datetime.now(timezone.utc)
220 >>> class PositiveTimeSkewExternalBugTracker(TestExternalBugTracker):
221 ... def getCurrentDBTime(self):
222@@ -417,7 +417,7 @@ than the UTC time.
223
224 >>> class LocalTimeExternalBugTracker(TestExternalBugTracker):
225 ... def getCurrentDBTime(self):
226- ... local_time = utc_now.astimezone(pytz.timezone("US/Eastern"))
227+ ... local_time = utc_now.astimezone(tz.gettz("US/Eastern"))
228 ... return local_time + timedelta(minutes=1)
229 ...
230 >>> bug_watch_updater.updateBugWatches(
231diff --git a/lib/lp/bugs/tests/bugs-emailinterface.rst b/lib/lp/bugs/tests/bugs-emailinterface.rst
232index b0d3575..5f87927 100644
233--- a/lib/lp/bugs/tests/bugs-emailinterface.rst
234+++ b/lib/lp/bugs/tests/bugs-emailinterface.rst
235@@ -3193,7 +3193,7 @@ we'll create a new bug on firefox and link it to a remote bug.
236 >>> no_priv = getUtility(IPersonSet).getByName("no-priv")
237
238 >>> from datetime import datetime, timezone
239- >>> import pytz
240+ >>> from dateutil import tz
241 >>> creation_date = datetime(2008, 4, 12, 10, 12, 12, tzinfo=timezone.utc)
242
243 We create the initial bug message separately from the bug itself so that
244@@ -3238,7 +3238,7 @@ importing machinery.
245 >>> bug_watch = getUtility(IBugWatchSet).get(bug_watch.id)
246
247 >>> comment_date = datetime(
248- ... 2008, 5, 19, 16, 19, 12, tzinfo=pytz.timezone("Europe/Prague")
249+ ... 2008, 5, 19, 16, 19, 12, tzinfo=tz.gettz("Europe/Prague")
250 ... )
251
252 >>> initial_mail = (
253@@ -3265,7 +3265,7 @@ Now someone uses the email interface to respond to the comment that has
254 been submitted.
255
256 >>> comment_date = datetime(
257- ... 2008, 5, 20, 11, 24, 12, tzinfo=pytz.timezone("Europe/Prague")
258+ ... 2008, 5, 20, 11, 24, 12, tzinfo=tz.gettz("Europe/Prague")
259 ... )
260
261 >>> reply_mail = (
262@@ -3318,7 +3318,7 @@ to an email that isn't linked to the bug, the new message will be linked
263 to the bug and will not have its bugwatch field set.
264
265 >>> comment_date = datetime(
266- ... 2008, 5, 21, 11, 9, 12, tzinfo=pytz.timezone("Europe/Prague")
267+ ... 2008, 5, 21, 11, 9, 12, tzinfo=tz.gettz("Europe/Prague")
268 ... )
269
270 >>> initial_mail = (
271@@ -3338,7 +3338,7 @@ to the bug and will not have its bugwatch field set.
272 >>> message = getUtility(IMessageSet).fromEmail(initial_mail, no_priv)
273
274 >>> comment_date = datetime(
275- ... 2008, 5, 21, 12, 52, 12, tzinfo=pytz.timezone("Europe/Prague")
276+ ... 2008, 5, 21, 12, 52, 12, tzinfo=tz.gettz("Europe/Prague")
277 ... )
278
279 >>> reply_mail = (
280diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
281index 1484dab..f6c7ca2 100644
282--- a/lib/lp/registry/browser/person.py
283+++ b/lib/lp/registry/browser/person.py
284@@ -56,7 +56,7 @@ from operator import attrgetter, itemgetter
285 from textwrap import dedent
286 from urllib.parse import quote, urlencode
287
288-import pytz
289+from dateutil import tz
290 from lazr.config import as_timedelta
291 from lazr.delegates import delegate_to
292 from lazr.restful.interface import copy_field
293@@ -2043,9 +2043,7 @@ class PersonView(LaunchpadView, FeedsMixin, ContactViaWebLinksMixin):
294 @property
295 def time_zone_offset(self):
296 """Return a string with offset from UTC"""
297- return datetime.now(pytz.timezone(self.context.time_zone)).strftime(
298- "%z"
299- )
300+ return datetime.now(tz.gettz(self.context.time_zone)).strftime("%z")
301
302
303 class PersonParticipationView(LaunchpadView):
304@@ -4419,13 +4417,13 @@ class PersonEditTimeZoneView(LaunchpadFormView):
305 @action(_("Update"), name="update")
306 def action_update(self, action, data):
307 """Set the time zone for the person."""
308- tz = data.get("time_zone")
309- if tz is None:
310+ time_zone = data.get("time_zone")
311+ if time_zone is None:
312 raise UnexpectedFormData("No location received.")
313 # XXX salgado, 2012-02-16, bug=933699: Use setLocation() because it's
314 # the cheaper way to set the timezone of a person. Once the bug is
315 # fixed we'll be able to get rid of this hack.
316- self.context.setLocation(None, None, tz, self.user)
317+ self.context.setLocation(None, None, time_zone, self.user)
318
319
320 def archive_to_person(archive):
321diff --git a/lib/lp/services/webapp/doc/launchbag.rst b/lib/lp/services/webapp/doc/launchbag.rst
322index 41e7b6e..091abaa 100644
323--- a/lib/lp/services/webapp/doc/launchbag.rst
324+++ b/lib/lp/services/webapp/doc/launchbag.rst
325@@ -115,4 +115,4 @@ After the LaunchBag has been cleared, the correct time zone is returned.
326 >>> launchbag.time_zone_name
327 'Europe/Paris'
328 >>> launchbag.time_zone
329- <... 'Europe/Paris' ...>
330+ tzfile('.../Europe/Paris')
331diff --git a/lib/lp/services/webapp/launchbag.py b/lib/lp/services/webapp/launchbag.py
332index f20dfe0..34118a3 100644
333--- a/lib/lp/services/webapp/launchbag.py
334+++ b/lib/lp/services/webapp/launchbag.py
335@@ -10,7 +10,7 @@ The collection of stuff we have traversed.
336 import threading
337 from datetime import timezone
338
339-import pytz
340+from dateutil import tz
341 from zope.component import getUtility
342 from zope.interface import implementer
343
344@@ -161,7 +161,7 @@ class LaunchBag:
345 if self.time_zone_name == "UTC":
346 self._store.time_zone = timezone.utc
347 else:
348- self._store.time_zone = pytz.timezone(self.time_zone_name)
349+ self._store.time_zone = tz.gettz(self.time_zone_name)
350 return self._store.time_zone
351
352
353diff --git a/lib/lp/services/worlddata/doc/vocabularies.rst b/lib/lp/services/worlddata/doc/vocabularies.rst
354index 4e4ec6e..1f698b3 100644
355--- a/lib/lp/services/worlddata/doc/vocabularies.rst
356+++ b/lib/lp/services/worlddata/doc/vocabularies.rst
357@@ -12,12 +12,10 @@ TimezoneName
358 The TimezoneName vocabulary should only contain timezone names that
359 do not raise an exception when instantiated.
360
361- >>> import pytz
362+ >>> from dateutil import tz
363 >>> timezone_vocabulary = vocabulary_registry.get(None, "TimezoneName")
364 >>> for timezone in timezone_vocabulary:
365- ... # Assign the return value of pytz.timezone() to the zone
366- ... # variable to prevent printing out the return value.
367- ... zone = pytz.timezone(timezone.value)
368+ ... _ = tz.gettz(timezone.value)
369 ...
370
371 LanguageVocabulary
372diff --git a/lib/lp/services/worlddata/vocabularies.py b/lib/lp/services/worlddata/vocabularies.py
373index bed76b1..72db756 100644
374--- a/lib/lp/services/worlddata/vocabularies.py
375+++ b/lib/lp/services/worlddata/vocabularies.py
376@@ -7,8 +7,6 @@ __all__ = [
377 "TimezoneNameVocabulary",
378 ]
379
380-import pytz
381-import six
382 from zope.component import getUtility
383 from zope.interface import alsoProvides
384 from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary
385@@ -19,14 +17,57 @@ from lp.services.worlddata.interfaces.timezone import ITimezoneNameVocabulary
386 from lp.services.worlddata.model.country import Country
387 from lp.services.worlddata.model.language import Language
388
389-# create a sorted list of the common time zone names, with UTC at the start
390-_values = sorted(six.ensure_text(tz) for tz in pytz.common_timezones)
391-_values.remove("UTC")
392-_values.insert(0, "UTC")
393
394-_timezone_vocab = SimpleVocabulary.fromValues(_values)
395+def _common_timezones():
396+ """A list of useful, current time zone names.
397+
398+ This is inspired by `pytz.common_timezones`, which seems to be
399+ approximately the list supported by `tzdata` with the additions of some
400+ Canada- and US-specific names. Since we're aiming for current rather
401+ than historical zone names, `zone1970.tab` seems appropriate.
402+ """
403+ zones = set()
404+ with open("/usr/share/zoneinfo/zone.tab") as zone_tab:
405+ for line in zone_tab:
406+ if line.startswith("#"):
407+ continue
408+ zones.add(line.rstrip("\n").split("\t")[2])
409+ # Backward-compatible US zone names, still in common use.
410+ zones.update(
411+ {
412+ "US/Alaska",
413+ "US/Arizona",
414+ "US/Central",
415+ "US/Eastern",
416+ "US/Hawaii",
417+ "US/Mountain",
418+ "US/Pacific",
419+ }
420+ )
421+ # Backward-compatible Canadian zone names; see
422+ # https://bugs.launchpad.net/pytz/+bug/506341.
423+ zones.update(
424+ {
425+ "Canada/Atlantic",
426+ "Canada/Central",
427+ "Canada/Eastern",
428+ "Canada/Mountain",
429+ "Canada/Newfoundland",
430+ "Canada/Pacific",
431+ }
432+ )
433+ # pytz has this in addition to UTC. Perhaps it's more understandable
434+ # for people not steeped in time zone lore.
435+ zones.add("GMT")
436+
437+ # UTC comes first, then everything else.
438+ yield "UTC"
439+ zones.discard("UTC")
440+ yield from sorted(zones)
441+
442+
443+_timezone_vocab = SimpleVocabulary.fromValues(_common_timezones())
444 alsoProvides(_timezone_vocab, ITimezoneNameVocabulary)
445-del _values
446
447
448 def TimezoneNameVocabulary(context=None):
449diff --git a/lib/lp/snappy/tests/test_snapbuildbehaviour.py b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
450index 28ffc4b..6508601 100644
451--- a/lib/lp/snappy/tests/test_snapbuildbehaviour.py
452+++ b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
453@@ -12,8 +12,8 @@ from textwrap import dedent
454 from urllib.parse import urlsplit
455
456 import fixtures
457-import pytz
458 from aptsources.sourceslist import SourceEntry
459+from dateutil import tz
460 from pymacaroons import Macaroon
461 from testtools import ExpectedException
462 from testtools.matchers import (
463@@ -101,8 +101,8 @@ class FormatAsRfc3339TestCase(TestCase):
464 self.assertEqual("2016-01-01T00:00:00Z", format_as_rfc3339(ts))
465
466 def test_tzinfo_is_ignored(self):
467- tz = datetime(2016, 1, 1, tzinfo=pytz.timezone("US/Eastern"))
468- self.assertEqual("2016-01-01T00:00:00Z", format_as_rfc3339(tz))
469+ time_zone = datetime(2016, 1, 1, tzinfo=tz.gettz("US/Eastern"))
470+ self.assertEqual("2016-01-01T00:00:00Z", format_as_rfc3339(time_zone))
471
472
473 class TestSnapBuildBehaviourBase(TestCaseWithFactory):
474diff --git a/requirements/types.txt b/requirements/types.txt
475index 221aad3..c42c102 100644
476--- a/requirements/types.txt
477+++ b/requirements/types.txt
478@@ -4,7 +4,7 @@ types-beautifulsoup4==4.9.0
479 types-bleach==3.3.1
480 types-oauthlib==3.1.0
481 types-psycopg2==2.9.21.4
482-types-pytz==0.1.0
483+types-python-dateutil==2.8.1
484 types-requests==0.1.13
485 types-six==0.1.9
486 types-urllib3==1.26.25.4
487diff --git a/setup.cfg b/setup.cfg
488index ef37294..b2a0f29 100644
489--- a/setup.cfg
490+++ b/setup.cfg
491@@ -79,12 +79,12 @@ install_requires =
492 pymemcache
493 pyparsing
494 pystache
495+ python-dateutil
496 python-debian
497 python-keystoneclient
498 python-openid2
499 python-subunit
500 python-swiftclient
501- pytz
502 PyYAML
503 rabbitfixture
504 requests

Subscribers

People subscribed via source and target branches

to status/vote changes: