Skip to content

Commit 62abf6a

Browse files
max-muotoauvipy
andauthored
Use ZoneInfo as primary source of timezone data (#8924)
* Use ZoneInfo as primary source of timezone data * Update tests/test_fields.py --------- Co-authored-by: Asif Saif Uddin <[email protected]>
1 parent 4842ad1 commit 62abf6a

File tree

4 files changed

+72
-14
lines changed

4 files changed

+72
-14
lines changed

rest_framework/fields.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from rest_framework.settings import api_settings
3636
from rest_framework.utils import html, humanize_datetime, json, representation
3737
from rest_framework.utils.formatting import lazy_format
38+
from rest_framework.utils.timezone import valid_datetime
3839
from rest_framework.validators import ProhibitSurrogateCharactersValidator
3940

4041

@@ -1154,7 +1155,12 @@ def enforce_timezone(self, value):
11541155
except OverflowError:
11551156
self.fail('overflow')
11561157
try:
1157-
return timezone.make_aware(value, field_timezone)
1158+
dt = timezone.make_aware(value, field_timezone)
1159+
# When the resulting datetime is a ZoneInfo instance, it won't necessarily
1160+
# throw given an invalid datetime, so we need to specifically check.
1161+
if not valid_datetime(dt):
1162+
self.fail('make_aware', timezone=field_timezone)
1163+
return dt
11581164
except InvalidTimeError:
11591165
self.fail('make_aware', timezone=field_timezone)
11601166
elif (field_timezone is None) and timezone.is_aware(value):

rest_framework/utils/timezone.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from datetime import datetime, timezone, tzinfo
2+
3+
4+
def datetime_exists(dt):
5+
"""Check if a datetime exists. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html"""
6+
# There are no non-existent times in UTC, and comparisons between
7+
# aware time zones always compare absolute times; if a datetime is
8+
# not equal to the same datetime represented in UTC, it is imaginary.
9+
return dt.astimezone(timezone.utc) == dt
10+
11+
12+
def datetime_ambiguous(dt: datetime):
13+
"""Check whether a datetime is ambiguous. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html"""
14+
# If a datetime exists and its UTC offset changes in response to
15+
# changing `fold`, it is ambiguous in the zone specified.
16+
return datetime_exists(dt) and (
17+
dt.replace(fold=not dt.fold).utcoffset() != dt.utcoffset()
18+
)
19+
20+
21+
def valid_datetime(dt):
22+
"""Returns True if the datetime is not ambiguous or imaginary, False otherwise."""
23+
if isinstance(dt.tzinfo, tzinfo) and not datetime_ambiguous(dt):
24+
return True
25+
return False

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def get_version(package):
8282
author_email='[email protected]', # SEE NOTE BELOW (*)
8383
packages=find_packages(exclude=['tests*']),
8484
include_package_data=True,
85-
install_requires=["django>=3.0", "pytz"],
85+
install_requires=["django>=3.0", "pytz", 'backports.zoneinfo;python_version<"3.9"'],
8686
python_requires=">=3.6",
8787
zip_safe=False,
8888
classifiers=[

tests/test_fields.py

+39-12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import uuid
77
from decimal import ROUND_DOWN, ROUND_UP, Decimal
8+
from unittest.mock import patch
89

910
import pytest
1011
import pytz
@@ -21,6 +22,11 @@
2122
)
2223
from tests.models import UUIDForeignKeyTarget
2324

25+
if sys.version_info >= (3, 9):
26+
from zoneinfo import ZoneInfo
27+
else:
28+
from backports.zoneinfo import ZoneInfo
29+
2430
utc = datetime.timezone.utc
2531

2632
# Tests for helper functions.
@@ -651,15 +657,15 @@ class FieldValues:
651657
"""
652658
Base class for testing valid and invalid input values.
653659
"""
654-
def test_valid_inputs(self):
660+
def test_valid_inputs(self, *args):
655661
"""
656662
Ensure that valid values return the expected validated data.
657663
"""
658664
for input_value, expected_output in get_items(self.valid_inputs):
659665
assert self.field.run_validation(input_value) == expected_output, \
660666
'input value: {}'.format(repr(input_value))
661667

662-
def test_invalid_inputs(self):
668+
def test_invalid_inputs(self, *args):
663669
"""
664670
Ensure that invalid values raise the expected validation error.
665671
"""
@@ -669,7 +675,7 @@ def test_invalid_inputs(self):
669675
assert exc_info.value.detail == expected_failure, \
670676
'input value: {}'.format(repr(input_value))
671677

672-
def test_outputs(self):
678+
def test_outputs(self, *args):
673679
for output_value, expected_output in get_items(self.outputs):
674680
assert self.field.to_representation(output_value) == expected_output, \
675681
'output value: {}'.format(repr(output_value))
@@ -1505,12 +1511,12 @@ class TestTZWithDateTimeField(FieldValues):
15051511
@classmethod
15061512
def setup_class(cls):
15071513
# use class setup method, as class-level attribute will still be evaluated even if test is skipped
1508-
kolkata = pytz.timezone('Asia/Kolkata')
1514+
kolkata = ZoneInfo('Asia/Kolkata')
15091515

15101516
cls.valid_inputs = {
1511-
'2016-12-19T10:00:00': kolkata.localize(datetime.datetime(2016, 12, 19, 10)),
1512-
'2016-12-19T10:00:00+05:30': kolkata.localize(datetime.datetime(2016, 12, 19, 10)),
1513-
datetime.datetime(2016, 12, 19, 10): kolkata.localize(datetime.datetime(2016, 12, 19, 10)),
1517+
'2016-12-19T10:00:00': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
1518+
'2016-12-19T10:00:00+05:30': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
1519+
datetime.datetime(2016, 12, 19, 10): datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
15141520
}
15151521
cls.invalid_inputs = {}
15161522
cls.outputs = {
@@ -1529,7 +1535,7 @@ class TestDefaultTZDateTimeField(TestCase):
15291535
@classmethod
15301536
def setup_class(cls):
15311537
cls.field = serializers.DateTimeField()
1532-
cls.kolkata = pytz.timezone('Asia/Kolkata')
1538+
cls.kolkata = ZoneInfo('Asia/Kolkata')
15331539

15341540
def assertUTC(self, tzinfo):
15351541
"""
@@ -1551,18 +1557,17 @@ def test_current_timezone(self):
15511557
self.assertUTC(self.field.default_timezone())
15521558

15531559

1554-
@pytest.mark.skipif(pytz is None, reason='pytz not installed')
15551560
@override_settings(TIME_ZONE='UTC', USE_TZ=True)
15561561
class TestCustomTimezoneForDateTimeField(TestCase):
15571562

15581563
@classmethod
15591564
def setup_class(cls):
1560-
cls.kolkata = pytz.timezone('Asia/Kolkata')
1565+
cls.kolkata = ZoneInfo('Asia/Kolkata')
15611566
cls.date_format = '%d/%m/%Y %H:%M'
15621567

15631568
def test_should_render_date_time_in_default_timezone(self):
15641569
field = serializers.DateTimeField(default_timezone=self.kolkata, format=self.date_format)
1565-
dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=pytz.utc)
1570+
dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=ZoneInfo("UTC"))
15661571

15671572
with override(self.kolkata):
15681573
rendered_date = field.to_representation(dt)
@@ -1572,7 +1577,8 @@ def test_should_render_date_time_in_default_timezone(self):
15721577
assert rendered_date == rendered_date_in_timezone
15731578

15741579

1575-
class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
1580+
@pytest.mark.skipif(pytz is None, reason="As Django 4.0 has deprecated pytz, this test should eventually be able to get removed.")
1581+
class TestPytzNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
15761582
"""
15771583
Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST.
15781584
Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and
@@ -1596,6 +1602,27 @@ def __str__(self):
15961602
field = serializers.DateTimeField(default_timezone=MockTimezone())
15971603

15981604

1605+
@patch('rest_framework.utils.timezone.datetime_ambiguous', return_value=True)
1606+
class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
1607+
"""
1608+
Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST.
1609+
Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and
1610+
from 2017-11-05T02:00:00 to 2017-11-05T01:00:00 in 2017.
1611+
"""
1612+
valid_inputs = {}
1613+
invalid_inputs = {
1614+
'2017-03-12T02:30:00': ['Invalid datetime for the timezone "America/New_York".'],
1615+
'2017-11-05T01:30:00': ['Invalid datetime for the timezone "America/New_York".']
1616+
}
1617+
outputs = {}
1618+
1619+
class MockZoneInfoTimezone(datetime.tzinfo):
1620+
def __str__(self):
1621+
return 'America/New_York'
1622+
1623+
field = serializers.DateTimeField(default_timezone=MockZoneInfoTimezone())
1624+
1625+
15991626
class TestTimeField(FieldValues):
16001627
"""
16011628
Valid and invalid values for `TimeField`.

0 commit comments

Comments
 (0)