From 46346f8ea08020d503b25472a26b949a5ce980a6 Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Mon, 10 May 2021 14:39:50 +0100 Subject: [PATCH] Refs #32738 -- Added sanitize_strftime_format() to replace datetime_safe. --- django/forms/widgets.py | 4 +- django/utils/formats.py | 41 ++++++++++++++-- .../widget_tests/test_selectdatewidget.py | 3 +- tests/i18n/tests.py | 48 ++++++++++++++++++- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/django/forms/widgets.py b/django/forms/widgets.py index 1e67857c31..0a24e924a5 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -10,7 +10,7 @@ from itertools import chain from django.forms.utils import to_current_timezone from django.templatetags.static import static -from django.utils import datetime_safe, formats +from django.utils import formats from django.utils.datastructures import OrderedSet from django.utils.dates import MONTHS from django.utils.formats import get_format @@ -1070,13 +1070,13 @@ class SelectDateWidget(Widget): return None if y is not None and m is not None and d is not None: input_format = get_format('DATE_INPUT_FORMATS')[0] + input_format = formats.sanitize_strftime_format(input_format) try: date_value = datetime.date(int(y), int(m), int(d)) except ValueError: # Return pseudo-ISO dates with zeros for any unselected values, # e.g. '2017-0-23'. return '%s-%s-%s' % (y or 0, m or 0, d or 0) - date_value = datetime_safe.new_date(date_value) return date_value.strftime(input_format) return data.get(name) diff --git a/django/utils/formats.py b/django/utils/formats.py index 028e11415a..3bad64150a 100644 --- a/django/utils/formats.py +++ b/django/utils/formats.py @@ -1,10 +1,12 @@ import datetime import decimal +import functools +import re import unicodedata from importlib import import_module from django.conf import settings -from django.utils import dateformat, datetime_safe, numberformat +from django.utils import dateformat, numberformat from django.utils.functional import lazy from django.utils.translation import ( check_for_language, get_language, to_locale, @@ -221,12 +223,12 @@ def localize_input(value, default=None): elif isinstance(value, (decimal.Decimal, float, int)): return number_format(value) elif isinstance(value, datetime.datetime): - value = datetime_safe.new_datetime(value) format = default or get_format('DATETIME_INPUT_FORMATS')[0] + format = sanitize_strftime_format(format) return value.strftime(format) elif isinstance(value, datetime.date): - value = datetime_safe.new_date(value) format = default or get_format('DATE_INPUT_FORMATS')[0] + format = sanitize_strftime_format(format) return value.strftime(format) elif isinstance(value, datetime.time): format = default or get_format('TIME_INPUT_FORMATS')[0] @@ -234,6 +236,39 @@ def localize_input(value, default=None): return value +@functools.lru_cache() +def sanitize_strftime_format(fmt): + """ + Ensure that certain specifiers are correctly padded with leading zeros. + + For years < 1000 specifiers %C, %F, %G, and %Y don't work as expected for + strftime provided by glibc on Linux as they don't pad the year or century + with leading zeros. Support for specifying the padding explicitly is + available, however, which can be used to fix this issue. + + FreeBSD, macOS, and Windows do not support explicitly specifying the + padding, but return four digit years (with leading zeros) as expected. + + This function checks whether the %Y produces a correctly padded string and, + if not, makes the following substitutions: + + - %C → %02C + - %F → %010F + - %G → %04G + - %Y → %04Y + + See https://bugs.python.org/issue13305 for more details. + """ + if datetime.date(1, 1, 1).strftime('%Y') == '0001': + return fmt + mapping = {'C': 2, 'F': 10, 'G': 4, 'Y': 4} + return re.sub( + r'((?:^|[^%])(?:%%)*)%([CFGY])', + lambda m: r'%s%%0%s%s' % (m[1], mapping[m[2]], m[2]), + fmt, + ) + + def sanitize_separators(value): """ Sanitize a value according to the current decimal and diff --git a/tests/forms_tests/widget_tests/test_selectdatewidget.py b/tests/forms_tests/widget_tests/test_selectdatewidget.py index 00cfbae758..661268a08d 100644 --- a/tests/forms_tests/widget_tests/test_selectdatewidget.py +++ b/tests/forms_tests/widget_tests/test_selectdatewidget.py @@ -477,7 +477,8 @@ class SelectDateWidgetTest(WidgetTest): w.value_from_datadict({'date_year': '1899', 'date_month': '8', 'date_day': '13'}, {}, 'date'), '13-08-1899', ) - # And years before 1000 (demonstrating the need for datetime_safe). + # And years before 1000 (demonstrating the need for + # sanitize_strftime_format). w = SelectDateWidget(years=('0001',)) self.assertEqual( w.value_from_datadict({'date_year': '0001', 'date_month': '8', 'date_day': '13'}, {}, 'date'), diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py index 7d81f2a07d..7705df738c 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -24,7 +24,8 @@ from django.test import ( from django.utils import translation from django.utils.formats import ( date_format, get_format, get_format_modules, iter_format_modules, localize, - localize_input, reset_format_cache, sanitize_separators, time_format, + localize_input, reset_format_cache, sanitize_separators, + sanitize_strftime_format, time_format, ) from django.utils.numberformat import format as nformat from django.utils.safestring import SafeString, mark_safe @@ -1074,6 +1075,51 @@ class FormattingTests(SimpleTestCase): with self.subTest(value=value): self.assertEqual(localize_input(value), expected) + def test_sanitize_strftime_format(self): + for year in (1, 99, 999, 1000): + dt = datetime.date(year, 1, 1) + for fmt, expected in [ + ('%C', '%02d' % (year // 100)), + ('%F', '%04d-01-01' % year), + ('%G', '%04d' % year), + ('%Y', '%04d' % year), + ]: + with self.subTest(year=year, fmt=fmt): + fmt = sanitize_strftime_format(fmt) + self.assertEqual(dt.strftime(fmt), expected) + + def test_sanitize_strftime_format_with_escaped_percent(self): + dt = datetime.date(1, 1, 1) + for fmt, expected in [ + ('%%C', '%C'), + ('%%F', '%F'), + ('%%G', '%G'), + ('%%Y', '%Y'), + ('%%%%C', '%%C'), + ('%%%%F', '%%F'), + ('%%%%G', '%%G'), + ('%%%%Y', '%%Y'), + ]: + with self.subTest(fmt=fmt): + fmt = sanitize_strftime_format(fmt) + self.assertEqual(dt.strftime(fmt), expected) + + for year in (1, 99, 999, 1000): + dt = datetime.date(year, 1, 1) + for fmt, expected in [ + ('%%%C', '%%%02d' % (year // 100)), + ('%%%F', '%%%04d-01-01' % year), + ('%%%G', '%%%04d' % year), + ('%%%Y', '%%%04d' % year), + ('%%%%%C', '%%%%%02d' % (year // 100)), + ('%%%%%F', '%%%%%04d-01-01' % year), + ('%%%%%G', '%%%%%04d' % year), + ('%%%%%Y', '%%%%%04d' % year), + ]: + with self.subTest(year=year, fmt=fmt): + fmt = sanitize_strftime_format(fmt) + self.assertEqual(dt.strftime(fmt), expected) + def test_sanitize_separators(self): """ Tests django.utils.formats.sanitize_separators.