Skip to content

Commit 027902b

Browse files
[3.12] gh-120713: Normalize year with century for datetime.strftime (GH-120820) (GH-121145)
(cherry picked from commit 6d34938) Co-authored-by: blhsing <[email protected]>
1 parent 21a9536 commit 027902b

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
@@ -1687,18 +1687,26 @@ def test_bool(self):
16871687
self.assertTrue(self.theclass.max)
16881688

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

17031711
def test_replace(self):
17041712
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
@@ -1603,13 +1603,23 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
16031603
const char *ptoappend; /* ptr to string to append to output buffer */
16041604
Py_ssize_t ntoappend; /* # of bytes to append to output buffer */
16051605

1606+
#ifdef Py_NORMALIZE_CENTURY
1607+
/* Buffer of maximum size of formatted year permitted by long. */
1608+
char buf[SIZEOF_LONG*5/2+2];
1609+
#endif
1610+
16061611
assert(object && format && timetuple);
16071612
assert(PyUnicode_Check(format));
16081613
/* Convert the input format to a C string and size */
16091614
pin = PyUnicode_AsUTF8AndSize(format, &flen);
16101615
if (!pin)
16111616
return NULL;
16121617

1618+
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
1619+
if (strftime == NULL) {
1620+
goto Done;
1621+
}
1622+
16131623
/* Scan the input format, looking for %z/%Z/%f escapes, building
16141624
* a new format. Since computing the replacements for those codes
16151625
* is expensive, don't unless they're actually used.
@@ -1691,8 +1701,47 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
16911701
ptoappend = PyBytes_AS_STRING(freplacement);
16921702
ntoappend = PyBytes_GET_SIZE(freplacement);
16931703
}
1704+
#ifdef Py_NORMALIZE_CENTURY
1705+
else if (ch == 'Y' || ch == 'G') {
1706+
/* 0-pad year with century as necessary */
1707+
PyObject *item = PyTuple_GET_ITEM(timetuple, 0);
1708+
long year_long = PyLong_AsLong(item);
1709+
1710+
if (year_long == -1 && PyErr_Occurred()) {
1711+
goto Done;
1712+
}
1713+
/* Note that datetime(1000, 1, 1).strftime('%G') == '1000' so year
1714+
1000 for %G can go on the fast path. */
1715+
if (year_long >= 1000) {
1716+
goto PassThrough;
1717+
}
1718+
if (ch == 'G') {
1719+
PyObject *year_str = PyObject_CallFunction(strftime, "sO",
1720+
"%G", timetuple);
1721+
if (year_str == NULL) {
1722+
goto Done;
1723+
}
1724+
PyObject *year = PyNumber_Long(year_str);
1725+
Py_DECREF(year_str);
1726+
if (year == NULL) {
1727+
goto Done;
1728+
}
1729+
year_long = PyLong_AsLong(year);
1730+
Py_DECREF(year);
1731+
if (year_long == -1 && PyErr_Occurred()) {
1732+
goto Done;
1733+
}
1734+
}
1735+
1736+
ntoappend = PyOS_snprintf(buf, sizeof(buf), "%04ld", year_long);
1737+
ptoappend = buf;
1738+
}
1739+
#endif
16941740
else {
16951741
/* percent followed by something else */
1742+
#ifdef Py_NORMALIZE_CENTURY
1743+
PassThrough:
1744+
#endif
16961745
ptoappend = pin - 2;
16971746
ntoappend = 2;
16981747
}
@@ -1724,24 +1773,21 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
17241773
goto Done;
17251774
{
17261775
PyObject *format;
1727-
PyObject *strftime = _PyImport_GetModuleAttrString("time", "strftime");
17281776

1729-
if (strftime == NULL)
1730-
goto Done;
17311777
format = PyUnicode_FromString(PyBytes_AS_STRING(newfmt));
17321778
if (format != NULL) {
17331779
result = PyObject_CallFunctionObjArgs(strftime,
17341780
format, timetuple, NULL);
17351781
Py_DECREF(format);
17361782
}
1737-
Py_DECREF(strftime);
17381783
}
17391784
Done:
17401785
Py_XDECREF(freplacement);
17411786
Py_XDECREF(zreplacement);
17421787
Py_XDECREF(colonzreplacement);
17431788
Py_XDECREF(Zreplacement);
17441789
Py_XDECREF(newfmt);
1790+
Py_XDECREF(strftime);
17451791
return result;
17461792
}
17471793

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
@@ -6415,6 +6415,34 @@ then
64156415
[Define if you have struct stat.st_mtimensec])
64166416
fi
64176417

6418+
AC_CACHE_CHECK([whether year with century should be normalized for strftime], [ac_cv_normalize_century], [
6419+
AC_RUN_IFELSE([AC_LANG_SOURCE([[
6420+
#include <time.h>
6421+
#include <string.h>
6422+
6423+
int main(void)
6424+
{
6425+
char year[5];
6426+
struct tm date = {
6427+
.tm_year = -1801,
6428+
.tm_mon = 0,
6429+
.tm_mday = 1
6430+
};
6431+
if (strftime(year, sizeof(year), "%Y", &date) && !strcmp(year, "0099")) {
6432+
return 1;
6433+
}
6434+
return 0;
6435+
}
6436+
]])],
6437+
[ac_cv_normalize_century=yes],
6438+
[ac_cv_normalize_century=no],
6439+
[ac_cv_normalize_century=yes])])
6440+
if test "$ac_cv_normalize_century" = yes
6441+
then
6442+
AC_DEFINE([Py_NORMALIZE_CENTURY], [1],
6443+
[Define if year with century should be normalized for strftime.])
6444+
fi
6445+
64186446
dnl check for ncurses/ncursesw and panel/panelw
64196447
dnl NOTE: old curses is not detected.
64206448
dnl have_curses=[no, ncursesw, ncurses]

pyconfig.h.in

+3
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,9 @@
16181618
SipHash13: 3, externally defined: 0 */
16191619
#undef Py_HASH_ALGORITHM
16201620

1621+
/* Define if year with century should be normalized for strftime. */
1622+
#undef Py_NORMALIZE_CENTURY
1623+
16211624
/* Define if you want to enable internal statistics gathering. */
16221625
#undef Py_STATS
16231626

0 commit comments

Comments
 (0)