From 126910edba812a01794f307b0cfa2a7f02bda190 Mon Sep 17 00:00:00 2001 From: blhsing Date: Fri, 23 Aug 2024 23:45:03 +0800 Subject: [PATCH] gh-122272: Guarantee specifiers %F and %C for datetime.strftime to be 0-padded (GH-122436) --- Lib/_pydatetime.py | 25 +++++++-- Lib/test/datetimetester.py | 17 ++++-- ...-07-30-04-27-55.gh-issue-122272.6Wwa1V.rst | 2 + Modules/_datetimemodule.c | 32 +++++++++--- configure | 52 +++++++++++++++++++ configure.ac | 28 ++++++++++ pyconfig.h.in | 3 ++ 7 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-07-30-04-27-55.gh-issue-122272.6Wwa1V.rst diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 27cacb8e01f..78432d46506 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -215,6 +215,17 @@ def _need_normalize_century(): _normalize_century = True return _normalize_century +_supports_c99 = None +def _can_support_c99(): + global _supports_c99 + if _supports_c99 is None: + try: + _supports_c99 = ( + _time.strftime("%F", (1900, 1, 1, 0, 0, 0, 0, 1, 0)) == "1900-01-01") + except ValueError: + _supports_c99 = False + return _supports_c99 + # 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. @@ -272,14 +283,20 @@ 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. + # Note that datetime(1000, 1, 1).strftime('%G') == '1000' so + # year 1000 for %G can go on the fast path. + elif ((ch in 'YG' or ch in 'FC' and _can_support_c99()) and + object.year < 1000 and _need_normalize_century()): if ch == 'G': year = int(_time.strftime("%G", timetuple)) else: year = object.year - push('{:04}'.format(year)) + if ch == 'C': + push('{:02}'.format(year // 100)) + else: + push('{:04}'.format(year)) + if ch == 'F': + push('-{:02}-{:02}'.format(*timetuple[1:3])) else: push('%') push(ch) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 38de1101072..02656434f4a 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1710,13 +1710,22 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase): (1000, 0), (1970, 0), ) - for year, offset in dataset: - for specifier in 'YG': + specifiers = 'YG' + if _time.strftime('%F', (1900, 1, 1, 0, 0, 0, 0, 1, 0)) == '1900-01-01': + specifiers += 'FC' + for year, g_offset in dataset: + for specifier in specifiers: 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}") + year += g_offset + if specifier == 'C': + expected = f"{year // 100:02d}" + else: + expected = f"{year:04d}" + if specifier == 'F': + expected += f"-01-01" + self.assertEqual(d.strftime(f"%{specifier}"), expected) def test_replace(self): cls = self.theclass diff --git a/Misc/NEWS.d/next/Library/2024-07-30-04-27-55.gh-issue-122272.6Wwa1V.rst b/Misc/NEWS.d/next/Library/2024-07-30-04-27-55.gh-issue-122272.6Wwa1V.rst new file mode 100644 index 00000000000..943010b9c16 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-07-30-04-27-55.gh-issue-122272.6Wwa1V.rst @@ -0,0 +1,2 @@ +On some platforms such as Linux, year with century was not 0-padded when formatted by :meth:`~.datetime.strftime` with C99-specific specifiers ``'%C'`` or ``'%F'``. The 0-padding behavior is now guaranteed when the format specifiers ``'%C'`` and ``'%F'`` are supported by the C library. +Patch by Ben Hsing diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 67b49aa6ac2..79314e06c82 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1853,7 +1853,12 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, #ifdef Py_NORMALIZE_CENTURY /* Buffer of maximum size of formatted year permitted by long. */ - char buf[SIZEOF_LONG*5/2+2]; + char buf[SIZEOF_LONG * 5 / 2 + 2 +#ifdef Py_STRFTIME_C99_SUPPORT + /* Need 6 more to accomodate dashes, 2-digit month and day for %F. */ + + 6 +#endif + ]; #endif assert(object && format && timetuple); @@ -1950,11 +1955,18 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, ntoappend = PyBytes_GET_SIZE(freplacement); } #ifdef Py_NORMALIZE_CENTURY - else if (ch == 'Y' || ch == 'G') { + else if (ch == 'Y' || ch == 'G' +#ifdef Py_STRFTIME_C99_SUPPORT + || ch == 'F' || ch == 'C' +#endif + ) { /* 0-pad year with century as necessary */ - PyObject *item = PyTuple_GET_ITEM(timetuple, 0); + PyObject *item = PySequence_GetItem(timetuple, 0); + if (item == NULL) { + goto Done; + } long year_long = PyLong_AsLong(item); - + Py_DECREF(item); if (year_long == -1 && PyErr_Occurred()) { goto Done; } @@ -1980,8 +1992,16 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, goto Done; } } - - ntoappend = PyOS_snprintf(buf, sizeof(buf), "%04ld", year_long); + ntoappend = PyOS_snprintf(buf, sizeof(buf), +#ifdef Py_STRFTIME_C99_SUPPORT + ch == 'F' ? "%04ld-%%m-%%d" : +#endif + "%04ld", year_long); +#ifdef Py_STRFTIME_C99_SUPPORT + if (ch == 'C') { + ntoappend -= 2; + } +#endif ptoappend = buf; } #endif diff --git a/configure b/configure index c28c3335502..2c58af3eace 100755 --- a/configure +++ b/configure @@ -26196,6 +26196,58 @@ printf "%s\n" "#define Py_NORMALIZE_CENTURY 1" >>confdefs.h fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking whether C99-specific strftime specifiers are supported" >&5 +printf %s "checking whether C99-specific strftime specifiers are supported... " >&6; } +if test ${ac_cv_strftime_c99_support+y} +then : + printf %s "(cached) " >&6 +else $as_nop + +if test "$cross_compiling" = yes +then : + ac_cv_strftime_c99_support=no +else $as_nop + cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#include +#include + +int main(void) +{ + char full_date[11]; + struct tm date = { + .tm_year = 0, + .tm_mon = 0, + .tm_mday = 1 + }; + if (strftime(full_date, sizeof(full_date), "%F", &date) && !strcmp(full_date, "1900-01-01")) { + return 0; + } + return 1; +} + +_ACEOF +if ac_fn_c_try_run "$LINENO" +then : + ac_cv_strftime_c99_support=yes +else $as_nop + ac_cv_strftime_c99_support=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_strftime_c99_support" >&5 +printf "%s\n" "$ac_cv_strftime_c99_support" >&6; } +if test "$ac_cv_strftime_c99_support" = yes +then + +printf "%s\n" "#define Py_STRFTIME_C99_SUPPORT 1" >>confdefs.h + +fi + have_curses=no have_panel=no diff --git a/configure.ac b/configure.ac index 9daace71793..3c1dc1cc501 100644 --- a/configure.ac +++ b/configure.ac @@ -6703,6 +6703,34 @@ then [Define if year with century should be normalized for strftime.]) fi +AC_CACHE_CHECK([whether C99-specific strftime specifiers are supported], [ac_cv_strftime_c99_support], [ +AC_RUN_IFELSE([AC_LANG_SOURCE([[ +#include +#include + +int main(void) +{ + char full_date[11]; + struct tm date = { + .tm_year = 0, + .tm_mon = 0, + .tm_mday = 1 + }; + if (strftime(full_date, sizeof(full_date), "%F", &date) && !strcmp(full_date, "1900-01-01")) { + return 0; + } + return 1; +} +]])], +[ac_cv_strftime_c99_support=yes], +[ac_cv_strftime_c99_support=no], +[ac_cv_strftime_c99_support=no])]) +if test "$ac_cv_strftime_c99_support" = yes +then + AC_DEFINE([Py_STRFTIME_C99_SUPPORT], [1], + [Define if C99-specific strftime specifiers are supported.]) +fi + dnl check for ncursesw/ncurses and panelw/panel dnl NOTE: old curses is not detected. dnl have_curses=[no, yes] diff --git a/pyconfig.h.in b/pyconfig.h.in index 39978d11e8c..a5946f3547b 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1701,6 +1701,9 @@ /* Define if you want to enable internal statistics gathering. */ #undef Py_STATS +/* Define if C99-specific strftime specifiers are supported. */ +#undef Py_STRFTIME_C99_SUPPORT + /* The version of SunOS/Solaris as reported by `uname -r' without the dot. */ #undef Py_SUNOS_VERSION