Skip to content

Commit 009618f

Browse files
[3.13] gh-120713: Normalize year with century for datetime.strftime (GH-120820) (GH-121144)
(cherry picked from commit 6d34938) Co-authored-by: blhsing <[email protected]>
1 parent cbbd953 commit 009618f

File tree

7 files changed

+174
-16
lines changed

7 files changed

+174
-16
lines changed

Lib/_pydatetime.py

+19
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,17 @@ def _format_offset(off, sep=':'):
204204
s += '.%06d' % ss.microseconds
205205
return s
206206

207+
_normalize_century = None
208+
def _need_normalize_century():
209+
global _normalize_century
210+
if _normalize_century is None:
211+
try:
212+
_normalize_century = (
213+
_time.strftime("%Y", (99, 1, 1, 0, 0, 0, 0, 1, 0)) != "0099")
214+
except ValueError:
215+
_normalize_century = True
216+
return _normalize_century
217+
207218
# Correctly substitute for %z and %Z escapes in strftime formats.
208219
def _wrap_strftime(object, format, timetuple):
209220
# Don't call utcoffset() or tzname() unless actually needed.
@@ -261,6 +272,14 @@ def _wrap_strftime(object, format, timetuple):
261272
# strftime is going to have at this: escape %
262273
Zreplace = s.replace('%', '%%')
263274
newformat.append(Zreplace)
275+
elif ch in 'YG' and object.year < 1000 and _need_normalize_century():
276+
# Note that datetime(1000, 1, 1).strftime('%G') == '1000' so
277+
# year 1000 for %G can go on the fast path.
278+
if ch == 'G':
279+
year = int(_time.strftime("%G", timetuple))
280+
else:
281+
year = object.year
282+
push('{:04}'.format(year))
264283
else:
265284
push('%')
266285
push(ch)

Lib/test/datetimetester.py

+20-12
Original file line numberDiff line numberDiff line change
@@ -1716,18 +1716,26 @@ def test_bool(self):
17161716
self.assertTrue(self.theclass.max)
17171717

17181718
def test_strftime_y2k(self):
1719-
for y in (1, 49, 70, 99, 100, 999, 1000, 1970):
1720-
d = self.theclass(y, 1, 1)
1721-
# Issue 13305: For years < 1000, the value is not always
1722-
# padded to 4 digits across platforms. The C standard
1723-
# assumes year >= 1900, so it does not specify the number
1724-
# of digits.
1725-
if d.strftime("%Y") != '%04d' % y:
1726-
# Year 42 returns '42', not padded
1727-
self.assertEqual(d.strftime("%Y"), '%d' % y)
1728-
# '0042' is obtained anyway
1729-
if support.has_strftime_extensions:
1730-
self.assertEqual(d.strftime("%4Y"), '%04d' % y)
1719+
# Test that years less than 1000 are 0-padded; note that the beginning
1720+
# of an ISO 8601 year may fall in an ISO week of the year before, and
1721+
# therefore needs an offset of -1 when formatting with '%G'.
1722+
dataset = (
1723+
(1, 0),
1724+
(49, -1),
1725+
(70, 0),
1726+
(99, 0),
1727+
(100, -1),
1728+
(999, 0),
1729+
(1000, 0),
1730+
(1970, 0),
1731+
)
1732+
for year, offset in dataset:
1733+
for specifier in 'YG':
1734+
with self.subTest(year=year, specifier=specifier):
1735+
d = self.theclass(year, 1, 1)
1736+
if specifier == 'G':
1737+
year += offset
1738+
self.assertEqual(d.strftime(f"%{specifier}"), f"{year:04d}")
17311739

17321740
def test_replace(self):
17331741
cls = self.theclass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:meth:`datetime.datetime.strftime` now 0-pads years with less than four digits for the format specifiers ``%Y`` and ``%G`` on Linux.
2+
Patch by Ben Hsing

Modules/_datetimemodule.c

+50-4
Original file line numberDiff line numberDiff line change
@@ -1848,13 +1848,23 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
18481848
const char *ptoappend; /* ptr to string to append to output buffer */
18491849
Py_ssize_t ntoappend; /* # of bytes to append to output buffer */
18501850

1851+
#ifdef Py_NORMALIZE_CENTURY
1852+
/* Buffer of maximum size of formatted year permitted by long. */
1853+
char buf[SIZEOF_LONG*5/2+2];
1854+
#endif
1855+
18511856
assert(object && format && timetuple);
18521857
assert(PyUnicode_Check(format));
18531858
/* Convert the input format to a C string and size */
18541859
pin = PyUnicode_AsUTF8AndSize(format, &flen);
18551860
if (!pin)
18561861
return NULL;
18571862

1863+
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
1864+
if (strftime == NULL) {
1865+
goto Done;
1866+
}
1867+
18581868
/* Scan the input format, looking for %z/%Z/%f escapes, building
18591869
* a new format. Since computing the replacements for those codes
18601870
* is expensive, don't unless they're actually used.
@@ -1936,8 +1946,47 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
19361946
ptoappend = PyBytes_AS_STRING(freplacement);
19371947
ntoappend = PyBytes_GET_SIZE(freplacement);
19381948
}
1949+
#ifdef Py_NORMALIZE_CENTURY
1950+
else if (ch == 'Y' || ch == 'G') {
1951+
/* 0-pad year with century as necessary */
1952+
PyObject *item = PyTuple_GET_ITEM(timetuple, 0);
1953+
long year_long = PyLong_AsLong(item);
1954+
1955+
if (year_long == -1 && PyErr_Occurred()) {
1956+
goto Done;
1957+
}
1958+
/* Note that datetime(1000, 1, 1).strftime('%G') == '1000' so year
1959+
1000 for %G can go on the fast path. */
1960+
if (year_long >= 1000) {
1961+
goto PassThrough;
1962+
}
1963+
if (ch == 'G') {
1964+
PyObject *year_str = PyObject_CallFunction(strftime, "sO",
1965+
"%G", timetuple);
1966+
if (year_str == NULL) {
1967+
goto Done;
1968+
}
1969+
PyObject *year = PyNumber_Long(year_str);
1970+
Py_DECREF(year_str);
1971+
if (year == NULL) {
1972+
goto Done;
1973+
}
1974+
year_long = PyLong_AsLong(year);
1975+
Py_DECREF(year);
1976+
if (year_long == -1 && PyErr_Occurred()) {
1977+
goto Done;
1978+
}
1979+
}
1980+
1981+
ntoappend = PyOS_snprintf(buf, sizeof(buf), "%04ld", year_long);
1982+
ptoappend = buf;
1983+
}
1984+
#endif
19391985
else {
19401986
/* percent followed by something else */
1987+
#ifdef Py_NORMALIZE_CENTURY
1988+
PassThrough:
1989+
#endif
19411990
ptoappend = pin - 2;
19421991
ntoappend = 2;
19431992
}
@@ -1969,24 +2018,21 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
19692018
goto Done;
19702019
{
19712020
PyObject *format;
1972-
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
19732021

1974-
if (strftime == NULL)
1975-
goto Done;
19762022
format = PyUnicode_FromString(PyBytes_AS_STRING(newfmt));
19772023
if (format != NULL) {
19782024
result = PyObject_CallFunctionObjArgs(strftime,
19792025
format, timetuple, NULL);
19802026
Py_DECREF(format);
19812027
}
1982-
Py_DECREF(strftime);
19832028
}
19842029
Done:
19852030
Py_XDECREF(freplacement);
19862031
Py_XDECREF(zreplacement);
19872032
Py_XDECREF(colonzreplacement);
19882033
Py_XDECREF(Zreplacement);
19892034
Py_XDECREF(newfmt);
2035+
Py_XDECREF(strftime);
19902036
return result;
19912037
}
19922038

configure

+52
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

configure.ac

+28
Original file line numberDiff line numberDiff line change
@@ -6567,6 +6567,34 @@ then
65676567
[Define if you have struct stat.st_mtimensec])
65686568
fi
65696569

6570+
AC_CACHE_CHECK([whether year with century should be normalized for strftime], [ac_cv_normalize_century], [
6571+
AC_RUN_IFELSE([AC_LANG_SOURCE([[
6572+
#include <time.h>
6573+
#include <string.h>
6574+
6575+
int main(void)
6576+
{
6577+
char year[5];
6578+
struct tm date = {
6579+
.tm_year = -1801,
6580+
.tm_mon = 0,
6581+
.tm_mday = 1
6582+
};
6583+
if (strftime(year, sizeof(year), "%Y", &date) && !strcmp(year, "0099")) {
6584+
return 1;
6585+
}
6586+
return 0;
6587+
}
6588+
]])],
6589+
[ac_cv_normalize_century=yes],
6590+
[ac_cv_normalize_century=no],
6591+
[ac_cv_normalize_century=yes])])
6592+
if test "$ac_cv_normalize_century" = yes
6593+
then
6594+
AC_DEFINE([Py_NORMALIZE_CENTURY], [1],
6595+
[Define if year with century should be normalized for strftime.])
6596+
fi
6597+
65706598
dnl check for ncurses/ncursesw and panel/panelw
65716599
dnl NOTE: old curses is not detected.
65726600
dnl have_curses=[no, ncursesw, ncurses]

pyconfig.h.in

+3
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,9 @@
16591659
SipHash13: 3, externally defined: 0 */
16601660
#undef Py_HASH_ALGORITHM
16611661

1662+
/* Define if year with century should be normalized for strftime. */
1663+
#undef Py_NORMALIZE_CENTURY
1664+
16621665
/* Define if rl_startup_hook takes arguments */
16631666
#undef Py_RL_STARTUP_HOOK_TAKES_ARGS
16641667

0 commit comments

Comments
 (0)