gh-120713: Normalize year with century for datetime.strftime (GH-120820)

This commit is contained in:
blhsing 2024-06-29 14:32:42 +08:00 committed by GitHub
parent 92893fd8dc
commit 6d34938dc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 174 additions and 16 deletions

View 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)

View File

@ -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

View File

@ -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

View File

@ -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;
}

52
configure generated vendored
View File

@ -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

View File

@ -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]

View File

@ -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