Skip to content

Commit

Permalink
Refs #32365 -- Allowed use of non-pytz timezone implementations.
Browse files Browse the repository at this point in the history
  • Loading branch information
pganssle authored and carltongibson committed Jan 19, 2021
1 parent 73ffc73 commit 10d1261
Show file tree
Hide file tree
Showing 11 changed files with 481 additions and 310 deletions.
5 changes: 5 additions & 0 deletions django/forms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 14 additions & 26 deletions django/utils/dateformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
)
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 _

Expand Down Expand Up @@ -160,15 +161,9 @@ def T(self):
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)

Expand All @@ -184,16 +179,13 @@ def Z(self):
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
Expand Down Expand Up @@ -232,16 +224,12 @@ def F(self):

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'"
Expand Down
26 changes: 24 additions & 2 deletions django/utils/timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
# 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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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)
33 changes: 18 additions & 15 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -943,21 +943,24 @@ appropriate entities.
:class:`~datetime.datetime`. If ``timezone`` is set to ``None``, it
defaults to the :ref:`current time zone <default-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.

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.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.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)

Expand Down
3 changes: 3 additions & 0 deletions docs/releases/3.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()``.

Expand Down
14 changes: 11 additions & 3 deletions docs/topics/i18n/timezones.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <USE_TZ>` in your settings file. Time zone support uses pytz_, which is
installed when you install Django.
True <USE_TZ>` 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::

Expand Down Expand Up @@ -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
Expand Down
46 changes: 32 additions & 14 deletions tests/admin_views/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@

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
Expand Down Expand Up @@ -63,6 +71,14 @@
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.
Expand Down Expand Up @@ -995,24 +1011,26 @@ def test_date_hierarchy_empty_queryset(self):
@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')
Expand Down

0 comments on commit 10d1261

Please sign in to comment.