Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-122272: guarantee specifiers %F and %C for datetime.strftime to be 0-padded #122436

Merged
Merged
25 changes: 21 additions & 4 deletions Lib/_pydatetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -1710,13 +1710,22 @@ def test_strftime_y2k(self):
(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
34 changes: 28 additions & 6 deletions Modules/_datetimemodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1950,11 +1955,20 @@ 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);
if (!PyTuple_Check(timetuple)) {
goto Done;
blhsing marked this conversation as resolved.
Show resolved Hide resolved
}
PyObject *item = PyTuple_GetItem(timetuple, 0);
blhsing marked this conversation as resolved.
Show resolved Hide resolved
if (item == NULL) {
goto Done;
}
long year_long = PyLong_AsLong(item);

if (year_long == -1 && PyErr_Occurred()) {
goto Done;
}
Expand All @@ -1980,8 +1994,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
Expand Down
52 changes: 52 additions & 0 deletions configure

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions configure.ac
Original file line number Diff line number Diff line change
Expand Up @@ -6700,6 +6700,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 <time.h>
#include <string.h>

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]
Expand Down
3 changes: 3 additions & 0 deletions pyconfig.h.in
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,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

Expand Down
Loading