From 62abf6ac1f20b48809c55f92d086d5f06d4c6c55 Mon Sep 17 00:00:00 2001 From: Maxwell Muoto Date: Sat, 8 Apr 2023 04:16:00 -0500 Subject: [PATCH] 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 --- rest_framework/fields.py | 8 ++++- rest_framework/utils/timezone.py | 25 ++++++++++++++++ setup.py | 2 +- tests/test_fields.py | 51 ++++++++++++++++++++++++-------- 4 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 rest_framework/utils/timezone.py diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 613bd325a..e41b56fb0 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -35,6 +35,7 @@ from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.settings import api_settings from rest_framework.utils import html, humanize_datetime, json, representation from rest_framework.utils.formatting import lazy_format +from rest_framework.utils.timezone import valid_datetime from rest_framework.validators import ProhibitSurrogateCharactersValidator @@ -1154,7 +1155,12 @@ class DateTimeField(Field): except OverflowError: self.fail('overflow') 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: self.fail('make_aware', timezone=field_timezone) elif (field_timezone is None) and timezone.is_aware(value): diff --git a/rest_framework/utils/timezone.py b/rest_framework/utils/timezone.py new file mode 100644 index 000000000..3257c8e27 --- /dev/null +++ b/rest_framework/utils/timezone.py @@ -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 diff --git a/setup.py b/setup.py index 9a5b272f3..d9002fdb9 100755 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=find_packages(exclude=['tests*']), 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", zip_safe=False, classifiers=[ diff --git a/tests/test_fields.py b/tests/test_fields.py index 56e2a45ba..5804d7b3b 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -5,6 +5,7 @@ import re import sys import uuid from decimal import ROUND_DOWN, ROUND_UP, Decimal +from unittest.mock import patch import pytest import pytz @@ -21,6 +22,11 @@ from rest_framework.fields import ( ) 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 # Tests for helper functions. @@ -651,7 +657,7 @@ class FieldValues: """ 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. """ @@ -659,7 +665,7 @@ class FieldValues: assert self.field.run_validation(input_value) == expected_output, \ '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. """ @@ -669,7 +675,7 @@ class FieldValues: assert exc_info.value.detail == expected_failure, \ '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): assert self.field.to_representation(output_value) == expected_output, \ 'output value: {}'.format(repr(output_value)) @@ -1505,12 +1511,12 @@ class TestTZWithDateTimeField(FieldValues): @classmethod def setup_class(cls): # 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 = { - '2016-12-19T10:00:00': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), - '2016-12-19T10:00:00+05:30': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), - datetime.datetime(2016, 12, 19, 10): 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': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata), + datetime.datetime(2016, 12, 19, 10): datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata), } cls.invalid_inputs = {} cls.outputs = { @@ -1529,7 +1535,7 @@ class TestDefaultTZDateTimeField(TestCase): @classmethod def setup_class(cls): cls.field = serializers.DateTimeField() - cls.kolkata = pytz.timezone('Asia/Kolkata') + cls.kolkata = ZoneInfo('Asia/Kolkata') def assertUTC(self, tzinfo): """ @@ -1551,18 +1557,17 @@ class TestDefaultTZDateTimeField(TestCase): self.assertUTC(self.field.default_timezone()) -@pytest.mark.skipif(pytz is None, reason='pytz not installed') @override_settings(TIME_ZONE='UTC', USE_TZ=True) class TestCustomTimezoneForDateTimeField(TestCase): @classmethod def setup_class(cls): - cls.kolkata = pytz.timezone('Asia/Kolkata') + cls.kolkata = ZoneInfo('Asia/Kolkata') cls.date_format = '%d/%m/%Y %H:%M' def test_should_render_date_time_in_default_timezone(self): 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): rendered_date = field.to_representation(dt) @@ -1572,7 +1577,8 @@ class TestCustomTimezoneForDateTimeField(TestCase): 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. 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()) +@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): """ Valid and invalid values for `TimeField`.