summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblhsing <blhsing@gmail.com>2024-06-29 06:32:42 (GMT)
committerGitHub <noreply@github.com>2024-06-29 06:32:42 (GMT)
commit6d34938dc8163f4a4bcc68069a1645a7ab76e935 (patch)
treeedfb0067fe8ef38fc9c1533a5aa12459eb885c31
parent92893fd8dc803ed7cdde55d29d25f84ccb5e3ef0 (diff)
downloadcpython-6d34938dc8163f4a4bcc68069a1645a7ab76e935.zip
cpython-6d34938dc8163f4a4bcc68069a1645a7ab76e935.tar.gz
cpython-6d34938dc8163f4a4bcc68069a1645a7ab76e935.tar.bz2
gh-120713: Normalize year with century for datetime.strftime (GH-120820)
-rw-r--r--Lib/_pydatetime.py19
-rw-r--r--Lib/test/datetimetester.py32
-rw-r--r--Misc/NEWS.d/next/Library/2024-06-21-06-37-46.gh-issue-120713.WBbQx4.rst2
-rw-r--r--Modules/_datetimemodule.c54
-rwxr-xr-xconfigure52
-rw-r--r--configure.ac28
-rw-r--r--pyconfig.h.in3
7 files changed, 174 insertions, 16 deletions
diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py
index 34ccb2d..27cacb8 100644
--- a/Lib/_pydatetime.py
+++ b/Lib/_pydatetime.py
@@ -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)
diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
index b8f69e7..b756413 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -1697,18 +1697,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
index 0000000..18386a4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-06-21-06-37-46.gh-issue-120713.WBbQx4.rst
@@ -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
diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c
index 85595dc..f20efd3 100644
--- a/Modules/_datetimemodule.c
+++ b/Modules/_datetimemodule.c
@@ -1851,6 +1851,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 */
@@ -1858,6 +1863,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.
@@ -1939,8 +1949,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;
}
@@ -1972,17 +2021,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);
@@ -1990,6 +2035,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
Py_XDECREF(colonzreplacement);
Py_XDECREF(Zreplacement);
Py_XDECREF(newfmt);
+ Py_XDECREF(strftime);
return result;
}
diff --git a/configure b/configure
index 527bb0b..58be837 100755
--- a/configure
+++ b/configure
@@ -25952,6 +25952,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
diff --git a/configure.ac b/configure.ac
index 9b8ab70..84ba55e 100644
--- a/configure.ac
+++ b/configure.ac
@@ -6577,6 +6577,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]
diff --git a/pyconfig.h.in b/pyconfig.h.in
index c279b14..a90f936 100644
--- a/pyconfig.h.in
+++ b/pyconfig.h.in
@@ -1659,6 +1659,9 @@
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 rl_startup_hook takes arguments */
#undef Py_RL_STARTUP_HOOK_TAKES_ARGS