From 10d126198434810529e0220b0c6896ed64ca0e88 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 19 Jan 2021 11:16:01 +0100 Subject: [PATCH] Refs #32365 -- Allowed use of non-pytz timezone implementations. --- django/forms/utils.py | 5 + django/utils/dateformat.py | 40 +- django/utils/timezone.py | 26 +- docs/ref/utils.txt | 31 +- docs/releases/3.2.txt | 3 + docs/topics/i18n/timezones.txt | 14 +- tests/admin_views/tests.py | 46 ++- .../datetime/test_extract_trunc.py | 377 ++++++++++-------- tests/requirements/py3.txt | 2 + tests/timezones/tests.py | 157 ++++---- tests/utils_tests/test_timezone.py | 82 +++- 11 files changed, 477 insertions(+), 306 deletions(-) diff --git a/django/forms/utils.py b/django/forms/utils.py index fbe79f1142b..50412f414b2 100644 --- a/django/forms/utils.py +++ b/django/forms/utils.py @@ -161,6 +161,11 @@ def from_current_timezone(value): if settings.USE_TZ and value is not None and timezone.is_naive(value): current_timezone = timezone.get_current_timezone() try: + if ( + not timezone._is_pytz_zone(current_timezone) and + timezone._datetime_ambiguous_or_imaginary(value, current_timezone) + ): + raise ValueError('Ambiguous or non-existent time.') return timezone.make_aware(value, current_timezone) except Exception as exc: raise ValidationError( diff --git a/django/utils/dateformat.py b/django/utils/dateformat.py index 9bd05a437b6..38e89c47bb5 100644 --- a/django/utils/dateformat.py +++ b/django/utils/dateformat.py @@ -20,7 +20,8 @@ from django.utils.dates import ( ) from django.utils.regex_helper import _lazy_re_compile from django.utils.timezone import ( - get_default_timezone, is_aware, is_naive, make_aware, + _datetime_ambiguous_or_imaginary, get_default_timezone, is_aware, is_naive, + make_aware, ) from django.utils.translation import gettext as _ @@ -160,15 +161,9 @@ class TimeFormat(Formatter): if not self.timezone: return "" - name = None - try: + if not _datetime_ambiguous_or_imaginary(self.data, self.timezone): name = self.timezone.tzname(self.data) - except Exception: - # pytz raises AmbiguousTimeError during the autumn DST change. - # This happens mainly when __init__ receives a naive datetime - # and sets self.timezone = get_default_timezone(). - pass - if name is None: + else: name = self.format('O') return str(name) @@ -184,16 +179,13 @@ class TimeFormat(Formatter): If timezone information is not available, return an empty string. """ - if not self.timezone: + if ( + not self.timezone or + _datetime_ambiguous_or_imaginary(self.data, self.timezone) + ): return "" - try: - offset = self.timezone.utcoffset(self.data) - except Exception: - # pytz raises AmbiguousTimeError during the autumn DST change. - # This happens mainly when __init__ receives a naive datetime - # and sets self.timezone = get_default_timezone(). - return "" + offset = self.timezone.utcoffset(self.data) # `offset` is a datetime.timedelta. For negative values (to the west of # UTC) only days can be negative (days=-1) and seconds are always @@ -232,16 +224,12 @@ class DateFormat(TimeFormat): def I(self): # NOQA: E743, E741 "'1' if Daylight Savings Time, '0' otherwise." - try: - if self.timezone and self.timezone.dst(self.data): - return '1' - else: - return '0' - except Exception: - # pytz raises AmbiguousTimeError during the autumn DST change. - # This happens mainly when __init__ receives a naive datetime - # and sets self.timezone = get_default_timezone(). + if ( + not self.timezone or + _datetime_ambiguous_or_imaginary(self.data, self.timezone) + ): return '' + return '1' if self.timezone.dst(self.data) else '0' def j(self): "Day of the month without leading zeros; i.e. '1' to '31'" diff --git a/django/utils/timezone.py b/django/utils/timezone.py index a87ec5fc33c..cf22ec34d08 100644 --- a/django/utils/timezone.py +++ b/django/utils/timezone.py @@ -24,6 +24,11 @@ __all__ = [ # UTC time zone as a tzinfo instance. utc = pytz.utc +_PYTZ_BASE_CLASSES = (pytz.tzinfo.BaseTzInfo, pytz._FixedOffset) +# In releases prior to 2018.4, pytz.UTC was not a subclass of BaseTzInfo +if not isinstance(pytz.UTC, pytz._FixedOffset): + _PYTZ_BASE_CLASSES = _PYTZ_BASE_CLASSES + (type(pytz.UTC),) + def get_fixed_timezone(offset): """Return a tzinfo instance with a fixed offset from UTC.""" @@ -68,7 +73,7 @@ def get_current_timezone_name(): def _get_timezone_name(timezone): """Return the name of ``timezone``.""" - return timezone.tzname(None) + return str(timezone) # Timezone selection functions. @@ -229,7 +234,7 @@ def make_aware(value, timezone=None, is_dst=None): """Make a naive datetime.datetime in a given time zone aware.""" if timezone is None: timezone = get_current_timezone() - if hasattr(timezone, 'localize'): + if _is_pytz_zone(timezone): # This method is available for pytz time zones. return timezone.localize(value, is_dst=is_dst) else: @@ -249,3 +254,20 @@ def make_naive(value, timezone=None): if is_naive(value): raise ValueError("make_naive() cannot be applied to a naive datetime") return value.astimezone(timezone).replace(tzinfo=None) + + +def _is_pytz_zone(tz): + """Checks if a zone is a pytz zone.""" + return isinstance(tz, _PYTZ_BASE_CLASSES) + + +def _datetime_ambiguous_or_imaginary(dt, tz): + if _is_pytz_zone(tz): + try: + tz.utcoffset(dt) + except (pytz.AmbiguousTimeError, pytz.NonExistentTimeError): + return True + else: + return False + + return tz.utcoffset(dt.replace(fold=not dt.fold)) != tz.utcoffset(dt) diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 52c5101e9fc..843d6ab4de2 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -943,21 +943,24 @@ appropriate entities. :class:`~datetime.datetime`. If ``timezone`` is set to ``None``, it defaults to the :ref:`current time zone `. - The ``pytz.AmbiguousTimeError`` exception is raised if you try to make - ``value`` aware during a DST transition where the same time occurs twice - (when reverting from DST). Setting ``is_dst`` to ``True`` or ``False`` will - avoid the exception by choosing if the time is pre-transition or - post-transition respectively. + When using ``pytz``, the ``pytz.AmbiguousTimeError`` exception is raised if + you try to make ``value`` aware during a DST transition where the same time + occurs twice (when reverting from DST). Setting ``is_dst`` to ``True`` or + ``False`` will avoid the exception by choosing if the time is + pre-transition or post-transition respectively. - The ``pytz.NonExistentTimeError`` exception is raised if you try to make - ``value`` aware during a DST transition such that the time never occurred. - For example, if the 2:00 hour is skipped during a DST transition, trying to - make 2:30 aware in that time zone will raise an exception. To avoid that - you can use ``is_dst`` to specify how ``make_aware()`` should interpret - such a nonexistent time. If ``is_dst=True`` then the above time would be - interpreted as 2:30 DST time (equivalent to 1:30 local time). Conversely, - if ``is_dst=False`` the time would be interpreted as 2:30 standard time - (equivalent to 3:30 local time). + When using ``pytz``, the ``pytz.NonExistentTimeError`` exception is raised + if you try to make ``value`` aware during a DST transition such that the + time never occurred. For example, if the 2:00 hour is skipped during a DST + transition, trying to make 2:30 aware in that time zone will raise an + exception. To avoid that you can use ``is_dst`` to specify how + ``make_aware()`` should interpret such a nonexistent time. If + ``is_dst=True`` then the above time would be interpreted as 2:30 DST time + (equivalent to 1:30 local time). Conversely, if ``is_dst=False`` the time + would be interpreted as 2:30 standard time (equivalent to 3:30 local time). + + The ``is_dst`` parameter has no effect when using non-``pytz`` timezone + implementations. .. function:: make_naive(value, timezone=None) diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 2182618a7b9..d3d3f44b8e2 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -657,6 +657,9 @@ MySQL 5.7 and higher. Miscellaneous ------------- +* Django now supports non-``pytz`` time zones, such as Python 3.9+'s + :mod:`zoneinfo` module and its backport. + * The undocumented ``SpatiaLiteOperations.proj4_version()`` method is renamed to ``proj_version()``. diff --git a/docs/topics/i18n/timezones.txt b/docs/topics/i18n/timezones.txt index 367835a2474..ab8902b0cd1 100644 --- a/docs/topics/i18n/timezones.txt +++ b/docs/topics/i18n/timezones.txt @@ -26,8 +26,15 @@ to this problem is to use UTC in the code and use local time only when interacting with end users. Time zone support is disabled by default. To enable it, set :setting:`USE_TZ = -True ` in your settings file. Time zone support uses pytz_, which is -installed when you install Django. +True ` in your settings file. By default, time zone support uses pytz_, +which is installed when you install Django; Django also supports the use of +other time zone implementations like :mod:`zoneinfo` by passing +:class:`~datetime.tzinfo` objects directly to functions in +:mod:`django.utils.timezone`. + +.. versionchanged:: 3.2 + + Support for non-``pytz`` timezone implementations was added. .. note:: @@ -680,7 +687,8 @@ Usage pytz_ provides helpers_, including a list of current time zones and a list of all available time zones -- some of which are only of historical - interest. + interest. :mod:`zoneinfo` also provides similar functionality via + :func:`zoneinfo.available_timezones`. .. _pytz: http://pytz.sourceforge.net/ .. _more examples: http://pytz.sourceforge.net/#example-usage diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 0b2415cdb84..8cb3fda9668 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -7,6 +7,14 @@ from urllib.parse import parse_qsl, urljoin, urlparse import pytz +try: + import zoneinfo +except ImportError: + try: + from backports import zoneinfo + except ImportError: + zoneinfo = None + from django.contrib import admin from django.contrib.admin import AdminSite, ModelAdmin from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME @@ -63,6 +71,14 @@ for a staff account. Note that both fields may be case-sensitive." MULTIPART_ENCTYPE = 'enctype="multipart/form-data"' +def make_aware_datetimes(dt, iana_key): + """Makes one aware datetime for each supported time zone provider.""" + yield pytz.timezone(iana_key).localize(dt, is_dst=None) + + if zoneinfo is not None: + yield dt.replace(tzinfo=zoneinfo.ZoneInfo(iana_key)) + + class AdminFieldExtractionMixin: """ Helper methods for extracting data from AdminForm. @@ -995,24 +1011,26 @@ class AdminViewBasicTest(AdminViewBasicTestCase): @override_settings(TIME_ZONE='America/Sao_Paulo', USE_TZ=True) def test_date_hierarchy_timezone_dst(self): # This datetime doesn't exist in this timezone due to DST. - date = pytz.timezone('America/Sao_Paulo').localize(datetime.datetime(2016, 10, 16, 15), is_dst=None) - q = Question.objects.create(question='Why?', expires=date) - Answer2.objects.create(question=q, answer='Because.') - response = self.client.get(reverse('admin:admin_views_answer2_changelist')) - self.assertContains(response, 'question__expires__day=16') - self.assertContains(response, 'question__expires__month=10') - self.assertContains(response, 'question__expires__year=2016') + for date in make_aware_datetimes(datetime.datetime(2016, 10, 16, 15), 'America/Sao_Paulo'): + with self.subTest(repr(date.tzinfo)): + q = Question.objects.create(question='Why?', expires=date) + Answer2.objects.create(question=q, answer='Because.') + response = self.client.get(reverse('admin:admin_views_answer2_changelist')) + self.assertContains(response, 'question__expires__day=16') + self.assertContains(response, 'question__expires__month=10') + self.assertContains(response, 'question__expires__year=2016') @override_settings(TIME_ZONE='America/Los_Angeles', USE_TZ=True) def test_date_hierarchy_local_date_differ_from_utc(self): # This datetime is 2017-01-01 in UTC. - date = pytz.timezone('America/Los_Angeles').localize(datetime.datetime(2016, 12, 31, 16)) - q = Question.objects.create(question='Why?', expires=date) - Answer2.objects.create(question=q, answer='Because.') - response = self.client.get(reverse('admin:admin_views_answer2_changelist')) - self.assertContains(response, 'question__expires__day=31') - self.assertContains(response, 'question__expires__month=12') - self.assertContains(response, 'question__expires__year=2016') + for date in make_aware_datetimes(datetime.datetime(2016, 12, 31, 16), 'America/Los_Angeles'): + with self.subTest(repr(date.tzinfo)): + q = Question.objects.create(question='Why?', expires=date) + Answer2.objects.create(question=q, answer='Because.') + response = self.client.get(reverse('admin:admin_views_answer2_changelist')) + self.assertContains(response, 'question__expires__day=31') + self.assertContains(response, 'question__expires__month=12') + self.assertContains(response, 'question__expires__year=2016') def test_sortable_by_columns_subset(self): expected_sortable_fields = ('date', 'callable_year') diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index 035900da932..258600127f9 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -2,6 +2,14 @@ from datetime import datetime, timedelta, timezone as datetime_timezone import pytz +try: + import zoneinfo +except ImportError: + try: + from backports import zoneinfo + except ImportError: + zoneinfo = None + from django.conf import settings from django.db.models import ( DateField, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, @@ -21,6 +29,10 @@ from django.utils import timezone from ..models import Author, DTModel, Fan +ZONE_CONSTRUCTORS = (pytz.timezone,) +if zoneinfo is not None: + ZONE_CONSTRUCTORS += (zoneinfo.ZoneInfo,) + def truncate_to(value, kind, tzinfo=None): # Convert to target timezone before truncation @@ -1039,7 +1051,7 @@ class DateFunctionTests(TestCase): outer = Author.objects.annotate( newest_fan_year=TruncYear(Subquery(inner, output_field=DateTimeField())) ) - tz = pytz.UTC if settings.USE_TZ else None + tz = timezone.utc if settings.USE_TZ else None self.assertSequenceEqual( outer.order_by('name').values('name', 'newest_fan_year'), [ @@ -1052,63 +1064,68 @@ class DateFunctionTests(TestCase): @override_settings(USE_TZ=True, TIME_ZONE='UTC') class DateFunctionWithTimeZoneTests(DateFunctionTests): + def get_timezones(self, key): + for constructor in ZONE_CONSTRUCTORS: + yield constructor(key) + def test_extract_func_with_timezone(self): start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321) end_datetime = datetime(2015, 6, 16, 13, 11, 27, 123) start_datetime = timezone.make_aware(start_datetime, is_dst=False) end_datetime = timezone.make_aware(end_datetime, is_dst=False) self.create_model(start_datetime, end_datetime) - melb = pytz.timezone('Australia/Melbourne') delta_tzinfo_pos = datetime_timezone(timedelta(hours=5)) delta_tzinfo_neg = datetime_timezone(timedelta(hours=-5, minutes=17)) - qs = DTModel.objects.annotate( - day=Extract('start_datetime', 'day'), - day_melb=Extract('start_datetime', 'day', tzinfo=melb), - week=Extract('start_datetime', 'week', tzinfo=melb), - isoyear=ExtractIsoYear('start_datetime', tzinfo=melb), - weekday=ExtractWeekDay('start_datetime'), - weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb), - isoweekday=ExtractIsoWeekDay('start_datetime'), - isoweekday_melb=ExtractIsoWeekDay('start_datetime', tzinfo=melb), - quarter=ExtractQuarter('start_datetime', tzinfo=melb), - hour=ExtractHour('start_datetime'), - hour_melb=ExtractHour('start_datetime', tzinfo=melb), - hour_with_delta_pos=ExtractHour('start_datetime', tzinfo=delta_tzinfo_pos), - hour_with_delta_neg=ExtractHour('start_datetime', tzinfo=delta_tzinfo_neg), - minute_with_delta_neg=ExtractMinute('start_datetime', tzinfo=delta_tzinfo_neg), - ).order_by('start_datetime') + for melb in self.get_timezones('Australia/Melbourne'): + with self.subTest(repr(melb)): + qs = DTModel.objects.annotate( + day=Extract('start_datetime', 'day'), + day_melb=Extract('start_datetime', 'day', tzinfo=melb), + week=Extract('start_datetime', 'week', tzinfo=melb), + isoyear=ExtractIsoYear('start_datetime', tzinfo=melb), + weekday=ExtractWeekDay('start_datetime'), + weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb), + isoweekday=ExtractIsoWeekDay('start_datetime'), + isoweekday_melb=ExtractIsoWeekDay('start_datetime', tzinfo=melb), + quarter=ExtractQuarter('start_datetime', tzinfo=melb), + hour=ExtractHour('start_datetime'), + hour_melb=ExtractHour('start_datetime', tzinfo=melb), + hour_with_delta_pos=ExtractHour('start_datetime', tzinfo=delta_tzinfo_pos), + hour_with_delta_neg=ExtractHour('start_datetime', tzinfo=delta_tzinfo_neg), + minute_with_delta_neg=ExtractMinute('start_datetime', tzinfo=delta_tzinfo_neg), + ).order_by('start_datetime') - utc_model = qs.get() - self.assertEqual(utc_model.day, 15) - self.assertEqual(utc_model.day_melb, 16) - self.assertEqual(utc_model.week, 25) - self.assertEqual(utc_model.isoyear, 2015) - self.assertEqual(utc_model.weekday, 2) - self.assertEqual(utc_model.weekday_melb, 3) - self.assertEqual(utc_model.isoweekday, 1) - self.assertEqual(utc_model.isoweekday_melb, 2) - self.assertEqual(utc_model.quarter, 2) - self.assertEqual(utc_model.hour, 23) - self.assertEqual(utc_model.hour_melb, 9) - self.assertEqual(utc_model.hour_with_delta_pos, 4) - self.assertEqual(utc_model.hour_with_delta_neg, 18) - self.assertEqual(utc_model.minute_with_delta_neg, 47) + utc_model = qs.get() + self.assertEqual(utc_model.day, 15) + self.assertEqual(utc_model.day_melb, 16) + self.assertEqual(utc_model.week, 25) + self.assertEqual(utc_model.isoyear, 2015) + self.assertEqual(utc_model.weekday, 2) + self.assertEqual(utc_model.weekday_melb, 3) + self.assertEqual(utc_model.isoweekday, 1) + self.assertEqual(utc_model.isoweekday_melb, 2) + self.assertEqual(utc_model.quarter, 2) + self.assertEqual(utc_model.hour, 23) + self.assertEqual(utc_model.hour_melb, 9) + self.assertEqual(utc_model.hour_with_delta_pos, 4) + self.assertEqual(utc_model.hour_with_delta_neg, 18) + self.assertEqual(utc_model.minute_with_delta_neg, 47) - with timezone.override(melb): - melb_model = qs.get() + with timezone.override(melb): + melb_model = qs.get() - self.assertEqual(melb_model.day, 16) - self.assertEqual(melb_model.day_melb, 16) - self.assertEqual(melb_model.week, 25) - self.assertEqual(melb_model.isoyear, 2015) - self.assertEqual(melb_model.weekday, 3) - self.assertEqual(melb_model.isoweekday, 2) - self.assertEqual(melb_model.quarter, 2) - self.assertEqual(melb_model.weekday_melb, 3) - self.assertEqual(melb_model.isoweekday_melb, 2) - self.assertEqual(melb_model.hour, 9) - self.assertEqual(melb_model.hour_melb, 9) + self.assertEqual(melb_model.day, 16) + self.assertEqual(melb_model.day_melb, 16) + self.assertEqual(melb_model.week, 25) + self.assertEqual(melb_model.isoyear, 2015) + self.assertEqual(melb_model.weekday, 3) + self.assertEqual(melb_model.isoweekday, 2) + self.assertEqual(melb_model.quarter, 2) + self.assertEqual(melb_model.weekday_melb, 3) + self.assertEqual(melb_model.isoweekday_melb, 2) + self.assertEqual(melb_model.hour, 9) + self.assertEqual(melb_model.hour_melb, 9) def test_extract_func_explicit_timezone_priority(self): start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321) @@ -1116,27 +1133,29 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): start_datetime = timezone.make_aware(start_datetime, is_dst=False) end_datetime = timezone.make_aware(end_datetime, is_dst=False) self.create_model(start_datetime, end_datetime) - melb = pytz.timezone('Australia/Melbourne') - with timezone.override(melb): - model = DTModel.objects.annotate( - day_melb=Extract('start_datetime', 'day'), - day_utc=Extract('start_datetime', 'day', tzinfo=timezone.utc), - ).order_by('start_datetime').get() - self.assertEqual(model.day_melb, 16) - self.assertEqual(model.day_utc, 15) + for melb in self.get_timezones('Australia/Melbourne'): + with self.subTest(repr(melb)): + with timezone.override(melb): + model = DTModel.objects.annotate( + day_melb=Extract('start_datetime', 'day'), + day_utc=Extract('start_datetime', 'day', tzinfo=timezone.utc), + ).order_by('start_datetime').get() + self.assertEqual(model.day_melb, 16) + self.assertEqual(model.day_utc, 15) def test_extract_invalid_field_with_timezone(self): - melb = pytz.timezone('Australia/Melbourne') - msg = 'tzinfo can only be used with DateTimeField.' - with self.assertRaisesMessage(ValueError, msg): - DTModel.objects.annotate( - day_melb=Extract('start_date', 'day', tzinfo=melb), - ).get() - with self.assertRaisesMessage(ValueError, msg): - DTModel.objects.annotate( - hour_melb=Extract('start_time', 'hour', tzinfo=melb), - ).get() + for melb in self.get_timezones('Australia/Melbourne'): + with self.subTest(repr(melb)): + msg = 'tzinfo can only be used with DateTimeField.' + with self.assertRaisesMessage(ValueError, msg): + DTModel.objects.annotate( + day_melb=Extract('start_date', 'day', tzinfo=melb), + ).get() + with self.assertRaisesMessage(ValueError, msg): + DTModel.objects.annotate( + hour_melb=Extract('start_time', 'hour', tzinfo=melb), + ).get() def test_trunc_timezone_applied_before_truncation(self): start_datetime = datetime(2016, 1, 1, 1, 30, 50, 321) @@ -1145,36 +1164,37 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): end_datetime = timezone.make_aware(end_datetime, is_dst=False) self.create_model(start_datetime, end_datetime) - melb = pytz.timezone('Australia/Melbourne') - pacific = pytz.timezone('US/Pacific') + for melb, pacific in zip( + self.get_timezones('Australia/Melbourne'), self.get_timezones('America/Los_Angeles') + ): + with self.subTest((repr(melb), repr(pacific))): + model = DTModel.objects.annotate( + melb_year=TruncYear('start_datetime', tzinfo=melb), + pacific_year=TruncYear('start_datetime', tzinfo=pacific), + melb_date=TruncDate('start_datetime', tzinfo=melb), + pacific_date=TruncDate('start_datetime', tzinfo=pacific), + melb_time=TruncTime('start_datetime', tzinfo=melb), + pacific_time=TruncTime('start_datetime', tzinfo=pacific), + ).order_by('start_datetime').get() - model = DTModel.objects.annotate( - melb_year=TruncYear('start_datetime', tzinfo=melb), - pacific_year=TruncYear('start_datetime', tzinfo=pacific), - melb_date=TruncDate('start_datetime', tzinfo=melb), - pacific_date=TruncDate('start_datetime', tzinfo=pacific), - melb_time=TruncTime('start_datetime', tzinfo=melb), - pacific_time=TruncTime('start_datetime', tzinfo=pacific), - ).order_by('start_datetime').get() - - melb_start_datetime = start_datetime.astimezone(melb) - pacific_start_datetime = start_datetime.astimezone(pacific) - self.assertEqual(model.start_datetime, start_datetime) - self.assertEqual(model.melb_year, truncate_to(start_datetime, 'year', melb)) - self.assertEqual(model.pacific_year, truncate_to(start_datetime, 'year', pacific)) - self.assertEqual(model.start_datetime.year, 2016) - self.assertEqual(model.melb_year.year, 2016) - self.assertEqual(model.pacific_year.year, 2015) - self.assertEqual(model.melb_date, melb_start_datetime.date()) - self.assertEqual(model.pacific_date, pacific_start_datetime.date()) - self.assertEqual(model.melb_time, melb_start_datetime.time()) - self.assertEqual(model.pacific_time, pacific_start_datetime.time()) + melb_start_datetime = start_datetime.astimezone(melb) + pacific_start_datetime = start_datetime.astimezone(pacific) + self.assertEqual(model.start_datetime, start_datetime) + self.assertEqual(model.melb_year, truncate_to(start_datetime, 'year', melb)) + self.assertEqual(model.pacific_year, truncate_to(start_datetime, 'year', pacific)) + self.assertEqual(model.start_datetime.year, 2016) + self.assertEqual(model.melb_year.year, 2016) + self.assertEqual(model.pacific_year.year, 2015) + self.assertEqual(model.melb_date, melb_start_datetime.date()) + self.assertEqual(model.pacific_date, pacific_start_datetime.date()) + self.assertEqual(model.melb_time, melb_start_datetime.time()) + self.assertEqual(model.pacific_time, pacific_start_datetime.time()) def test_trunc_ambiguous_and_invalid_times(self): sao = pytz.timezone('America/Sao_Paulo') - utc = pytz.timezone('UTC') - start_datetime = utc.localize(datetime(2016, 10, 16, 13)) - end_datetime = utc.localize(datetime(2016, 2, 21, 1)) + utc = timezone.utc + start_datetime = datetime(2016, 10, 16, 13, tzinfo=utc) + end_datetime = datetime(2016, 2, 21, 1, tzinfo=utc) self.create_model(start_datetime, end_datetime) with timezone.override(sao): with self.assertRaisesMessage(pytz.NonExistentTimeError, '2016-10-16 00:00:00'): @@ -1206,94 +1226,99 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): self.create_model(start_datetime, end_datetime) self.create_model(end_datetime, start_datetime) - melb = pytz.timezone('Australia/Melbourne') - - def test_datetime_kind(kind): - self.assertQuerysetEqual( - DTModel.objects.annotate( - truncated=Trunc('start_datetime', kind, output_field=DateTimeField(), tzinfo=melb) - ).order_by('start_datetime'), - [ - (start_datetime, truncate_to(start_datetime.astimezone(melb), kind, melb)), - (end_datetime, truncate_to(end_datetime.astimezone(melb), kind, melb)) - ], - lambda m: (m.start_datetime, m.truncated) - ) - - def test_datetime_to_date_kind(kind): - self.assertQuerysetEqual( - DTModel.objects.annotate( - truncated=Trunc( - 'start_datetime', - kind, - output_field=DateField(), - tzinfo=melb, - ), - ).order_by('start_datetime'), - [ - ( - start_datetime, - truncate_to(start_datetime.astimezone(melb).date(), kind), - ), - ( - end_datetime, - truncate_to(end_datetime.astimezone(melb).date(), kind), - ), - ], - lambda m: (m.start_datetime, m.truncated), - ) - - def test_datetime_to_time_kind(kind): - self.assertQuerysetEqual( - DTModel.objects.annotate( - truncated=Trunc( - 'start_datetime', - kind, - output_field=TimeField(), - tzinfo=melb, + for melb in self.get_timezones('Australia/Melbourne'): + with self.subTest(repr(melb)): + def test_datetime_kind(kind): + self.assertQuerysetEqual( + DTModel.objects.annotate( + truncated=Trunc( + 'start_datetime', kind, output_field=DateTimeField(), tzinfo=melb + ) + ).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.astimezone(melb), kind, melb)), + (end_datetime, truncate_to(end_datetime.astimezone(melb), kind, melb)) + ], + lambda m: (m.start_datetime, m.truncated) ) - ).order_by('start_datetime'), - [ - ( - start_datetime, - truncate_to(start_datetime.astimezone(melb).time(), kind), - ), - ( - end_datetime, - truncate_to(end_datetime.astimezone(melb).time(), kind), - ), - ], - lambda m: (m.start_datetime, m.truncated), - ) - test_datetime_to_date_kind('year') - test_datetime_to_date_kind('quarter') - test_datetime_to_date_kind('month') - test_datetime_to_date_kind('week') - test_datetime_to_date_kind('day') - test_datetime_to_time_kind('hour') - test_datetime_to_time_kind('minute') - test_datetime_to_time_kind('second') - test_datetime_kind('year') - test_datetime_kind('quarter') - test_datetime_kind('month') - test_datetime_kind('week') - test_datetime_kind('day') - test_datetime_kind('hour') - test_datetime_kind('minute') - test_datetime_kind('second') + def test_datetime_to_date_kind(kind): + self.assertQuerysetEqual( + DTModel.objects.annotate( + truncated=Trunc( + 'start_datetime', + kind, + output_field=DateField(), + tzinfo=melb, + ), + ).order_by('start_datetime'), + [ + ( + start_datetime, + truncate_to(start_datetime.astimezone(melb).date(), kind), + ), + ( + end_datetime, + truncate_to(end_datetime.astimezone(melb).date(), kind), + ), + ], + lambda m: (m.start_datetime, m.truncated), + ) - qs = DTModel.objects.filter(start_datetime__date=Trunc('start_datetime', 'day', output_field=DateField())) - self.assertEqual(qs.count(), 2) + def test_datetime_to_time_kind(kind): + self.assertQuerysetEqual( + DTModel.objects.annotate( + truncated=Trunc( + 'start_datetime', + kind, + output_field=TimeField(), + tzinfo=melb, + ) + ).order_by('start_datetime'), + [ + ( + start_datetime, + truncate_to(start_datetime.astimezone(melb).time(), kind), + ), + ( + end_datetime, + truncate_to(end_datetime.astimezone(melb).time(), kind), + ), + ], + lambda m: (m.start_datetime, m.truncated), + ) + + test_datetime_to_date_kind('year') + test_datetime_to_date_kind('quarter') + test_datetime_to_date_kind('month') + test_datetime_to_date_kind('week') + test_datetime_to_date_kind('day') + test_datetime_to_time_kind('hour') + test_datetime_to_time_kind('minute') + test_datetime_to_time_kind('second') + test_datetime_kind('year') + test_datetime_kind('quarter') + test_datetime_kind('month') + test_datetime_kind('week') + test_datetime_kind('day') + test_datetime_kind('hour') + test_datetime_kind('minute') + test_datetime_kind('second') + + qs = DTModel.objects.filter( + start_datetime__date=Trunc('start_datetime', 'day', output_field=DateField()) + ) + self.assertEqual(qs.count(), 2) def test_trunc_invalid_field_with_timezone(self): - melb = pytz.timezone('Australia/Melbourne') - msg = 'tzinfo can only be used with DateTimeField.' - with self.assertRaisesMessage(ValueError, msg): - DTModel.objects.annotate( - day_melb=Trunc('start_date', 'day', tzinfo=melb), - ).get() - with self.assertRaisesMessage(ValueError, msg): - DTModel.objects.annotate( - hour_melb=Trunc('start_time', 'hour', tzinfo=melb), - ).get() + for melb in self.get_timezones('Australia/Melbourne'): + with self.subTest(repr(melb)): + msg = 'tzinfo can only be used with DateTimeField.' + with self.assertRaisesMessage(ValueError, msg): + DTModel.objects.annotate( + day_melb=Trunc('start_date', 'day', tzinfo=melb), + ).get() + with self.assertRaisesMessage(ValueError, msg): + DTModel.objects.annotate( + hour_melb=Trunc('start_time', 'hour', tzinfo=melb), + ).get() diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index 98f9ae94fd4..6488ab6a7d3 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -1,5 +1,6 @@ asgiref >= 3.2.10 argon2-cffi >= 16.1.0 +backports.zoneinfo; python_version < '3.9' bcrypt docutils geoip2 @@ -17,4 +18,5 @@ PyYAML selenium sqlparse >= 0.2.2 tblib >= 1.5.0 +tzdata colorama; sys.platform == 'win32' diff --git a/tests/timezones/tests.py b/tests/timezones/tests.py index 8756f877163..861636ddf18 100644 --- a/tests/timezones/tests.py +++ b/tests/timezones/tests.py @@ -7,6 +7,14 @@ from xml.dom.minidom import parseString import pytz +try: + import zoneinfo +except ImportError: + try: + from backports import zoneinfo + except ImportError: + zoneinfo = None + from django.contrib.auth.models import User from django.core import serializers from django.db import connection @@ -51,6 +59,14 @@ UTC = timezone.utc EAT = timezone.get_fixed_timezone(180) # Africa/Nairobi ICT = timezone.get_fixed_timezone(420) # Asia/Bangkok +ZONE_CONSTRUCTORS = (pytz.timezone,) +if zoneinfo is not None: + ZONE_CONSTRUCTORS += (zoneinfo.ZoneInfo,) + + +def get_timezones(key): + return [constructor(key) for constructor in ZONE_CONSTRUCTORS] + @contextmanager def override_database_connection_timezone(timezone): @@ -326,16 +342,17 @@ class NewDatabaseTests(TestCase): self.assertEqual(Event.objects.filter(dt__gt=dt2).count(), 0) def test_query_filter_with_pytz_timezones(self): - tz = pytz.timezone('Europe/Paris') - dt = datetime.datetime(2011, 9, 1, 12, 20, 30, tzinfo=tz) - Event.objects.create(dt=dt) - next = dt + datetime.timedelta(seconds=3) - prev = dt - datetime.timedelta(seconds=3) - self.assertEqual(Event.objects.filter(dt__exact=dt).count(), 1) - self.assertEqual(Event.objects.filter(dt__exact=next).count(), 0) - self.assertEqual(Event.objects.filter(dt__in=(prev, next)).count(), 0) - self.assertEqual(Event.objects.filter(dt__in=(prev, dt, next)).count(), 1) - self.assertEqual(Event.objects.filter(dt__range=(prev, next)).count(), 1) + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + dt = datetime.datetime(2011, 9, 1, 12, 20, 30, tzinfo=tz) + Event.objects.create(dt=dt) + next = dt + datetime.timedelta(seconds=3) + prev = dt - datetime.timedelta(seconds=3) + self.assertEqual(Event.objects.filter(dt__exact=dt).count(), 1) + self.assertEqual(Event.objects.filter(dt__exact=next).count(), 0) + self.assertEqual(Event.objects.filter(dt__in=(prev, next)).count(), 0) + self.assertEqual(Event.objects.filter(dt__in=(prev, dt, next)).count(), 1) + self.assertEqual(Event.objects.filter(dt__range=(prev, next)).count(), 1) def test_query_convert_timezones(self): # Connection timezone is equal to the current timezone, datetime @@ -543,7 +560,7 @@ class NewDatabaseTests(TestCase): with connection.cursor() as cursor: cursor.execute('SELECT CURRENT_TIMESTAMP') now = cursor.fetchone()[0] - self.assertEqual(now.tzinfo.zone, 'Europe/Paris') + self.assertEqual(str(now.tzinfo), 'Europe/Paris') @requires_tz_support def test_filter_date_field_with_aware_datetime(self): @@ -871,32 +888,26 @@ class TemplateTests(SimpleTestCase): expected = results[k1][k2] self.assertEqual(actual, expected, '%s / %s: %r != %r' % (k1, k2, actual, expected)) - def test_localtime_filters_with_pytz(self): + def test_localtime_filters_with_iana(self): """ - Test the |localtime, |utc, and |timezone filters with pytz. + Test the |localtime, |utc, and |timezone filters with iana zones. """ - # Use a pytz timezone as local time + # Use an IANA timezone as local time tpl = Template("{% load tz %}{{ dt|localtime }}|{{ dt|utc }}") ctx = Context({'dt': datetime.datetime(2011, 9, 1, 12, 20, 30)}) with self.settings(TIME_ZONE='Europe/Paris'): self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00|2011-09-01T10:20:30+00:00") - # Use a pytz timezone as argument - tpl = Template("{% load tz %}{{ dt|timezone:tz }}") - ctx = Context({ - 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30), - 'tz': pytz.timezone('Europe/Paris'), - }) - self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00") - - # Use a pytz timezone name as argument - tpl = Template("{% load tz %}{{ dt|timezone:'Europe/Paris' }}") - ctx = Context({ - 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30), - 'tz': pytz.timezone('Europe/Paris'), - }) - self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00") + # Use an IANA timezone as argument + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + tpl = Template("{% load tz %}{{ dt|timezone:tz }}") + ctx = Context({ + 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30), + 'tz': pytz.timezone('Europe/Paris'), + }) + self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00") def test_localtime_templatetag_invalid_argument(self): with self.assertRaises(TemplateSyntaxError): @@ -945,20 +956,22 @@ class TemplateTests(SimpleTestCase): "2011-09-01T13:20:30+03:00|2011-09-01T17:20:30+07:00|2011-09-01T13:20:30+03:00" ) - def test_timezone_templatetag_with_pytz(self): + def test_timezone_templatetag_with_iana(self): """ - Test the {% timezone %} templatetag with pytz. + Test the {% timezone %} templatetag with IANA time zone providers. """ tpl = Template("{% load tz %}{% timezone tz %}{{ dt }}{% endtimezone %}") - # Use a pytz timezone as argument - ctx = Context({ - 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT), - 'tz': pytz.timezone('Europe/Paris'), - }) - self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00") + # Use a IANA timezone as argument + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + ctx = Context({ + 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT), + 'tz': tz, + }) + self.assertEqual(tpl.render(ctx), "2011-09-01T12:20:30+02:00") - # Use a pytz timezone name as argument + # Use a IANA timezone name as argument ctx = Context({ 'dt': datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT), 'tz': 'Europe/Paris', @@ -991,13 +1004,15 @@ class TemplateTests(SimpleTestCase): with timezone.override(UTC): self.assertEqual(tpl.render(Context({'tz': ICT})), "+0700") - def test_get_current_timezone_templatetag_with_pytz(self): + def test_get_current_timezone_templatetag_with_iana(self): """ Test the {% get_current_timezone %} templatetag with pytz. """ tpl = Template("{% load tz %}{% get_current_timezone as time_zone %}{{ time_zone }}") - with timezone.override(pytz.timezone('Europe/Paris')): - self.assertEqual(tpl.render(Context()), "Europe/Paris") + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + with timezone.override(tz): + self.assertEqual(tpl.render(Context()), "Europe/Paris") tpl = Template( "{% load tz %}{% timezone 'Europe/Paris' %}" @@ -1059,17 +1074,21 @@ class LegacyFormsTests(TestCase): def test_form_with_non_existent_time(self): form = EventForm({'dt': '2011-03-27 02:30:00'}) - with timezone.override(pytz.timezone('Europe/Paris')): - # This is a bug. - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['dt'], datetime.datetime(2011, 3, 27, 2, 30, 0)) + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + with timezone.override(tz): + # This is a bug. + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['dt'], datetime.datetime(2011, 3, 27, 2, 30, 0)) def test_form_with_ambiguous_time(self): form = EventForm({'dt': '2011-10-30 02:30:00'}) - with timezone.override(pytz.timezone('Europe/Paris')): - # This is a bug. - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['dt'], datetime.datetime(2011, 10, 30, 2, 30, 0)) + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + with timezone.override(tz): + # This is a bug. + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['dt'], datetime.datetime(2011, 10, 30, 2, 30, 0)) def test_split_form(self): form = EventSplitForm({'dt_0': '2011-09-01', 'dt_1': '13:20:30'}) @@ -1098,26 +1117,30 @@ class NewFormsTests(TestCase): self.assertEqual(form.cleaned_data['dt'], datetime.datetime(2011, 9, 1, 10, 20, 30, tzinfo=UTC)) def test_form_with_non_existent_time(self): - with timezone.override(pytz.timezone('Europe/Paris')): - form = EventForm({'dt': '2011-03-27 02:30:00'}) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors['dt'], [ - '2011-03-27 02:30:00 couldn’t be interpreted in time zone ' - 'Europe/Paris; it may be ambiguous or it may not exist.' - ] - ) + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + with timezone.override(tz): + form = EventForm({'dt': '2011-03-27 02:30:00'}) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors['dt'], [ + '2011-03-27 02:30:00 couldn’t be interpreted in time zone ' + 'Europe/Paris; it may be ambiguous or it may not exist.' + ] + ) def test_form_with_ambiguous_time(self): - with timezone.override(pytz.timezone('Europe/Paris')): - form = EventForm({'dt': '2011-10-30 02:30:00'}) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors['dt'], [ - '2011-10-30 02:30:00 couldn’t be interpreted in time zone ' - 'Europe/Paris; it may be ambiguous or it may not exist.' - ] - ) + for tz in get_timezones('Europe/Paris'): + with self.subTest(repr(tz)): + with timezone.override(tz): + form = EventForm({'dt': '2011-10-30 02:30:00'}) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors['dt'], [ + '2011-10-30 02:30:00 couldn’t be interpreted in time zone ' + 'Europe/Paris; it may be ambiguous or it may not exist.' + ] + ) @requires_tz_support def test_split_form(self): diff --git a/tests/utils_tests/test_timezone.py b/tests/utils_tests/test_timezone.py index bfd71fb5060..2e28d3a9a5f 100644 --- a/tests/utils_tests/test_timezone.py +++ b/tests/utils_tests/test_timezone.py @@ -1,14 +1,38 @@ import datetime +import unittest from unittest import mock import pytz +try: + import zoneinfo +except ImportError: + try: + from backports import zoneinfo + except ImportError: + zoneinfo = None + from django.test import SimpleTestCase, override_settings from django.utils import timezone CET = pytz.timezone("Europe/Paris") EAT = timezone.get_fixed_timezone(180) # Africa/Nairobi ICT = timezone.get_fixed_timezone(420) # Asia/Bangkok +UTC = datetime.timezone.utc + +HAS_ZONEINFO = zoneinfo is not None + +if not HAS_ZONEINFO: + PARIS_ZI = None + PARIS_IMPLS = (CET,) + + needs_zoneinfo = unittest.skip("Test requires zoneinfo") +else: + PARIS_ZI = zoneinfo.ZoneInfo('Europe/Paris') + PARIS_IMPLS = (CET, PARIS_ZI) + + def needs_zoneinfo(f): + return f class TimezoneTests(SimpleTestCase): @@ -142,13 +166,21 @@ class TimezoneTests(SimpleTestCase): ) def test_make_aware2(self): - self.assertEqual( - timezone.make_aware(datetime.datetime(2011, 9, 1, 12, 20, 30), CET), - CET.localize(datetime.datetime(2011, 9, 1, 12, 20, 30))) + CEST = datetime.timezone(datetime.timedelta(hours=2), 'CEST') + for tz in PARIS_IMPLS: + with self.subTest(repr(tz)): + self.assertEqual( + timezone.make_aware(datetime.datetime(2011, 9, 1, 12, 20, 30), tz), + datetime.datetime(2011, 9, 1, 12, 20, 30, tzinfo=CEST)) + with self.assertRaises(ValueError): timezone.make_aware(CET.localize(datetime.datetime(2011, 9, 1, 12, 20, 30)), CET) - def test_make_aware_pytz(self): + if HAS_ZONEINFO: + with self.assertRaises(ValueError): + timezone.make_aware(datetime.datetime(2011, 9, 1, 12, 20, 30, tzinfo=PARIS_ZI), PARIS_ZI) + + def test_make_naive_pytz(self): self.assertEqual( timezone.make_naive(CET.localize(datetime.datetime(2011, 9, 1, 12, 20, 30)), CET), datetime.datetime(2011, 9, 1, 12, 20, 30)) @@ -160,6 +192,18 @@ class TimezoneTests(SimpleTestCase): with self.assertRaisesMessage(ValueError, 'make_naive() cannot be applied to a naive datetime'): timezone.make_naive(datetime.datetime(2011, 9, 1, 12, 20, 30), CET) + @needs_zoneinfo + def test_make_naive_zoneinfo(self): + self.assertEqual( + timezone.make_naive(datetime.datetime(2011, 9, 1, 12, 20, 30, tzinfo=PARIS_ZI), PARIS_ZI), + datetime.datetime(2011, 9, 1, 12, 20, 30) + ) + + self.assertEqual( + timezone.make_naive(datetime.datetime(2011, 9, 1, 12, 20, 30, fold=1, tzinfo=PARIS_ZI), PARIS_ZI), + datetime.datetime(2011, 9, 1, 12, 20, 30, fold=1) + ) + def test_make_aware_pytz_ambiguous(self): # 2:30 happens twice, once before DST ends and once after ambiguous = datetime.datetime(2015, 10, 25, 2, 30) @@ -173,6 +217,21 @@ class TimezoneTests(SimpleTestCase): self.assertEqual(std.tzinfo.utcoffset(std), datetime.timedelta(hours=1)) self.assertEqual(dst.tzinfo.utcoffset(dst), datetime.timedelta(hours=2)) + @needs_zoneinfo + def test_make_aware_zoneinfo_ambiguous(self): + # 2:30 happens twice, once before DST ends and once after + ambiguous = datetime.datetime(2015, 10, 25, 2, 30) + + std = timezone.make_aware(ambiguous.replace(fold=1), timezone=PARIS_ZI) + dst = timezone.make_aware(ambiguous, timezone=PARIS_ZI) + + self.assertEqual( + std.astimezone(UTC) - dst.astimezone(UTC), + datetime.timedelta(hours=1) + ) + self.assertEqual(std.utcoffset(), datetime.timedelta(hours=1)) + self.assertEqual(dst.utcoffset(), datetime.timedelta(hours=2)) + def test_make_aware_pytz_non_existent(self): # 2:30 never happened due to DST non_existent = datetime.datetime(2015, 3, 29, 2, 30) @@ -186,6 +245,21 @@ class TimezoneTests(SimpleTestCase): self.assertEqual(std.tzinfo.utcoffset(std), datetime.timedelta(hours=1)) self.assertEqual(dst.tzinfo.utcoffset(dst), datetime.timedelta(hours=2)) + @needs_zoneinfo + def test_make_aware_zoneinfo_non_existent(self): + # 2:30 never happened due to DST + non_existent = datetime.datetime(2015, 3, 29, 2, 30) + + std = timezone.make_aware(non_existent, PARIS_ZI) + dst = timezone.make_aware(non_existent.replace(fold=1), PARIS_ZI) + + self.assertEqual( + std.astimezone(UTC) - dst.astimezone(UTC), + datetime.timedelta(hours=1) + ) + self.assertEqual(std.utcoffset(), datetime.timedelta(hours=1)) + self.assertEqual(dst.utcoffset(), datetime.timedelta(hours=2)) + def test_get_default_timezone(self): self.assertEqual(timezone.get_default_timezone_name(), 'America/Chicago')