From 2f6c0ce05e2f87f7c1867ea557160434bebe59f2 Mon Sep 17 00:00:00 2001 From: marat Date: Wed, 17 Sep 2025 02:04:35 +0300 Subject: [PATCH 1/2] gh-137165: Add non-zero-padded Windows support for datetime.strftime --- Lib/_pydatetime.py | 15 +++++++ Lib/test/datetimetester.py | 15 +++++++ ...-09-16-15-36-18.gh-issue-137165.AclPcn.rst | 2 + Modules/_datetimemodule.c | 44 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index b6d68f2372850a..50066df792fc7d 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -213,6 +213,11 @@ def _need_normalize_century(): _normalize_century = True return _normalize_century +def _make_dash_replacement(ch, timetuple): + fmt = '%' + ch + val = _time.strftime(fmt, timetuple) + return val.lstrip('0') or '0' + # 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. @@ -284,6 +289,16 @@ def _wrap_strftime(object, format, timetuple): push('{:04}'.format(year)) if ch == 'F': push('-{:02}-{:02}'.format(*timetuple[1:3])) + elif ch == '-': + if i < n: + next_ch = format[i] + i += 1 + if sys.platform.startswith('win') or sys.platform.startswith('android'): + push(_make_dash_replacement(next_ch, timetuple)) + else: + push('%-' + next_ch) + else: + push('%-') else: push('%') push(ch) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 2299d1fab2e73d..f509ad1cc9fafd 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -14,6 +14,7 @@ import textwrap import unittest import warnings +import platform from array import array @@ -1588,6 +1589,15 @@ def test_strftime(self): self.assertEqual(t.strftime(""), "") # SF bug #761337 self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 + # SF bug #137165 + if platform.system() == 'Darwin': + self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") + elif platform.system() == 'Windows': + self.assertEqual(t.strftime("m:%#m d:%#d y:%#y"), "m:3 d:2 y:5") + self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:5") + else: + self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:5") + self.assertRaises(TypeError, t.strftime) # needs an arg self.assertRaises(TypeError, t.strftime, "one", "two") # too many args self.assertRaises(TypeError, t.strftime, 42) # arg wrong type @@ -3890,6 +3900,11 @@ def test_strftime(self): # A naive object replaces %z, %:z and %Z with empty strings. self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''") + # SF bug #137165 + self.assertEqual(t.strftime('%-H %-M %-S %f'), "1 2 3 000004") + if platform.system() == 'Windows': + self.assertEqual(t.strftime('%#H %#M %#S %f'), "1 2 3 000004") + # bpo-34482: Check that surrogates don't cause a crash. try: t.strftime('%H\ud800%M') diff --git a/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst b/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst new file mode 100644 index 00000000000000..bd4ab63eb19efe --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst @@ -0,0 +1,2 @@ +Add support for Windows non-zero-padded formatting directives in +:func:`datetime.datetime.strftime` (e.g., ``"m:%-m d:%-d y:%-y"``). diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 12d316985fceb9..c8cc958ebf9cee 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1856,6 +1856,39 @@ make_freplacement(PyObject *object) return PyUnicode_FromString(freplacement); } +#if defined(MS_WINDOWS) || defined(__ANDROID__) +static PyObject * +make_dash_replacement(PyObject *object, Py_UCS4 ch, PyObject *timetuple) +{ + PyObject *strftime = PyImport_ImportModuleAttrString("time", "strftime"); + if (!strftime) { + return NULL; + } + + char fmt[3] = {'%', (char)ch, 0}; + PyObject *fmt_obj = PyUnicode_FromString(fmt); + if (!fmt_obj) { + Py_DECREF(strftime); + return NULL; + } + + PyObject *res = PyObject_CallFunctionObjArgs(strftime, fmt_obj, timetuple, NULL); + Py_DECREF(fmt_obj); + Py_DECREF(strftime); + if (!res) { + return NULL; + } + + PyObject *stripped = PyObject_CallMethod(res, "lstrip", "s", "0"); + Py_DECREF(res); + if (!stripped) { + return NULL; + } + + return stripped; +} +#endif + /* I sure don't want to reproduce the strftime code from the time module, * so this imports the module and calls it. All the hair is due to * giving special meanings to the %z, %:z, %Z and %f format codes via a @@ -2002,6 +2035,17 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } continue; } + #if defined(MS_WINDOWS) || defined(__ANDROID__) + /* non-0-pad Windows support */ + else if (ch == '-' && i < flen) { + Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); + i++; + replacement = make_dash_replacement(object, next_ch, timetuple); + if (replacement == NULL) { + goto Error; + } + } + #endif else { /* percent followed by something else */ continue; From 8d169011f13a6d6b84c3131c361c1065d95fdfaf Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 18 Sep 2025 03:13:51 +0300 Subject: [PATCH 2/2] Refactor make_dash_replacement func and add negative test --- Lib/test/datetimetester.py | 5 +- ...-09-16-15-36-18.gh-issue-137165.AclPcn.rst | 2 - ...-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst | 3 ++ Modules/_datetimemodule.c | 46 +++++++++++-------- 4 files changed, 33 insertions(+), 23 deletions(-) delete mode 100644 Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst create mode 100644 Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index f509ad1cc9fafd..5031f2629e7e95 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1592,10 +1592,9 @@ def test_strftime(self): # SF bug #137165 if platform.system() == 'Darwin': self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") - elif platform.system() == 'Windows': - self.assertEqual(t.strftime("m:%#m d:%#d y:%#y"), "m:3 d:2 y:5") - self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:5") else: + if platform.system() == 'Windows': + self.assertEqual(t.strftime("m:%#m d:%#d y:%#y"), "m:3 d:2 y:5") self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:5") self.assertRaises(TypeError, t.strftime) # needs an arg diff --git a/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst b/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst deleted file mode 100644 index bd4ab63eb19efe..00000000000000 --- a/Misc/NEWS.d/next/Library/2025-09-16-15-36-18.gh-issue-137165.AclPcn.rst +++ /dev/null @@ -1,2 +0,0 @@ -Add support for Windows non-zero-padded formatting directives in -:func:`datetime.datetime.strftime` (e.g., ``"m:%-m d:%-d y:%-y"``). diff --git a/Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst b/Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst new file mode 100644 index 00000000000000..eb8c562f28dfc8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst @@ -0,0 +1,3 @@ +Standardized non-zero-padded date formatting in +:func:`datetime.datetime.strftime` and :func:`datetime.date.strftime` across +Windows and Unix. (e.g. ``"m:%-m d:%-d y:%-y"``). diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index c8cc958ebf9cee..38321a7cb5114b 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1860,32 +1860,42 @@ make_freplacement(PyObject *object) static PyObject * make_dash_replacement(PyObject *object, Py_UCS4 ch, PyObject *timetuple) { - PyObject *strftime = PyImport_ImportModuleAttrString("time", "strftime"); - if (!strftime) { - return NULL; + PyObject *strftime = NULL; + PyObject *fmt_obj = NULL; + PyObject *res = NULL; + PyObject *stripped = NULL; + + strftime = PyImport_ImportModuleAttrString("time", "strftime"); + if (strftime == NULL) { + goto error; } - char fmt[3] = {'%', (char)ch, 0}; - PyObject *fmt_obj = PyUnicode_FromString(fmt); - if (!fmt_obj) { - Py_DECREF(strftime); - return NULL; + fmt_obj = PyUnicode_FromFormat("%%%c", (char)ch); + if (fmt_obj == NULL) { + goto error; } - PyObject *res = PyObject_CallFunctionObjArgs(strftime, fmt_obj, timetuple, NULL); - Py_DECREF(fmt_obj); - Py_DECREF(strftime); - if (!res) { - return NULL; + res = PyObject_CallFunctionObjArgs(strftime, fmt_obj, timetuple, NULL); + if (res == NULL) { + goto error; } - PyObject *stripped = PyObject_CallMethod(res, "lstrip", "s", "0"); - Py_DECREF(res); - if (!stripped) { - return NULL; + stripped = PyObject_CallMethod(res, "lstrip", "s", "0"); + if (stripped == NULL) { + goto error; } + Py_DECREF(fmt_obj); + Py_DECREF(strftime); + Py_DECREF(res); return stripped; + +error: + Py_XDECREF(fmt_obj); + Py_XDECREF(strftime); + Py_XDECREF(res); + Py_XDECREF(stripped); + return NULL; } #endif @@ -2036,7 +2046,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, continue; } #if defined(MS_WINDOWS) || defined(__ANDROID__) - /* non-0-pad Windows support */ + /* non-0-pad Windows and Android support */ else if (ch == '-' && i < flen) { Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); i++;