From 89a437083d9ab9c420314175e0acff839ec5c814 Mon Sep 17 00:00:00 2001 From: Maxwell Muoto <41130755+max-muoto@users.noreply.github.com> Date: Sun, 2 Apr 2023 14:03:46 -0500 Subject: [PATCH 1/2] Use ZoneInfo as primary source of timezone data --- 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 613bd325a6..e41b56fb01 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -35,6 +35,7 @@ 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 @@ def enforce_timezone(self, value): 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 0000000000..3257c8e274 --- /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 9a5b272f3b..d9002fdb91 100755 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ def get_version(package): 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 56e2a45bad..397691cb3c 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -5,6 +5,7 @@ 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 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 @@ def test_valid_inputs(self): 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 @@ def test_invalid_inputs(self): 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 @@ def test_current_timezone(self): 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 @@ def test_should_render_date_time_in_default_timezone(self): assert rendered_date == rendered_date_in_timezone -class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues): +@pytest.mark.skipif(pytz is None, reason="As Django 5.0 is deprecating 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 @@ def __str__(self): 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`. From 2b6fe08297f2ed0263a170ab0d0970adf01e5e35 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Sat, 8 Apr 2023 15:06:50 +0600 Subject: [PATCH 2/2] Update tests/test_fields.py --- tests/test_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 397691cb3c..5804d7b3b3 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1577,7 +1577,7 @@ def test_should_render_date_time_in_default_timezone(self): assert rendered_date == rendered_date_in_timezone -@pytest.mark.skipif(pytz is None, reason="As Django 5.0 is deprecating pytz, this test should eventually be able to get removed.") +@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. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy