Skip to content

Commit 4cd7f38

Browse files
authored
Add 'zoneinfo' backend to TimezoneType (kvesteri#510)
This adds support for using the standard library's 'zoneinfo' module as a backend for the TimezoneType. This module is available on Python 3.9+. For older versions, the backports.zoneinfo module (https://github.com/pganssle/zoneinfo) is automatically used, and is thus required on these versions.
1 parent 29eb245 commit 4cd7f38

File tree

3 files changed

+59
-17
lines changed

3 files changed

+59
-17
lines changed

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def get_version():
3333
'pg8000>=1.12.4',
3434
'pytz>=2014.2',
3535
'python-dateutil>=2.6',
36+
'backports.zoneinfo;python_version<"3.9"',
3637
'pymysql',
3738
'flake8>=2.4.0',
3839
'isort>=4.2.2',

sqlalchemy_utils/types/timezone.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77

88
class TimezoneType(ScalarCoercible, types.TypeDecorator):
99
"""
10-
TimezoneType provides a way for saving timezones (from either the pytz or
11-
the dateutil package) objects into database. TimezoneType saves timezone
12-
objects as strings on the way in and converts them back to objects when
13-
querying the database.
14-
10+
TimezoneType provides a way for saving timezones objects into database.
11+
TimezoneType saves timezone objects as strings on the way in and converts
12+
them back to objects when querying the database.
1513
1614
::
1715
@@ -20,20 +18,22 @@ class TimezoneType(ScalarCoercible, types.TypeDecorator):
2018
class User(Base):
2119
__tablename__ = 'user'
2220
23-
# Pass backend='pytz' to change it to use pytz (dateutil by
24-
# default)
21+
# Pass backend='pytz' to change it to use pytz. Other values:
22+
# 'dateutil' (default), and 'zoneinfo'.
2523
timezone = sa.Column(TimezoneType(backend='pytz'))
24+
25+
:param backend: Whether to use 'dateutil', 'pytz' or 'zoneinfo' for
26+
timezones. 'zoneinfo' uses the standard library module in Python 3.9+,
27+
but requires the external 'backports.zoneinfo' package for older
28+
Python versions.
29+
2630
"""
2731

2832
impl = types.Unicode(50)
2933

3034
python_type = None
3135

3236
def __init__(self, backend='dateutil'):
33-
"""
34-
:param backend: Whether to use 'dateutil' or 'pytz' for timezones.
35-
"""
36-
3737
self.backend = backend
3838
if backend == 'dateutil':
3939
try:
@@ -65,10 +65,27 @@ def __init__(self, backend='dateutil'):
6565
"for 'TimezoneType'"
6666
)
6767

68+
elif backend == "zoneinfo":
69+
try:
70+
import zoneinfo
71+
except ImportError:
72+
try:
73+
from backports import zoneinfo
74+
except ImportError:
75+
raise ImproperlyConfigured(
76+
"'backports.zoneinfo' is required to use "
77+
"the 'zoneinfo' backend for 'TimezoneType'"
78+
"on Python version < 3.9"
79+
)
80+
81+
self.python_type = zoneinfo.ZoneInfo
82+
self._to = zoneinfo.ZoneInfo
83+
self._from = six.text_type
84+
6885
else:
6986
raise ImproperlyConfigured(
70-
"'pytz' or 'dateutil' are the backends supported for "
71-
"'TimezoneType'"
87+
"'pytz', 'dateutil' or 'zoneinfo' are the backends "
88+
"supported for 'TimezoneType'"
7289
)
7390

7491
def _coerce(self, value):

tests/types/test_timezone.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
import sqlalchemy as sa
44
from dateutil.zoneinfo import getzoneinfofile_stream, tzfile, ZoneInfoFile
55

6+
try:
7+
import zoneinfo
8+
except ImportError:
9+
from backports import zoneinfo
10+
611
from sqlalchemy_utils.types import timezone, TimezoneType
712

813

@@ -17,6 +22,9 @@ class Visitor(Base):
1722
timezone_pytz = sa.Column(
1823
timezone.TimezoneType(backend='pytz')
1924
)
25+
timezone_zoneinfo = sa.Column(
26+
timezone.TimezoneType(backend='zoneinfo')
27+
)
2028

2129
def __repr__(self):
2230
return 'Visitor(%r)' % self.id
@@ -32,7 +40,8 @@ class TestTimezoneType:
3240
def test_parameter_processing(self, session, Visitor):
3341
visitor = Visitor(
3442
timezone_dateutil=u'America/Los_Angeles',
35-
timezone_pytz=u'America/Los_Angeles'
43+
timezone_pytz=u'America/Los_Angeles',
44+
timezone_zoneinfo=u'America/Los_Angeles'
3645
)
3746

3847
session.add(visitor)
@@ -44,17 +53,21 @@ def test_parameter_processing(self, session, Visitor):
4453
visitor_pytz = session.query(Visitor).filter_by(
4554
timezone_pytz=u'America/Los_Angeles'
4655
).first()
56+
visitor_zoneinfo = session.query(Visitor).filter_by(
57+
timezone_zoneinfo=u'America/Los_Angeles'
58+
).first()
4759

4860
assert visitor_dateutil is not None
4961
assert visitor_pytz is not None
62+
assert visitor_zoneinfo is not None
5063

5164
def test_compilation(self, Visitor, session):
5265
query = sa.select([Visitor.timezone_pytz])
5366
# the type should be cacheable and not throw exception
5467
session.execute(query)
5568

5669

57-
TIMEZONE_BACKENDS = ['dateutil', 'pytz']
70+
TIMEZONE_BACKENDS = ['dateutil', 'pytz', 'zoneinfo']
5871

5972

6073
def test_can_coerce_pytz_DstTzInfo():
@@ -83,12 +96,23 @@ def test_can_coerce_string_for_dateutil_zone(zone):
8396
assert isinstance(tzcol._coerce(zone), tzfile)
8497

8598

99+
@pytest.mark.parametrize('zone', zoneinfo.available_timezones())
100+
def test_can_coerce_string_for_zoneinfo_zone(zone):
101+
tzcol = TimezoneType(backend='zoneinfo')
102+
assert str(tzcol._coerce(zone)) == zone
103+
104+
86105
@pytest.mark.parametrize('backend', TIMEZONE_BACKENDS)
87106
def test_can_coerce_and_raise_UnknownTimeZoneError_or_ValueError(backend):
88107
tzcol = TimezoneType(backend=backend)
89-
with pytest.raises((ValueError, pytz.exceptions.UnknownTimeZoneError)):
108+
exceptions = (
109+
ValueError,
110+
pytz.exceptions.UnknownTimeZoneError,
111+
zoneinfo.ZoneInfoNotFoundError
112+
)
113+
with pytest.raises(exceptions):
90114
tzcol._coerce('SolarSystem/Mars')
91-
with pytest.raises((ValueError, pytz.exceptions.UnknownTimeZoneError)):
115+
with pytest.raises(exceptions):
92116
tzcol._coerce('')
93117

94118

0 commit comments

Comments
 (0)