Skip to content

Commit

Permalink
Implement zoneinfo support and make pytz optional (#940)
Browse files Browse the repository at this point in the history
  • Loading branch information
ds-cbo committed Jan 11, 2023
1 parent 53637dd commit 14216ed
Show file tree
Hide file tree
Showing 15 changed files with 340 additions and 211 deletions.
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Babel Changelog
===============

Unreleased
----------

* Use `zoneinfo` timezone resolving on python 3.9+, while keeping pytz support
for lower versions


Version 2.11.0
--------------

Expand Down
151 changes: 108 additions & 43 deletions babel/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,24 @@

import re
import warnings
from typing import TYPE_CHECKING, SupportsInt

try:
import pytz
except ModuleNotFoundError:
pytz = None
import zoneinfo

from bisect import bisect_right
from collections.abc import Iterable
from datetime import date, datetime, time, timedelta, tzinfo
from typing import TYPE_CHECKING, SupportsInt

import pytz as _pytz

from babel import localtime
from babel.core import Locale, default_locale, get_global
from babel.localedata import LocaleDataDict
from babel.util import LOCALTZ, UTC

if TYPE_CHECKING:
from typing_extensions import Literal, TypeAlias

_Instant: TypeAlias = date | time | float | None
_PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
_Context: TypeAlias = Literal['format', 'stand-alone']
Expand All @@ -48,6 +52,12 @@
NO_INHERITANCE_MARKER = u'\u2205\u2205\u2205'


if pytz:
UTC = pytz.utc
else:
UTC = zoneinfo.ZoneInfo('UTC')
LOCALTZ = localtime.LOCALTZ

LC_TIME = default_locale('LC_TIME')

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


def _localize(tz: tzinfo, dt: datetime) -> datetime:
# Support localizing with both pytz and zoneinfo tzinfos
# nothing to do
if dt.tzinfo is tz:
return dt

if hasattr(tz, 'localize'): # pytz
return tz.localize(dt)

if dt.tzinfo is None:
# convert naive to localized
return dt.replace(tzinfo=tz)

# convert timezones
return dt.astimezone(tz)



def _get_dt_and_tzinfo(dt_or_tzinfo: _DtOrTzinfo) -> tuple[datetime_ | None, tzinfo]:
"""
Parse a `dt_or_tzinfo` value into a datetime and a tzinfo.
Expand Down Expand Up @@ -150,15 +178,15 @@ def _ensure_datetime_tzinfo(datetime: datetime_, tzinfo: tzinfo | None = None) -
If a tzinfo is passed in, the datetime is normalized to that timezone.
>>> _ensure_datetime_tzinfo(datetime(2015, 1, 1)).tzinfo.zone
>>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1)))
'UTC'
>>> tz = get_timezone("Europe/Stockholm")
>>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour
14
:param datetime: Datetime to augment.
:param tzinfo: Optional tznfo.
:param tzinfo: optional tzinfo
:return: datetime with tzinfo
:rtype: datetime
"""
Expand All @@ -184,8 +212,10 @@ def _get_time(time: time | datetime | None, tzinfo: tzinfo | None = None) -> tim
time = datetime.utcnow()
elif isinstance(time, (int, float)):
time = datetime.utcfromtimestamp(time)

if time.tzinfo is None:
time = time.replace(tzinfo=UTC)

if isinstance(time, datetime):
if tzinfo is not None:
time = time.astimezone(tzinfo)
Expand All @@ -197,28 +227,40 @@ def _get_time(time: time | datetime | None, tzinfo: tzinfo | None = None) -> tim
return time


def get_timezone(zone: str | _pytz.BaseTzInfo | None = None) -> _pytz.BaseTzInfo:
def get_timezone(zone: str | tzinfo | None = None) -> tzinfo:
"""Looks up a timezone by name and returns it. The timezone object
returned comes from ``pytz`` and corresponds to the `tzinfo` interface and
can be used with all of the functions of Babel that operate with dates.
returned comes from ``pytz`` or ``zoneinfo``, whichever is available.
It corresponds to the `tzinfo` interface and can be used with all of
the functions of Babel that operate with dates.
If a timezone is not known a :exc:`LookupError` is raised. If `zone`
is ``None`` a local zone object is returned.
:param zone: the name of the timezone to look up. If a timezone object
itself is passed in, mit's returned unchanged.
itself is passed in, it's returned unchanged.
"""
if zone is None:
return LOCALTZ
if not isinstance(zone, str):
return zone
try:
return _pytz.timezone(zone)
except _pytz.UnknownTimeZoneError:
raise LookupError(f"Unknown timezone {zone}")

exc = None
if pytz:
try:
return pytz.timezone(zone)
except pytz.UnknownTimeZoneError as exc:
pass
else:
assert zoneinfo
try:
return zoneinfo.ZoneInfo(zone)
except zoneinfo.ZoneInfoNotFoundError as exc:
pass

raise LookupError(f"Unknown timezone {zone}") from exc

def get_next_timezone_transition(zone: _pytz.BaseTzInfo | None = None, dt: _Instant = None) -> TimezoneTransition:

def get_next_timezone_transition(zone: tzinfo | None = None, dt: _Instant = None) -> TimezoneTransition:
"""Given a timezone it will return a :class:`TimezoneTransition` object
that holds the information about the next timezone transition that's going
to happen. For instance this can be used to detect when the next DST
Expand Down Expand Up @@ -474,7 +516,7 @@ def get_timezone_gmt(datetime: _Instant = None, width: Literal['long', 'short',
>>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
u'+00'
>>> tz = get_timezone('America/Los_Angeles')
>>> dt = tz.localize(datetime(2007, 4, 1, 15, 30))
>>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30))
>>> get_timezone_gmt(dt, locale='en')
u'GMT-07:00'
>>> get_timezone_gmt(dt, 'short', locale='en')
Expand Down Expand Up @@ -608,7 +650,7 @@ def get_timezone_name(dt_or_tzinfo: _DtOrTzinfo = None, width: Literal['long', '
u'PST'
If this function gets passed only a `tzinfo` object and no concrete
`datetime`, the returned display name is indenpendent of daylight savings
`datetime`, the returned display name is independent of daylight savings
time. This can be used for example for selecting timezones, or to set the
time of events that recur across DST changes:
Expand Down Expand Up @@ -755,12 +797,11 @@ def format_datetime(datetime: _Instant = None, format: _PredefinedTimeFormat | s
>>> format_datetime(dt, locale='en_US')
u'Apr 1, 2007, 3:30:00 PM'
For any pattern requiring the display of the time-zone, the third-party
``pytz`` package is needed to explicitly specify the time-zone:
For any pattern requiring the display of the timezone:
>>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
... locale='fr_FR')
u'dimanche 1 avril 2007 \xe0 17:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale'
'dimanche 1 avril 2007 à 17:30:00 heure d’été d’Europe centrale'
>>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
... tzinfo=get_timezone('US/Eastern'), locale='en')
u'2007.04.01 AD at 11:30:00 EDT'
Expand Down Expand Up @@ -806,9 +847,9 @@ def format_time(time: time | datetime | float | None = None, format: _Predefined
>>> t = datetime(2007, 4, 1, 15, 30)
>>> tzinfo = get_timezone('Europe/Paris')
>>> t = tzinfo.localize(t)
>>> t = _localize(tzinfo, t)
>>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
u'15:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale'
'15:30:00 heure d’été d’Europe centrale'
>>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
... locale='en')
u"09 o'clock AM, Eastern Daylight Time"
Expand Down Expand Up @@ -841,12 +882,17 @@ def format_time(time: time | datetime | float | None = None, format: _Predefined
:param tzinfo: the time-zone to apply to the time for display
:param locale: a `Locale` object or a locale identifier
"""

# get reference date for if we need to find the right timezone variant
# in the pattern
ref_date = time.date() if isinstance(time, datetime) else None

time = _get_time(time, tzinfo)

locale = Locale.parse(locale)
if format in ('full', 'long', 'medium', 'short'):
format = get_time_format(format, locale=locale)
return parse_pattern(format).apply(time, locale)
return parse_pattern(format).apply(time, locale, reference_date=ref_date)


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


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

def apply(self, datetime: date | time, locale: Locale | str | None) -> str:
return self % DateTimeFormat(datetime, locale)
def apply(
self,
datetime: date | time,
locale: Locale | str | None,
reference_date: date | None = None
) -> str:
return self % DateTimeFormat(datetime, locale, reference_date)


class DateTimeFormat:

def __init__(self, value: date | time, locale: Locale | str):
def __init__(
self,
value: date | time,
locale: Locale | str,
reference_date: date | None = None
):
assert isinstance(value, (date, datetime, time))
if isinstance(value, (datetime, time)) and value.tzinfo is None:
value = value.replace(tzinfo=UTC)
self.value = value
self.locale = Locale.parse(locale)
self.reference_date = reference_date

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

def format_timezone(self, char: str, num: int) -> str:
width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]

# It could be that we only receive a time to format, but also have a
# reference date which is important to distinguish between timezone
# variants (summer/standard time)
value = self.value
if self.reference_date:
value = datetime.combine(self.reference_date, self.value)

if char == 'z':
return get_timezone_name(self.value, width, locale=self.locale)
return get_timezone_name(value, width, locale=self.locale)
elif char == 'Z':
if num == 5:
return get_timezone_gmt(self.value, width, locale=self.locale, return_z=True)
return get_timezone_gmt(self.value, width, locale=self.locale)
return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
return get_timezone_gmt(value, width, locale=self.locale)
elif char == 'O':
if num == 4:
return get_timezone_gmt(self.value, width, locale=self.locale)
return get_timezone_gmt(value, width, locale=self.locale)
# TODO: To add support for O:1
elif char == 'v':
return get_timezone_name(self.value.tzinfo, width,
return get_timezone_name(value.tzinfo, width,
locale=self.locale)
elif char == 'V':
if num == 1:
return get_timezone_name(self.value.tzinfo, width,
return get_timezone_name(value.tzinfo, width,
uncommon=True, locale=self.locale)
elif num == 2:
return get_timezone_name(self.value.tzinfo, locale=self.locale, return_zone=True)
return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
elif num == 3:
return get_timezone_location(self.value.tzinfo, locale=self.locale, return_city=True)
return get_timezone_location(self.value.tzinfo, locale=self.locale)
return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True)
return get_timezone_location(value.tzinfo, locale=self.locale)
# Included additional elif condition to add support for 'Xx' in timezone format
elif char == 'X':
if num == 1:
return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale,
return get_timezone_gmt(value, width='iso8601_short', locale=self.locale,
return_z=True)
elif num in (2, 4):
return get_timezone_gmt(self.value, width='short', locale=self.locale,
return get_timezone_gmt(value, width='short', locale=self.locale,
return_z=True)
elif num in (3, 5):
return get_timezone_gmt(self.value, width='iso8601', locale=self.locale,
return get_timezone_gmt(value, width='iso8601', locale=self.locale,
return_z=True)
elif char == 'x':
if num == 1:
return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale)
return get_timezone_gmt(value, width='iso8601_short', locale=self.locale)
elif num in (2, 4):
return get_timezone_gmt(self.value, width='short', locale=self.locale)
return get_timezone_gmt(value, width='short', locale=self.locale)
elif num in (3, 5):
return get_timezone_gmt(self.value, width='iso8601', locale=self.locale)
return get_timezone_gmt(value, width='iso8601', locale=self.locale)

def format(self, value: SupportsInt, length: int) -> str:
return '%0*d' % (length, value)
Expand Down
6 changes: 2 additions & 4 deletions babel/localtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
from datetime import datetime, timedelta, tzinfo
from threading import RLock

import pytz

if sys.platform == 'win32':
from babel.localtime._win32 import _get_localzone
else:
Expand Down Expand Up @@ -61,7 +59,7 @@ def _isdst(self, dt: datetime) -> bool:
return tt.tm_isdst > 0


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

try:
LOCALTZ = get_localzone()
except pytz.UnknownTimeZoneError:
except LookupError:
LOCALTZ = _FallbackLocalTimezone()
Loading

0 comments on commit 14216ed

Please sign in to comment.