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 <auvipy@gmail.com>
This commit is contained in:
Maxwell Muoto 2023-04-08 04:16:00 -05:00 committed by GitHub
parent 4842ad1b6a
commit 62abf6ac1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 72 additions and 14 deletions

View File

@ -35,6 +35,7 @@ from rest_framework.exceptions import ErrorDetail, ValidationError
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.utils import html, humanize_datetime, json, representation from rest_framework.utils import html, humanize_datetime, json, representation
from rest_framework.utils.formatting import lazy_format from rest_framework.utils.formatting import lazy_format
from rest_framework.utils.timezone import valid_datetime
from rest_framework.validators import ProhibitSurrogateCharactersValidator from rest_framework.validators import ProhibitSurrogateCharactersValidator
@ -1154,7 +1155,12 @@ class DateTimeField(Field):
except OverflowError: except OverflowError:
self.fail('overflow') self.fail('overflow')
try: try:
return timezone.make_aware(value, field_timezone) dt = timezone.make_aware(value, field_timezone)
# When the resulting datetime is a ZoneInfo instance, it won't necessarily
# throw given an invalid datetime, so we need to specifically check.
if not valid_datetime(dt):
self.fail('make_aware', timezone=field_timezone)
return dt
except InvalidTimeError: except InvalidTimeError:
self.fail('make_aware', timezone=field_timezone) self.fail('make_aware', timezone=field_timezone)
elif (field_timezone is None) and timezone.is_aware(value): elif (field_timezone is None) and timezone.is_aware(value):

View File

@ -0,0 +1,25 @@
from datetime import datetime, timezone, tzinfo
def datetime_exists(dt):
"""Check if a datetime exists. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html"""
# There are no non-existent times in UTC, and comparisons between
# aware time zones always compare absolute times; if a datetime is
# not equal to the same datetime represented in UTC, it is imaginary.
return dt.astimezone(timezone.utc) == dt
def datetime_ambiguous(dt: datetime):
"""Check whether a datetime is ambiguous. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html"""
# If a datetime exists and its UTC offset changes in response to
# changing `fold`, it is ambiguous in the zone specified.
return datetime_exists(dt) and (
dt.replace(fold=not dt.fold).utcoffset() != dt.utcoffset()
)
def valid_datetime(dt):
"""Returns True if the datetime is not ambiguous or imaginary, False otherwise."""
if isinstance(dt.tzinfo, tzinfo) and not datetime_ambiguous(dt):
return True
return False

View File

@ -82,7 +82,7 @@ setup(
author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) author_email='tom@tomchristie.com', # SEE NOTE BELOW (*)
packages=find_packages(exclude=['tests*']), packages=find_packages(exclude=['tests*']),
include_package_data=True, include_package_data=True,
install_requires=["django>=3.0", "pytz"], install_requires=["django>=3.0", "pytz", 'backports.zoneinfo;python_version<"3.9"'],
python_requires=">=3.6", python_requires=">=3.6",
zip_safe=False, zip_safe=False,
classifiers=[ classifiers=[

View File

@ -5,6 +5,7 @@ import re
import sys import sys
import uuid import uuid
from decimal import ROUND_DOWN, ROUND_UP, Decimal from decimal import ROUND_DOWN, ROUND_UP, Decimal
from unittest.mock import patch
import pytest import pytest
import pytz import pytz
@ -21,6 +22,11 @@ from rest_framework.fields import (
) )
from tests.models import UUIDForeignKeyTarget from tests.models import UUIDForeignKeyTarget
if sys.version_info >= (3, 9):
from zoneinfo import ZoneInfo
else:
from backports.zoneinfo import ZoneInfo
utc = datetime.timezone.utc utc = datetime.timezone.utc
# Tests for helper functions. # Tests for helper functions.
@ -651,7 +657,7 @@ class FieldValues:
""" """
Base class for testing valid and invalid input values. Base class for testing valid and invalid input values.
""" """
def test_valid_inputs(self): def test_valid_inputs(self, *args):
""" """
Ensure that valid values return the expected validated data. Ensure that valid values return the expected validated data.
""" """
@ -659,7 +665,7 @@ class FieldValues:
assert self.field.run_validation(input_value) == expected_output, \ assert self.field.run_validation(input_value) == expected_output, \
'input value: {}'.format(repr(input_value)) 'input value: {}'.format(repr(input_value))
def test_invalid_inputs(self): def test_invalid_inputs(self, *args):
""" """
Ensure that invalid values raise the expected validation error. Ensure that invalid values raise the expected validation error.
""" """
@ -669,7 +675,7 @@ class FieldValues:
assert exc_info.value.detail == expected_failure, \ assert exc_info.value.detail == expected_failure, \
'input value: {}'.format(repr(input_value)) 'input value: {}'.format(repr(input_value))
def test_outputs(self): def test_outputs(self, *args):
for output_value, expected_output in get_items(self.outputs): for output_value, expected_output in get_items(self.outputs):
assert self.field.to_representation(output_value) == expected_output, \ assert self.field.to_representation(output_value) == expected_output, \
'output value: {}'.format(repr(output_value)) 'output value: {}'.format(repr(output_value))
@ -1505,12 +1511,12 @@ class TestTZWithDateTimeField(FieldValues):
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):
# use class setup method, as class-level attribute will still be evaluated even if test is skipped # use class setup method, as class-level attribute will still be evaluated even if test is skipped
kolkata = pytz.timezone('Asia/Kolkata') kolkata = ZoneInfo('Asia/Kolkata')
cls.valid_inputs = { cls.valid_inputs = {
'2016-12-19T10:00:00': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), '2016-12-19T10:00:00': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
'2016-12-19T10:00:00+05:30': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), '2016-12-19T10:00:00+05:30': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
datetime.datetime(2016, 12, 19, 10): kolkata.localize(datetime.datetime(2016, 12, 19, 10)), datetime.datetime(2016, 12, 19, 10): datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata),
} }
cls.invalid_inputs = {} cls.invalid_inputs = {}
cls.outputs = { cls.outputs = {
@ -1529,7 +1535,7 @@ class TestDefaultTZDateTimeField(TestCase):
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):
cls.field = serializers.DateTimeField() cls.field = serializers.DateTimeField()
cls.kolkata = pytz.timezone('Asia/Kolkata') cls.kolkata = ZoneInfo('Asia/Kolkata')
def assertUTC(self, tzinfo): def assertUTC(self, tzinfo):
""" """
@ -1551,18 +1557,17 @@ class TestDefaultTZDateTimeField(TestCase):
self.assertUTC(self.field.default_timezone()) self.assertUTC(self.field.default_timezone())
@pytest.mark.skipif(pytz is None, reason='pytz not installed')
@override_settings(TIME_ZONE='UTC', USE_TZ=True) @override_settings(TIME_ZONE='UTC', USE_TZ=True)
class TestCustomTimezoneForDateTimeField(TestCase): class TestCustomTimezoneForDateTimeField(TestCase):
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):
cls.kolkata = pytz.timezone('Asia/Kolkata') cls.kolkata = ZoneInfo('Asia/Kolkata')
cls.date_format = '%d/%m/%Y %H:%M' cls.date_format = '%d/%m/%Y %H:%M'
def test_should_render_date_time_in_default_timezone(self): def test_should_render_date_time_in_default_timezone(self):
field = serializers.DateTimeField(default_timezone=self.kolkata, format=self.date_format) field = serializers.DateTimeField(default_timezone=self.kolkata, format=self.date_format)
dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=pytz.utc) dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=ZoneInfo("UTC"))
with override(self.kolkata): with override(self.kolkata):
rendered_date = field.to_representation(dt) rendered_date = field.to_representation(dt)
@ -1572,7 +1577,8 @@ class TestCustomTimezoneForDateTimeField(TestCase):
assert rendered_date == rendered_date_in_timezone assert rendered_date == rendered_date_in_timezone
class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues): @pytest.mark.skipif(pytz is None, reason="As Django 4.0 has deprecated pytz, this test should eventually be able to get removed.")
class TestPytzNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
""" """
Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST. Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST.
Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and
@ -1596,6 +1602,27 @@ class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
field = serializers.DateTimeField(default_timezone=MockTimezone()) field = serializers.DateTimeField(default_timezone=MockTimezone())
@patch('rest_framework.utils.timezone.datetime_ambiguous', return_value=True)
class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues):
"""
Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST.
Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and
from 2017-11-05T02:00:00 to 2017-11-05T01:00:00 in 2017.
"""
valid_inputs = {}
invalid_inputs = {
'2017-03-12T02:30:00': ['Invalid datetime for the timezone "America/New_York".'],
'2017-11-05T01:30:00': ['Invalid datetime for the timezone "America/New_York".']
}
outputs = {}
class MockZoneInfoTimezone(datetime.tzinfo):
def __str__(self):
return 'America/New_York'
field = serializers.DateTimeField(default_timezone=MockZoneInfoTimezone())
class TestTimeField(FieldValues): class TestTimeField(FieldValues):
""" """
Valid and invalid values for `TimeField`. Valid and invalid values for `TimeField`.