Skip to content

Commit 6d34938

Browse files
authored
gh-120713: Normalize year with century for datetime.strftime (GH-120820)
1 parent 92893fd commit 6d34938

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
@@ -1697,18 +1697,26 @@ def test_bool(self):
16971697
self.assertTrue(self.theclass.max)
16981698

16991699
def test_strftime_y2k(self):
1700-
for y in (1, 49, 70, 99, 100, 999, 1000, 1970):
1701-
d = self.theclass(y, 1, 1)
1702-
# Issue 13305: For years < 1000, the value is not always
1703-
# padded to 4 digits across platforms. The C standard
1704-
# assumes year >= 1900, so it does not specify the number
1705-
# of digits.
1706-
if d.strftime("%Y") != '%04d' % y:
1707-
# Year 42 returns '42', not padded
1708-
self.assertEqual(d.strftime("%Y"), '%d' % y)
1709-
# '0042' is obtained anyway
1710-
if support.has_strftime_extensions:
1711-
self.assertEqual(d.strftime("%4Y"), '%04d' % y)
1700+
# Test that years less than 1000 are 0-padded; note that the beginning
1701+
# of an ISO 8601 year may fall in an ISO week of the year before, and
1702+
# therefore needs an offset of -1 when formatting with '%G'.
1703+
dataset = (
1704+
(1, 0),
1705+
(49, -1),
1706+
(70, 0),
1707+
(99, 0),
1708+
(100, -1),
1709+
(999, 0),
1710+
(1000, 0),
1711+
(1970, 0),
1712+
)
1713+
for year, offset in dataset:
1714+
for specifier in 'YG':
1715+
with self.subTest(year=year, specifier=specifier):
1716+
d = self.theclass(year, 1, 1)
1717+
if specifier == 'G':
1718+
year += offset
1719+
self.assertEqual(d.strftime(f"%{specifier}"), f"{year:04d}")
17121720

17131721
def test_replace(self):
17141722
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
@@ -1851,13 +1851,23 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
18511851
const char *ptoappend; /* ptr to string to append to output buffer */
18521852
Py_ssize_t ntoappend; /* # of bytes to append to output buffer */
18531853

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

1866+
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
1867+
if (strftime == NULL) {
1868+
goto Done;
1869+
}
1870+
18611871
/* Scan the input format, looking for %z/%Z/%f escapes, building
18621872
* a new format. Since computing the replacements for those codes
18631873
* is expensive, don't unless they're actually used.
@@ -1939,8 +1949,47 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
19391949
ptoappend = PyBytes_AS_STRING(freplacement);
19401950
ntoappend = PyBytes_GET_SIZE(freplacement);
19411951
}
1952+
#ifdef Py_NORMALIZE_CENTURY
1953+
else if (ch == 'Y' || ch == 'G') {
1954+
/* 0-pad year with century as necessary */
1955+
PyObject *item = PyTuple_GET_ITEM(timetuple, 0);
1956+
long year_long = PyLong_AsLong(item);
1957+
1958+
if (year_long == -1 && PyErr_Occurred()) {
1959+
goto Done;
1960+
}
1961+
/* Note that datetime(1000, 1, 1).strftime('%G') == '1000' so year
1962+
1000 for %G can go on the fast path. */
1963+
if (year_long >= 1000) {
1964+
goto PassThrough;
1965+
}
1966+
if (ch == 'G') {
1967+
PyObject *year_str = PyObject_CallFunction(strftime, "sO",
1968+
"%G", timetuple);
1969+
if (year_str == NULL) {
1970+
goto Done;
1971+
}
1972+
PyObject *year = PyNumber_Long(year_str);
1973+
Py_DECREF(year_str);
1974+
if (year == NULL) {
1975+
goto Done;
1976+
}
1977+
year_long = PyLong_AsLong(year);
1978+
Py_DECREF(year);
1979+
if (year_long == -1 && PyErr_Occurred()) {
1980+
goto Done;
1981+
}
1982+
}
1983+
1984+
ntoappend = PyOS_snprintf(buf, sizeof(buf), "%04ld", year_long);
1985+
ptoappend = buf;
1986+
}
1987+
#endif
19421988
else {
19431989
/* percent followed by something else */
1990+
#ifdef Py_NORMALIZE_CENTURY
1991+
PassThrough:
1992+
#endif
19441993
ptoappend = pin - 2;
19451994
ntoappend = 2;
19461995
}
@@ -1972,24 +2021,21 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
19722021
goto Done;
19732022
{
19742023
PyObject *format;
1975-
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
19762024

1977-
if (strftime == NULL)
1978-
goto Done;
19792025
format = PyUnicode_FromString(PyBytes_AS_STRING(newfmt));
19802026
if (format != NULL) {
19812027
result = PyObject_CallFunctionObjArgs(strftime,
19822028
format, timetuple, NULL);
19832029
Py_DECREF(format);
19842030
}
1985-
Py_DECREF(strftime);
19862031
}
19872032
Done:
19882033
Py_XDECREF(freplacement);
19892034
Py_XDECREF(zreplacement);
19902035
Py_XDECREF(colonzreplacement);
19912036
Py_XDECREF(Zreplacement);
19922037
Py_XDECREF(newfmt);
2038+
Py_XDECREF(strftime);
19932039
return result;
19942040
}
19952041

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
@@ -6577,6 +6577,34 @@ then
65776577
[Define if you have struct stat.st_mtimensec])
65786578
fi
65796579

6580+
AC_CACHE_CHECK([whether year with century should be normalized for strftime], [ac_cv_normalize_century], [
6581+
AC_RUN_IFELSE([AC_LANG_SOURCE([[
6582+
#include <time.h>
6583+
#include <string.h>
6584+
6585+
int main(void)
6586+
{
6587+
char year[5];
6588+
struct tm date = {
6589+
.tm_year = -1801,
6590+
.tm_mon = 0,
6591+
.tm_mday = 1
6592+
};
6593+
if (strftime(year, sizeof(year), "%Y", &date) && !strcmp(year, "0099")) {
6594+
return 1;
6595+
}
6596+
return 0;
6597+
}
6598+
]])],
6599+
[ac_cv_normalize_century=yes],
6600+
[ac_cv_normalize_century=no],
6601+
[ac_cv_normalize_century=yes])])
6602+
if test "$ac_cv_normalize_century" = yes
6603+
then
6604+
AC_DEFINE([Py_NORMALIZE_CENTURY], [1],
6605+
[Define if year with century should be normalized for strftime.])
6606+
fi
6607+
65806608
dnl check for ncurses/ncursesw and panel/panelw
65816609
dnl NOTE: old curses is not detected.
65826610
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)