Skip to content

Commit 14216ed

Browse files
authored
Implement zoneinfo support and make pytz optional (#940)
1 parent 53637dd commit 14216ed

File tree

15 files changed

+340
-211
lines changed

15 files changed

+340
-211
lines changed

CHANGES.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Babel Changelog
22
===============
33

4+
Unreleased
5+
----------
6+
7+
* Use `zoneinfo` timezone resolving on python 3.9+, while keeping pytz support
8+
for lower versions
9+
10+
411
Version 2.11.0
512
--------------
613

babel/dates.py

Lines changed: 108 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,24 @@
1919

2020
import re
2121
import warnings
22+
from typing import TYPE_CHECKING, SupportsInt
23+
24+
try:
25+
import pytz
26+
except ModuleNotFoundError:
27+
pytz = None
28+
import zoneinfo
29+
2230
from bisect import bisect_right
2331
from collections.abc import Iterable
2432
from datetime import date, datetime, time, timedelta, tzinfo
25-
from typing import TYPE_CHECKING, SupportsInt
26-
27-
import pytz as _pytz
2833

34+
from babel import localtime
2935
from babel.core import Locale, default_locale, get_global
3036
from babel.localedata import LocaleDataDict
31-
from babel.util import LOCALTZ, UTC
3237

3338
if TYPE_CHECKING:
3439
from typing_extensions import Literal, TypeAlias
35-
3640
_Instant: TypeAlias = date | time | float | None
3741
_PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
3842
_Context: TypeAlias = Literal['format', 'stand-alone']
@@ -48,6 +52,12 @@
4852
NO_INHERITANCE_MARKER = u'\u2205\u2205\u2205'
4953

5054

55+
if pytz:
56+
UTC = pytz.utc
57+
else:
58+
UTC = zoneinfo.ZoneInfo('UTC')
59+
LOCALTZ = localtime.LOCALTZ
60+
5161
LC_TIME = default_locale('LC_TIME')
5262

5363
# Aliases for use in scopes where the modules are shadowed by local variables
@@ -56,6 +66,24 @@
5666
time_ = time
5767

5868

69+
def _localize(tz: tzinfo, dt: datetime) -> datetime:
70+
# Support localizing with both pytz and zoneinfo tzinfos
71+
# nothing to do
72+
if dt.tzinfo is tz:
73+
return dt
74+
75+
if hasattr(tz, 'localize'): # pytz
76+
return tz.localize(dt)
77+
78+
if dt.tzinfo is None:
79+
# convert naive to localized
80+
return dt.replace(tzinfo=tz)
81+
82+
# convert timezones
83+
return dt.astimezone(tz)
84+
85+
86+
5987
def _get_dt_and_tzinfo(dt_or_tzinfo: _DtOrTzinfo) -> tuple[datetime_ | None, tzinfo]:
6088
"""
6189
Parse a `dt_or_tzinfo` value into a datetime and a tzinfo.
@@ -150,15 +178,15 @@ def _ensure_datetime_tzinfo(datetime: datetime_, tzinfo: tzinfo | None = None) -
150178
151179
If a tzinfo is passed in, the datetime is normalized to that timezone.
152180
153-
>>> _ensure_datetime_tzinfo(datetime(2015, 1, 1)).tzinfo.zone
181+
>>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1)))
154182
'UTC'
155183
156184
>>> tz = get_timezone("Europe/Stockholm")
157185
>>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour
158186
14
159187
160188
:param datetime: Datetime to augment.
161-
:param tzinfo: Optional tznfo.
189+
:param tzinfo: optional tzinfo
162190
:return: datetime with tzinfo
163191
:rtype: datetime
164192
"""
@@ -184,8 +212,10 @@ def _get_time(time: time | datetime | None, tzinfo: tzinfo | None = None) -> tim
184212
time = datetime.utcnow()
185213
elif isinstance(time, (int, float)):
186214
time = datetime.utcfromtimestamp(time)
215+
187216
if time.tzinfo is None:
188217
time = time.replace(tzinfo=UTC)
218+
189219
if isinstance(time, datetime):
190220
if tzinfo is not None:
191221
time = time.astimezone(tzinfo)
@@ -197,28 +227,40 @@ def _get_time(time: time | datetime | None, tzinfo: tzinfo | None = None) -> tim
197227
return time
198228

199229

200-
def get_timezone(zone: str | _pytz.BaseTzInfo | None = None) -> _pytz.BaseTzInfo:
230+
def get_timezone(zone: str | tzinfo | None = None) -> tzinfo:
201231
"""Looks up a timezone by name and returns it. The timezone object
202-
returned comes from ``pytz`` and corresponds to the `tzinfo` interface and
203-
can be used with all of the functions of Babel that operate with dates.
232+
returned comes from ``pytz`` or ``zoneinfo``, whichever is available.
233+
It corresponds to the `tzinfo` interface and can be used with all of
234+
the functions of Babel that operate with dates.
204235
205236
If a timezone is not known a :exc:`LookupError` is raised. If `zone`
206237
is ``None`` a local zone object is returned.
207238
208239
:param zone: the name of the timezone to look up. If a timezone object
209-
itself is passed in, mit's returned unchanged.
240+
itself is passed in, it's returned unchanged.
210241
"""
211242
if zone is None:
212243
return LOCALTZ
213244
if not isinstance(zone, str):
214245
return zone
215-
try:
216-
return _pytz.timezone(zone)
217-
except _pytz.UnknownTimeZoneError:
218-
raise LookupError(f"Unknown timezone {zone}")
219246

247+
exc = None
248+
if pytz:
249+
try:
250+
return pytz.timezone(zone)
251+
except pytz.UnknownTimeZoneError as exc:
252+
pass
253+
else:
254+
assert zoneinfo
255+
try:
256+
return zoneinfo.ZoneInfo(zone)
257+
except zoneinfo.ZoneInfoNotFoundError as exc:
258+
pass
259+
260+
raise LookupError(f"Unknown timezone {zone}") from exc
220261

221-
def get_next_timezone_transition(zone: _pytz.BaseTzInfo | None = None, dt: _Instant = None) -> TimezoneTransition:
262+
263+
def get_next_timezone_transition(zone: tzinfo | None = None, dt: _Instant = None) -> TimezoneTransition:
222264
"""Given a timezone it will return a :class:`TimezoneTransition` object
223265
that holds the information about the next timezone transition that's going
224266
to happen. For instance this can be used to detect when the next DST
@@ -474,7 +516,7 @@ def get_timezone_gmt(datetime: _Instant = None, width: Literal['long', 'short',
474516
>>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
475517
u'+00'
476518
>>> tz = get_timezone('America/Los_Angeles')
477-
>>> dt = tz.localize(datetime(2007, 4, 1, 15, 30))
519+
>>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30))
478520
>>> get_timezone_gmt(dt, locale='en')
479521
u'GMT-07:00'
480522
>>> get_timezone_gmt(dt, 'short', locale='en')
@@ -608,7 +650,7 @@ def get_timezone_name(dt_or_tzinfo: _DtOrTzinfo = None, width: Literal['long', '
608650
u'PST'
609651
610652
If this function gets passed only a `tzinfo` object and no concrete
611-
`datetime`, the returned display name is indenpendent of daylight savings
653+
`datetime`, the returned display name is independent of daylight savings
612654
time. This can be used for example for selecting timezones, or to set the
613655
time of events that recur across DST changes:
614656
@@ -755,12 +797,11 @@ def format_datetime(datetime: _Instant = None, format: _PredefinedTimeFormat | s
755797
>>> format_datetime(dt, locale='en_US')
756798
u'Apr 1, 2007, 3:30:00 PM'
757799
758-
For any pattern requiring the display of the time-zone, the third-party
759-
``pytz`` package is needed to explicitly specify the time-zone:
800+
For any pattern requiring the display of the timezone:
760801
761802
>>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
762803
... locale='fr_FR')
763-
u'dimanche 1 avril 2007 \xe0 17:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale'
804+
'dimanche 1 avril 2007 à 17:30:00 heure d’été d’Europe centrale'
764805
>>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
765806
... tzinfo=get_timezone('US/Eastern'), locale='en')
766807
u'2007.04.01 AD at 11:30:00 EDT'
@@ -806,9 +847,9 @@ def format_time(time: time | datetime | float | None = None, format: _Predefined
806847
807848
>>> t = datetime(2007, 4, 1, 15, 30)
808849
>>> tzinfo = get_timezone('Europe/Paris')
809-
>>> t = tzinfo.localize(t)
850+
>>> t = _localize(tzinfo, t)
810851
>>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
811-
u'15:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale'
852+
'15:30:00 heure d’été d’Europe centrale'
812853
>>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
813854
... locale='en')
814855
u"09 o'clock AM, Eastern Daylight Time"
@@ -841,12 +882,17 @@ def format_time(time: time | datetime | float | None = None, format: _Predefined
841882
:param tzinfo: the time-zone to apply to the time for display
842883
:param locale: a `Locale` object or a locale identifier
843884
"""
885+
886+
# get reference date for if we need to find the right timezone variant
887+
# in the pattern
888+
ref_date = time.date() if isinstance(time, datetime) else None
889+
844890
time = _get_time(time, tzinfo)
845891

846892
locale = Locale.parse(locale)
847893
if format in ('full', 'long', 'medium', 'short'):
848894
format = get_time_format(format, locale=locale)
849-
return parse_pattern(format).apply(time, locale)
895+
return parse_pattern(format).apply(time, locale, reference_date=ref_date)
850896

851897

852898
def format_skeleton(skeleton: str, datetime: _Instant = None, tzinfo: tzinfo | None = None,
@@ -1124,7 +1170,7 @@ def format_interval(start: _Instant, end: _Instant, skeleton: str | None = None,
11241170
return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
11251171

11261172

1127-
def get_period_id(time: _Instant, tzinfo: _pytz.BaseTzInfo | None = None, type: Literal['selection'] | None = None,
1173+
def get_period_id(time: _Instant, tzinfo: tzinfo | None = None, type: Literal['selection'] | None = None,
11281174
locale: Locale | str | None = LC_TIME) -> str:
11291175
"""
11301176
Get the day period ID for a given time.
@@ -1327,18 +1373,29 @@ def __mod__(self, other: DateTimeFormat) -> str:
13271373
return NotImplemented
13281374
return self.format % other
13291375

1330-
def apply(self, datetime: date | time, locale: Locale | str | None) -> str:
1331-
return self % DateTimeFormat(datetime, locale)
1376+
def apply(
1377+
self,
1378+
datetime: date | time,
1379+
locale: Locale | str | None,
1380+
reference_date: date | None = None
1381+
) -> str:
1382+
return self % DateTimeFormat(datetime, locale, reference_date)
13321383

13331384

13341385
class DateTimeFormat:
13351386

1336-
def __init__(self, value: date | time, locale: Locale | str):
1387+
def __init__(
1388+
self,
1389+
value: date | time,
1390+
locale: Locale | str,
1391+
reference_date: date | None = None
1392+
):
13371393
assert isinstance(value, (date, datetime, time))
13381394
if isinstance(value, (datetime, time)) and value.tzinfo is None:
13391395
value = value.replace(tzinfo=UTC)
13401396
self.value = value
13411397
self.locale = Locale.parse(locale)
1398+
self.reference_date = reference_date
13421399

13431400
def __getitem__(self, name: str) -> str:
13441401
char = name[0]
@@ -1558,46 +1615,54 @@ def format_milliseconds_in_day(self, num):
15581615

15591616
def format_timezone(self, char: str, num: int) -> str:
15601617
width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]
1618+
1619+
# It could be that we only receive a time to format, but also have a
1620+
# reference date which is important to distinguish between timezone
1621+
# variants (summer/standard time)
1622+
value = self.value
1623+
if self.reference_date:
1624+
value = datetime.combine(self.reference_date, self.value)
1625+
15611626
if char == 'z':
1562-
return get_timezone_name(self.value, width, locale=self.locale)
1627+
return get_timezone_name(value, width, locale=self.locale)
15631628
elif char == 'Z':
15641629
if num == 5:
1565-
return get_timezone_gmt(self.value, width, locale=self.locale, return_z=True)
1566-
return get_timezone_gmt(self.value, width, locale=self.locale)
1630+
return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
1631+
return get_timezone_gmt(value, width, locale=self.locale)
15671632
elif char == 'O':
15681633
if num == 4:
1569-
return get_timezone_gmt(self.value, width, locale=self.locale)
1634+
return get_timezone_gmt(value, width, locale=self.locale)
15701635
# TODO: To add support for O:1
15711636
elif char == 'v':
1572-
return get_timezone_name(self.value.tzinfo, width,
1637+
return get_timezone_name(value.tzinfo, width,
15731638
locale=self.locale)
15741639
elif char == 'V':
15751640
if num == 1:
1576-
return get_timezone_name(self.value.tzinfo, width,
1641+
return get_timezone_name(value.tzinfo, width,
15771642
uncommon=True, locale=self.locale)
15781643
elif num == 2:
1579-
return get_timezone_name(self.value.tzinfo, locale=self.locale, return_zone=True)
1644+
return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
15801645
elif num == 3:
1581-
return get_timezone_location(self.value.tzinfo, locale=self.locale, return_city=True)
1582-
return get_timezone_location(self.value.tzinfo, locale=self.locale)
1646+
return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True)
1647+
return get_timezone_location(value.tzinfo, locale=self.locale)
15831648
# Included additional elif condition to add support for 'Xx' in timezone format
15841649
elif char == 'X':
15851650
if num == 1:
1586-
return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale,
1651+
return get_timezone_gmt(value, width='iso8601_short', locale=self.locale,
15871652
return_z=True)
15881653
elif num in (2, 4):
1589-
return get_timezone_gmt(self.value, width='short', locale=self.locale,
1654+
return get_timezone_gmt(value, width='short', locale=self.locale,
15901655
return_z=True)
15911656
elif num in (3, 5):
1592-
return get_timezone_gmt(self.value, width='iso8601', locale=self.locale,
1657+
return get_timezone_gmt(value, width='iso8601', locale=self.locale,
15931658
return_z=True)
15941659
elif char == 'x':
15951660
if num == 1:
1596-
return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale)
1661+
return get_timezone_gmt(value, width='iso8601_short', locale=self.locale)
15971662
elif num in (2, 4):
1598-
return get_timezone_gmt(self.value, width='short', locale=self.locale)
1663+
return get_timezone_gmt(value, width='short', locale=self.locale)
15991664
elif num in (3, 5):
1600-
return get_timezone_gmt(self.value, width='iso8601', locale=self.locale)
1665+
return get_timezone_gmt(value, width='iso8601', locale=self.locale)
16011666

16021667
def format(self, value: SupportsInt, length: int) -> str:
16031668
return '%0*d' % (length, value)

babel/localtime/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
from datetime import datetime, timedelta, tzinfo
1515
from threading import RLock
1616

17-
import pytz
18-
1917
if sys.platform == 'win32':
2018
from babel.localtime._win32 import _get_localzone
2119
else:
@@ -61,7 +59,7 @@ def _isdst(self, dt: datetime) -> bool:
6159
return tt.tm_isdst > 0
6260

6361

64-
def get_localzone() -> pytz.BaseTzInfo:
62+
def get_localzone() -> tzinfo:
6563
"""Returns the current underlying local timezone object.
6664
Generally this function does not need to be used, it's a
6765
better idea to use the :data:`LOCALTZ` singleton instead.
@@ -71,5 +69,5 @@ def get_localzone() -> pytz.BaseTzInfo:
7169

7270
try:
7371
LOCALTZ = get_localzone()
74-
except pytz.UnknownTimeZoneError:
72+
except LookupError:
7573
LOCALTZ = _FallbackLocalTimezone()

0 commit comments

Comments
 (0)