[Python-checkins] cpython: Closes issue #12006: Add ISO 8601 year, week, and day directives to strptime.

alexander.belopolsky python-checkins at python.org
Tue Oct 6 13:30:04 EDT 2015


https://hg.python.org/cpython/rev/acdebfbfbdcf
changeset:   98564:acdebfbfbdcf
user:        Alexander Belopolsky <alexander.belopolsky at gmail.com>
date:        Tue Oct 06 13:29:56 2015 -0400
summary:
  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.

files:
  Doc/library/datetime.rst  |  37 ++++++++++++-
  Doc/whatsnew/3.6.rst      |   8 ++
  Lib/_strptime.py          |  81 ++++++++++++++++++++++----
  Lib/test/test_strptime.py |  57 ++++++++++++++----
  Misc/NEWS                 |   3 +
  5 files changed, 159 insertions(+), 27 deletions(-)


diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst
--- a/Doc/library/datetime.rst
+++ b/Doc/library/datetime.rst
@@ -1909,6 +1909,34 @@
 | ``%%``    | 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 @@
 
 (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
 
diff --git a/Doc/whatsnew/3.6.rst b/Doc/whatsnew/3.6.rst
--- a/Doc/whatsnew/3.6.rst
+++ b/Doc/whatsnew/3.6.rst
@@ -110,6 +110,14 @@
 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
 =============
diff --git a/Lib/_strptime.py b/Lib/_strptime.py
--- a/Lib/_strptime.py
+++ b/Lib/_strptime.py
@@ -195,12 +195,15 @@
             '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 @@
         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 @@
         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 @@
                 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 @@
                 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 @@
             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 @@
                     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
diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py
--- a/Lib/test/test_strptime.py
+++ b/Lib/test/test_strptime.py
@@ -152,8 +152,8 @@
                          "'%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 @@
             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 @@
 
     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 @@
         # 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 @@
             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):
diff --git a/Misc/NEWS b/Misc/NEWS
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -383,6 +383,9 @@
 - 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
 -------------
 

-- 
Repository URL: https://hg.python.org/cpython


More information about the Python-checkins mailing list