]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
[3.12] gh-120713: Normalize year with century for datetime.strftime (GH-120820) ...
authorSerhiy Storchaka <storchaka@gmail.com>
Sat, 29 Jun 2024 06:54:27 +0000 (09:54 +0300)
committerGitHub <noreply@github.com>
Sat, 29 Jun 2024 06:54:27 +0000 (06:54 +0000)
(cherry picked from commit 6d34938dc8163f4a4bcc68069a1645a7ab76e935)

Co-authored-by: blhsing <blhsing@gmail.com>
Lib/_pydatetime.py
Lib/test/datetimetester.py
Misc/NEWS.d/next/Library/2024-06-21-06-37-46.gh-issue-120713.WBbQx4.rst [new file with mode: 0644]
Modules/_datetimemodule.c
configure
configure.ac
pyconfig.h.in

index ad6292e1e412c44b47bbc32219960073fac47181..d5d4210a35eb8c2ed9701a35bf59b5a58b561d19 100644 (file)
@@ -204,6 +204,17 @@ def _format_offset(off, sep=':'):
                 s += '.%06d' % ss.microseconds
     return s
 
+_normalize_century = None
+def _need_normalize_century():
+    global _normalize_century
+    if _normalize_century is None:
+        try:
+            _normalize_century = (
+                _time.strftime("%Y", (99, 1, 1, 0, 0, 0, 0, 1, 0)) != "0099")
+        except ValueError:
+            _normalize_century = True
+    return _normalize_century
+
 # Correctly substitute for %z and %Z escapes in strftime formats.
 def _wrap_strftime(object, format, timetuple):
     # Don't call utcoffset() or tzname() unless actually needed.
@@ -261,6 +272,14 @@ def _wrap_strftime(object, format, timetuple):
                                 # strftime is going to have at this: escape %
                                 Zreplace = s.replace('%', '%%')
                     newformat.append(Zreplace)
+                elif ch in 'YG' and object.year < 1000 and _need_normalize_century():
+                    # Note that datetime(1000, 1, 1).strftime('%G') == '1000' so
+                    # year 1000 for %G can go on the fast path.
+                    if ch == 'G':
+                        year = int(_time.strftime("%G", timetuple))
+                    else:
+                        year = object.year
+                    push('{:04}'.format(year))
                 else:
                     push('%')
                     push(ch)
index 0528e0701fa982ce4a07ff4d2b10f4a975f8d1a1..ac6a07b2509ad8c2f7c0b5abdb8f6a0b73f9c9b1 100644 (file)
@@ -1687,18 +1687,26 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase):
         self.assertTrue(self.theclass.max)
 
     def test_strftime_y2k(self):
-        for y in (1, 49, 70, 99, 100, 999, 1000, 1970):
-            d = self.theclass(y, 1, 1)
-            # Issue 13305:  For years < 1000, the value is not always
-            # padded to 4 digits across platforms.  The C standard
-            # assumes year >= 1900, so it does not specify the number
-            # of digits.
-            if d.strftime("%Y") != '%04d' % y:
-                # Year 42 returns '42', not padded
-                self.assertEqual(d.strftime("%Y"), '%d' % y)
-                # '0042' is obtained anyway
-                if support.has_strftime_extensions:
-                    self.assertEqual(d.strftime("%4Y"), '%04d' % y)
+        # Test that years less than 1000 are 0-padded; note that the beginning
+        # of an ISO 8601 year may fall in an ISO week of the year before, and
+        # therefore needs an offset of -1 when formatting with '%G'.
+        dataset = (
+            (1, 0),
+            (49, -1),
+            (70, 0),
+            (99, 0),
+            (100, -1),
+            (999, 0),
+            (1000, 0),
+            (1970, 0),
+        )
+        for year, offset in dataset:
+            for specifier in 'YG':
+                with self.subTest(year=year, specifier=specifier):
+                    d = self.theclass(year, 1, 1)
+                    if specifier == 'G':
+                        year += offset
+                    self.assertEqual(d.strftime(f"%{specifier}"), f"{year:04d}")
 
     def test_replace(self):
         cls = self.theclass
diff --git a/Misc/NEWS.d/next/Library/2024-06-21-06-37-46.gh-issue-120713.WBbQx4.rst b/Misc/NEWS.d/next/Library/2024-06-21-06-37-46.gh-issue-120713.WBbQx4.rst
new file mode 100644 (file)
index 0000000..18386a4
--- /dev/null
@@ -0,0 +1,2 @@
+:meth:`datetime.datetime.strftime` now 0-pads years with less than four digits for the format specifiers ``%Y`` and ``%G`` on Linux.
+Patch by Ben Hsing
index 5a062b9c8c0e2ceb1548931bb357d8be82c031e5..632baa06f269501c3c81518b486d547435db9d5f 100644 (file)
@@ -1603,6 +1603,11 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
     const char *ptoappend;      /* ptr to string to append to output buffer */
     Py_ssize_t ntoappend;       /* # of bytes to append to output buffer */
 
+#ifdef Py_NORMALIZE_CENTURY
+    /* Buffer of maximum size of formatted year permitted by long. */
+    char buf[SIZEOF_LONG*5/2+2];
+#endif
+
     assert(object && format && timetuple);
     assert(PyUnicode_Check(format));
     /* Convert the input format to a C string and size */
@@ -1610,6 +1615,11 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
     if (!pin)
         return NULL;
 
+    PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
+    if (strftime == NULL) {
+        goto Done;
+    }
+
     /* Scan the input format, looking for %z/%Z/%f escapes, building
      * a new format.  Since computing the replacements for those codes
      * is expensive, don't unless they're actually used.
@@ -1691,8 +1701,47 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
             ptoappend = PyBytes_AS_STRING(freplacement);
             ntoappend = PyBytes_GET_SIZE(freplacement);
         }
+#ifdef Py_NORMALIZE_CENTURY
+        else if (ch == 'Y' || ch == 'G') {
+            /* 0-pad year with century as necessary */
+            PyObject *item = PyTuple_GET_ITEM(timetuple, 0);
+            long year_long = PyLong_AsLong(item);
+
+            if (year_long == -1 && PyErr_Occurred()) {
+                goto Done;
+            }
+            /* Note that datetime(1000, 1, 1).strftime('%G') == '1000' so year
+               1000 for %G can go on the fast path. */
+            if (year_long >= 1000) {
+                goto PassThrough;
+            }
+            if (ch == 'G') {
+                PyObject *year_str = PyObject_CallFunction(strftime, "sO",
+                                                           "%G", timetuple);
+                if (year_str == NULL) {
+                    goto Done;
+                }
+                PyObject *year = PyNumber_Long(year_str);
+                Py_DECREF(year_str);
+                if (year == NULL) {
+                    goto Done;
+                }
+                year_long = PyLong_AsLong(year);
+                Py_DECREF(year);
+                if (year_long == -1 && PyErr_Occurred()) {
+                    goto Done;
+                }
+            }
+
+            ntoappend = PyOS_snprintf(buf, sizeof(buf), "%04ld", year_long);
+            ptoappend = buf;
+        }
+#endif
         else {
             /* percent followed by something else */
+#ifdef Py_NORMALIZE_CENTURY
+ PassThrough:
+#endif
             ptoappend = pin - 2;
             ntoappend = 2;
         }
@@ -1724,17 +1773,13 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
         goto Done;
     {
         PyObject *format;
-        PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
 
-        if (strftime == NULL)
-            goto Done;
         format = PyUnicode_FromString(PyBytes_AS_STRING(newfmt));
         if (format != NULL) {
             result = PyObject_CallFunctionObjArgs(strftime,
                                                    format, timetuple, NULL);
             Py_DECREF(format);
         }
-        Py_DECREF(strftime);
     }
  Done:
     Py_XDECREF(freplacement);
@@ -1742,6 +1787,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
     Py_XDECREF(colonzreplacement);
     Py_XDECREF(Zreplacement);
     Py_XDECREF(newfmt);
+    Py_XDECREF(strftime);
     return result;
 }
 
index 4dfaeecfc0b2693f61f491a1cfa56913470d8996..d93a4e91f485d7ee5ffdcb52cd9d821e4b492b2b 100755 (executable)
--- a/configure
+++ b/configure
@@ -25687,6 +25687,58 @@ printf "%s\n" "#define HAVE_STAT_TV_NSEC2 1" >>confdefs.h
 
 fi
 
+{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking whether year with century should be normalized for strftime" >&5
+printf %s "checking whether year with century should be normalized for strftime... " >&6; }
+if test ${ac_cv_normalize_century+y}
+then :
+  printf %s "(cached) " >&6
+else $as_nop
+
+if test "$cross_compiling" = yes
+then :
+  ac_cv_normalize_century=yes
+else $as_nop
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <time.h>
+#include <string.h>
+
+int main(void)
+{
+  char year[5];
+  struct tm date = {
+    .tm_year = -1801,
+    .tm_mon = 0,
+    .tm_mday = 1
+  };
+  if (strftime(year, sizeof(year), "%Y", &date) && !strcmp(year, "0099")) {
+    return 1;
+  }
+  return 0;
+}
+
+_ACEOF
+if ac_fn_c_try_run "$LINENO"
+then :
+  ac_cv_normalize_century=yes
+else $as_nop
+  ac_cv_normalize_century=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_normalize_century" >&5
+printf "%s\n" "$ac_cv_normalize_century" >&6; }
+if test "$ac_cv_normalize_century" = yes
+then
+
+printf "%s\n" "#define Py_NORMALIZE_CENTURY 1" >>confdefs.h
+
+fi
+
 have_curses=no
 have_panel=no
 
index 0d6df8e24e4233a792512aeec09065ea840b5893..b46098ae61663cbee0789913e2c01901651ff67a 100644 (file)
@@ -6415,6 +6415,34 @@ then
   [Define if you have struct stat.st_mtimensec])
 fi
 
+AC_CACHE_CHECK([whether year with century should be normalized for strftime], [ac_cv_normalize_century], [
+AC_RUN_IFELSE([AC_LANG_SOURCE([[
+#include <time.h>
+#include <string.h>
+
+int main(void)
+{
+  char year[5];
+  struct tm date = {
+    .tm_year = -1801,
+    .tm_mon = 0,
+    .tm_mday = 1
+  };
+  if (strftime(year, sizeof(year), "%Y", &date) && !strcmp(year, "0099")) {
+    return 1;
+  }
+  return 0;
+}
+]])],
+[ac_cv_normalize_century=yes],
+[ac_cv_normalize_century=no],
+[ac_cv_normalize_century=yes])])
+if test "$ac_cv_normalize_century" = yes
+then
+  AC_DEFINE([Py_NORMALIZE_CENTURY], [1],
+  [Define if year with century should be normalized for strftime.])
+fi
+
 dnl check for ncurses/ncursesw and panel/panelw
 dnl NOTE: old curses is not detected.
 dnl have_curses=[no, ncursesw, ncurses]
index 6d370f6664c10c0b7c28c3ab7781e38c5e9f7bff..b8e3c830fbecf51b25835034d28bfd7b248379ea 100644 (file)
    SipHash13: 3, externally defined: 0 */
 #undef Py_HASH_ALGORITHM
 
+/* Define if year with century should be normalized for strftime. */
+#undef Py_NORMALIZE_CENTURY
+
 /* Define if you want to enable internal statistics gathering. */
 #undef Py_STATS