Closes issue #12006: Add ISO 8601 year, week, and day directives to strptime.

This commit adds %G, %V, and %u directives to strptime.  Thanks Ashley Anderson
for the implementation.
This commit is contained in:
Alexander Belopolsky 2015-10-06 13:29:56 -04:00
parent fc632e3912
commit 68713e41a5
5 changed files with 159 additions and 27 deletions

View File

@ -1909,6 +1909,34 @@ format codes.
| ``%%`` | A literal ``'%'`` character. | % | |
+-----------+--------------------------------+------------------------+-------+
Several additional directives not required by the C89 standard are included for
convenience. These parameters all correspond to ISO 8601 date values. These
may not be available on all platforms when used with the :meth:`strftime`
method. The ISO 8601 year and ISO 8601 week directives are not interchangeable
with the year and week number directives above. Calling :meth:`strptime` with
incomplete or ambiguous ISO 8601 directives will raise a :exc:`ValueError`.
+-----------+--------------------------------+------------------------+-------+
| Directive | Meaning | Example | Notes |
+===========+================================+========================+=======+
| ``%G`` | ISO 8601 year with century | 0001, 0002, ..., 2013, | \(8) |
| | representing the year that | 2014, ..., 9998, 9999 | |
| | contains the greater part of | | |
| | the ISO week (``%V``). | | |
+-----------+--------------------------------+------------------------+-------+
| ``%u`` | ISO 8601 weekday as a decimal | 1, 2, ..., 7 | |
| | number where 1 is Monday. | | |
+-----------+--------------------------------+------------------------+-------+
| ``%V`` | ISO 8601 week as a decimal | 01, 02, ..., 53 | \(8) |
| | number with Monday as | | |
| | the first day of the week. | | |
| | Week 01 is the week containing | | |
| | Jan 4. | | |
+-----------+--------------------------------+------------------------+-------+
.. versionadded:: 3.6
``%G``, ``%u`` and ``%V`` were added.
Notes:
(1)
@ -1973,7 +2001,14 @@ Notes:
(7)
When used with the :meth:`strptime` method, ``%U`` and ``%W`` are only used
in calculations when the day of the week and the year are specified.
in calculations when the day of the week and the calendar year (``%Y``)
are specified.
(8)
Similar to ``%U`` and ``%W``, ``%V`` is only used in calculations when the
day of the week and the ISO year (``%G``) are specified in a
:meth:`strptime` format string. Also note that ``%G`` and ``%Y`` are not
interchangable.
.. rubric:: Footnotes

View File

@ -110,6 +110,14 @@ Private and special attribute names now are omitted unless the prefix starts
with underscores. A space or a colon can be added after completed keyword.
(Contributed by Serhiy Storchaka in :issue:`25011` and :issue:`25209`.)
datetime
--------
* :meth:`datetime.stftime <datetime.datetime.stftime>` and
:meth:`date.stftime <datetime.date.stftime>` methods now support ISO 8601
date directives ``%G``, ``%u`` and ``%V``.
(Contributed by Ashley Anderson in :issue:`12006`.)
Optimizations
=============

View File

@ -195,12 +195,15 @@ class TimeRE(dict):
'f': r"(?P<f>[0-9]{1,6})",
'H': r"(?P<H>2[0-3]|[0-1]\d|\d)",
'I': r"(?P<I>1[0-2]|0[1-9]|[1-9])",
'G': r"(?P<G>\d\d\d\d)",
'j': r"(?P<j>36[0-6]|3[0-5]\d|[1-2]\d\d|0[1-9]\d|00[1-9]|[1-9]\d|0[1-9]|[1-9])",
'm': r"(?P<m>1[0-2]|0[1-9]|[1-9])",
'M': r"(?P<M>[0-5]\d|\d)",
'S': r"(?P<S>6[0-1]|[0-5]\d|\d)",
'U': r"(?P<U>5[0-3]|[0-4]\d|\d)",
'w': r"(?P<w>[0-6])",
'u': r"(?P<u>[1-7])",
'V': r"(?P<V>5[0-3]|0[1-9]|[1-4]\d|\d)",
# W is set below by using 'U'
'y': r"(?P<y>\d\d)",
#XXX: Does 'Y' need to worry about having less or more than
@ -295,6 +298,22 @@ def _calc_julian_from_U_or_W(year, week_of_year, day_of_week, week_starts_Mon):
return 1 + days_to_week + day_of_week
def _calc_julian_from_V(iso_year, iso_week, iso_weekday):
"""Calculate the Julian day based on the ISO 8601 year, week, and weekday.
ISO weeks start on Mondays, with week 01 being the week containing 4 Jan.
ISO week days range from 1 (Monday) to 7 (Sunday).
"""
correction = datetime_date(iso_year, 1, 4).isoweekday() + 3
ordinal = (iso_week * 7) + iso_weekday - correction
# ordinal may be negative or 0 now, which means the date is in the previous
# calendar year
if ordinal < 1:
ordinal += datetime_date(iso_year, 1, 1).toordinal()
iso_year -= 1
ordinal -= datetime_date(iso_year, 1, 1).toordinal()
return iso_year, ordinal
def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
"""Return a 2-tuple consisting of a time struct and an int containing
the number of microseconds based on the input string and the
@ -339,15 +358,15 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
raise ValueError("unconverted data remains: %s" %
data_string[found.end():])
year = None
iso_year = year = None
month = day = 1
hour = minute = second = fraction = 0
tz = -1
tzoffset = None
# Default to -1 to signify that values not known; not critical to have,
# though
week_of_year = -1
week_of_year_start = -1
iso_week = week_of_year = None
week_of_year_start = None
# weekday and julian defaulted to None so as to signal need to calculate
# values
weekday = julian = None
@ -369,6 +388,8 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
year += 1900
elif group_key == 'Y':
year = int(found_dict['Y'])
elif group_key == 'G':
iso_year = int(found_dict['G'])
elif group_key == 'm':
month = int(found_dict['m'])
elif group_key == 'B':
@ -414,6 +435,9 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
weekday = 6
else:
weekday -= 1
elif group_key == 'u':
weekday = int(found_dict['u'])
weekday -= 1
elif group_key == 'j':
julian = int(found_dict['j'])
elif group_key in ('U', 'W'):
@ -424,6 +448,8 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
else:
# W starts week on Monday.
week_of_year_start = 0
elif group_key == 'V':
iso_week = int(found_dict['V'])
elif group_key == 'z':
z = found_dict['z']
tzoffset = int(z[1:3]) * 60 + int(z[3:5])
@ -444,28 +470,57 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
else:
tz = value
break
# Deal with the cases where ambiguities arize
# don't assume default values for ISO week/year
if year is None and iso_year is not None:
if iso_week is None or weekday is None:
raise ValueError("ISO year directive '%G' must be used with "
"the ISO week directive '%V' and a weekday "
"directive ('%A', '%a', '%w', or '%u').")
if julian is not None:
raise ValueError("Day of the year directive '%j' is not "
"compatible with ISO year directive '%G'. "
"Use '%Y' instead.")
elif week_of_year is None and iso_week is not None:
if weekday is None:
raise ValueError("ISO week directive '%V' must be used with "
"the ISO year directive '%G' and a weekday "
"directive ('%A', '%a', '%w', or '%u').")
else:
raise ValueError("ISO week directive '%V' is incompatible with "
"the year directive '%Y'. Use the ISO year '%G' "
"instead.")
leap_year_fix = False
if year is None and month == 2 and day == 29:
year = 1904 # 1904 is first leap year of 20th century
leap_year_fix = True
elif year is None:
year = 1900
# If we know the week of the year and what day of that week, we can figure
# out the Julian day of the year.
if julian is None and week_of_year != -1 and weekday is not None:
week_starts_Mon = True if week_of_year_start == 0 else False
julian = _calc_julian_from_U_or_W(year, week_of_year, weekday,
week_starts_Mon)
# Cannot pre-calculate datetime_date() since can change in Julian
# calculation and thus could have different value for the day of the week
# calculation.
if julian is None and weekday is not None:
if week_of_year is not None:
week_starts_Mon = True if week_of_year_start == 0 else False
julian = _calc_julian_from_U_or_W(year, week_of_year, weekday,
week_starts_Mon)
elif iso_year is not None and iso_week is not None:
year, julian = _calc_julian_from_V(iso_year, iso_week, weekday + 1)
if julian is None:
# Cannot pre-calculate datetime_date() since can change in Julian
# calculation and thus could have different value for the day of
# the week calculation.
# Need to add 1 to result since first day of the year is 1, not 0.
julian = datetime_date(year, month, day).toordinal() - \
datetime_date(year, 1, 1).toordinal() + 1
else: # Assume that if they bothered to include Julian day it will
# be accurate.
datetime_result = datetime_date.fromordinal((julian - 1) + datetime_date(year, 1, 1).toordinal())
else: # Assume that if they bothered to include Julian day (or if it was
# calculated above with year/week/weekday) it will be accurate.
datetime_result = datetime_date.fromordinal(
(julian - 1) +
datetime_date(year, 1, 1).toordinal())
year = datetime_result.year
month = datetime_result.month
day = datetime_result.day

View File

@ -152,8 +152,8 @@ class TimeRETests(unittest.TestCase):
"'%s' using '%s'; group 'a' = '%s', group 'b' = %s'" %
(found.string, found.re.pattern, found.group('a'),
found.group('b')))
for directive in ('a','A','b','B','c','d','H','I','j','m','M','p','S',
'U','w','W','x','X','y','Y','Z','%'):
for directive in ('a','A','b','B','c','d','G','H','I','j','m','M','p',
'S','u','U','V','w','W','x','X','y','Y','Z','%'):
compiled = self.time_re.compile("%" + directive)
found = compiled.match(time.strftime("%" + directive))
self.assertTrue(found, "Matching failed on '%s' using '%s' regex" %
@ -218,6 +218,26 @@ class StrptimeTests(unittest.TestCase):
else:
self.fail("'%s' did not raise ValueError" % bad_format)
# Ambiguous or incomplete cases using ISO year/week/weekday directives
# 1. ISO week (%V) is specified, but the year is specified with %Y
# instead of %G
with self.assertRaises(ValueError):
_strptime._strptime("1999 50", "%Y %V")
# 2. ISO year (%G) and ISO week (%V) are specified, but weekday is not
with self.assertRaises(ValueError):
_strptime._strptime("1999 51", "%G %V")
# 3. ISO year (%G) and weekday are specified, but ISO week (%V) is not
for w in ('A', 'a', 'w', 'u'):
with self.assertRaises(ValueError):
_strptime._strptime("1999 51","%G %{}".format(w))
# 4. ISO year is specified alone (e.g. time.strptime('2015', '%G'))
with self.assertRaises(ValueError):
_strptime._strptime("2015", "%G")
# 5. Julian/ordinal day (%j) is specified with %G, but not %Y
with self.assertRaises(ValueError):
_strptime._strptime("1999 256", "%G %j")
def test_strptime_exception_context(self):
# check that this doesn't chain exceptions needlessly (see #17572)
with self.assertRaises(ValueError) as e:
@ -289,7 +309,7 @@ class StrptimeTests(unittest.TestCase):
def test_weekday(self):
# Test weekday directives
for directive in ('A', 'a', 'w'):
for directive in ('A', 'a', 'w', 'u'):
self.helper(directive,6)
def test_julian(self):
@ -458,16 +478,20 @@ class CalculationTests(unittest.TestCase):
# Should be able to infer date if given year, week of year (%U or %W)
# and day of the week
def test_helper(ymd_tuple, test_reason):
for directive in ('W', 'U'):
format_string = "%%Y %%%s %%w" % directive
dt_date = datetime_date(*ymd_tuple)
strp_input = dt_date.strftime(format_string)
strp_output = _strptime._strptime_time(strp_input, format_string)
self.assertTrue(strp_output[:3] == ymd_tuple,
"%s(%s) test failed w/ '%s': %s != %s (%s != %s)" %
(test_reason, directive, strp_input,
strp_output[:3], ymd_tuple,
strp_output[7], dt_date.timetuple()[7]))
for year_week_format in ('%Y %W', '%Y %U', '%G %V'):
for weekday_format in ('%w', '%u', '%a', '%A'):
format_string = year_week_format + ' ' + weekday_format
with self.subTest(test_reason,
date=ymd_tuple,
format=format_string):
dt_date = datetime_date(*ymd_tuple)
strp_input = dt_date.strftime(format_string)
strp_output = _strptime._strptime_time(strp_input,
format_string)
msg = "%r: %s != %s" % (strp_input,
strp_output[7],
dt_date.timetuple()[7])
self.assertEqual(strp_output[:3], ymd_tuple, msg)
test_helper((1901, 1, 3), "week 0")
test_helper((1901, 1, 8), "common case")
test_helper((1901, 1, 13), "day on Sunday")
@ -499,18 +523,25 @@ class CalculationTests(unittest.TestCase):
self.assertEqual(_strptime._strptime_time(value, format)[:-1], expected)
check('2015 0 0', '%Y %U %w', 2014, 12, 28, 0, 0, 0, 6, -3)
check('2015 0 0', '%Y %W %w', 2015, 1, 4, 0, 0, 0, 6, 4)
check('2015 1 1', '%G %V %u', 2014, 12, 29, 0, 0, 0, 0, 363)
check('2015 0 1', '%Y %U %w', 2014, 12, 29, 0, 0, 0, 0, -2)
check('2015 0 1', '%Y %W %w', 2014, 12, 29, 0, 0, 0, 0, -2)
check('2015 1 2', '%G %V %u', 2014, 12, 30, 0, 0, 0, 1, 364)
check('2015 0 2', '%Y %U %w', 2014, 12, 30, 0, 0, 0, 1, -1)
check('2015 0 2', '%Y %W %w', 2014, 12, 30, 0, 0, 0, 1, -1)
check('2015 1 3', '%G %V %u', 2014, 12, 31, 0, 0, 0, 2, 365)
check('2015 0 3', '%Y %U %w', 2014, 12, 31, 0, 0, 0, 2, 0)
check('2015 0 3', '%Y %W %w', 2014, 12, 31, 0, 0, 0, 2, 0)
check('2015 1 4', '%G %V %u', 2015, 1, 1, 0, 0, 0, 3, 1)
check('2015 0 4', '%Y %U %w', 2015, 1, 1, 0, 0, 0, 3, 1)
check('2015 0 4', '%Y %W %w', 2015, 1, 1, 0, 0, 0, 3, 1)
check('2015 1 5', '%G %V %u', 2015, 1, 2, 0, 0, 0, 4, 2)
check('2015 0 5', '%Y %U %w', 2015, 1, 2, 0, 0, 0, 4, 2)
check('2015 0 5', '%Y %W %w', 2015, 1, 2, 0, 0, 0, 4, 2)
check('2015 1 6', '%G %V %u', 2015, 1, 3, 0, 0, 0, 5, 3)
check('2015 0 6', '%Y %U %w', 2015, 1, 3, 0, 0, 0, 5, 3)
check('2015 0 6', '%Y %W %w', 2015, 1, 3, 0, 0, 0, 5, 3)
check('2015 1 7', '%G %V %u', 2015, 1, 4, 0, 0, 0, 6, 4)
class CacheTests(unittest.TestCase):

View File

@ -383,6 +383,9 @@ Library
- Issue #23572: Fixed functools.singledispatch on classes with falsy
metaclasses. Patch by Ethan Furman.
- Issue #12006: Add ISO 8601 year, week, and day directives (%G, %V, %u) to
strptime.
Documentation
-------------