From 2f6c0ce05e2f87f7c1867ea557160434bebe59f2 Mon Sep 17 00:00:00 2001 From: marat Date: Wed, 17 Sep 2025 02:04:35 +0300 Subject: [PATCH 01/19] 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 02/19] 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++; From 11e021843d37e47e3822bf091bbb88f513e022a7 Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 18 Sep 2025 16:42:13 +0300 Subject: [PATCH 03/19] Fix tests for IOS and add docs --- Doc/library/datetime.rst | 8 +++++++- Lib/test/datetimetester.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 7010f99c54da0a..433afa62af4168 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2762,11 +2762,17 @@ Notes: interchangeable. (9) + When used with the :meth:`~.datetime.strftime` method, the leading zero is optional + for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, + ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` still requires a leading zero). + Use the ``%-`` flag to produce non-zero-padded output (e.g. ``%-d``). + +(10) When used with the :meth:`~.datetime.strptime` method, the leading zero is optional for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. -(10) +(11) When parsing a month and day using :meth:`~.datetime.strptime`, always include a year in the format. If the value you need to parse lacks a year, append an explicit dummy leap year. Otherwise your code will raise an diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 5031f2629e7e95..dad6bdcda26d9b 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1590,13 +1590,15 @@ def test_strftime(self): self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 # SF bug #137165 - if platform.system() == 'Darwin': + if platform.system() in ('Darwin', 'iOS'): self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") 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.assertEqual(t.strftime("%-j. %-U. %-W. %-V."), "61. 9. 9. 9.") + 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 From eb5c88393ae921033ba845071267d3d48712edfd Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 18 Sep 2025 17:10:39 +0300 Subject: [PATCH 04/19] Update docs and news entry --- Doc/library/datetime.rst | 2 +- .../Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst | 3 --- .../Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst | 3 +++ 3 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst create mode 100644 Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 433afa62af4168..4778e4622d92c6 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2764,7 +2764,7 @@ Notes: (9) When used with the :meth:`~.datetime.strftime` method, the leading zero is optional for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, - ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` still requires a leading zero). + ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` always produces a leading zero). Use the ``%-`` flag to produce non-zero-padded output (e.g. ``%-d``). (10) 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 deleted file mode 100644 index eb8c562f28dfc8..00000000000000 --- a/Misc/NEWS.d/next/Library/2025-09-18-03-02-39.gh-issue-137165.7tJ6DS.rst +++ /dev/null @@ -1,3 +0,0 @@ -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/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst new file mode 100644 index 00000000000000..3299a7f9f00412 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst @@ -0,0 +1,3 @@ +Standardized non-zero-padded numeric formatting for dates and times in +:func:`datetime.datetime.strftime` and :func:`datetime.date.strftime` across +all platforms (e.g. ``"m:%-m d:%-d y:%-y"``). From 224654c50605d842697e3368a9bdac9c0f047575 Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 18 Sep 2025 18:45:25 +0300 Subject: [PATCH 05/19] Fix 0 seconds/minutes strip and update docs --- Doc/library/datetime.rst | 5 +++-- Lib/test/datetimetester.py | 7 +++++-- Modules/_datetimemodule.c | 4 ++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 4778e4622d92c6..6ec23be6e12bab 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2764,8 +2764,9 @@ Notes: (9) When used with the :meth:`~.datetime.strftime` method, the leading zero is optional for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, - ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` always produces a leading zero). - Use the ``%-`` flag to produce non-zero-padded output (e.g. ``%-d``). + ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` always produces + a leading zero). Use the ``%-`` flag to produce non-zero-padded output + (for example, ``%-d``). (10) When used with the :meth:`~.datetime.strptime` method, the leading zero is optional diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index dad6bdcda26d9b..40120c36fa61e9 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1589,7 +1589,7 @@ 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 + # See gh-137165 if platform.system() in ('Darwin', 'iOS'): self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") else: @@ -3901,11 +3901,14 @@ 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 + # See gh-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") + t_zero = self.theclass(0, 0, 0, 4) + self.assertEqual(t_zero.strftime('%-H %-M %-S %f'), "0 0 0 000004") + # bpo-34482: Check that surrogates don't cause a crash. try: t.strftime('%H\ud800%M') diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 38321a7cb5114b..25add396a22eef 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1885,6 +1885,10 @@ make_dash_replacement(PyObject *object, Py_UCS4 ch, PyObject *timetuple) goto error; } + if (PyUnicode_GET_LENGTH(stripped) == 0) { + stripped = PyUnicode_FromString("0"); + } + Py_DECREF(fmt_obj); Py_DECREF(strftime); Py_DECREF(res); From 551f16e36774f597939a5d24cbd418f6c21d572e Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 18 Sep 2025 19:15:48 +0300 Subject: [PATCH 06/19] Update news entry --- .../next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst index 3299a7f9f00412..13ed317e60dfb8 100644 --- a/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst +++ b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst @@ -1,3 +1,3 @@ Standardized non-zero-padded numeric formatting for dates and times in :func:`datetime.datetime.strftime` and :func:`datetime.date.strftime` across -all platforms (e.g. ``"m:%-m d:%-d y:%-y"``). +all platforms. From ddc75ed650ca2282acceb1a5cb433f92aef555ed Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 19 Sep 2025 07:46:54 +0300 Subject: [PATCH 07/19] Unify strftime/ptime notes and refactor code --- Doc/library/datetime.rst | 13 ++++++------- Lib/_pydatetime.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 6ec23be6e12bab..afb4bbc03cff17 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2762,17 +2762,16 @@ Notes: interchangeable. (9) - When used with the :meth:`~.datetime.strftime` method, the leading zero is optional - for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, - ``%W``, ``%V`` and ``%y`` (except that on Apple platforms, ``%y`` always produces - a leading zero). Use the ``%-`` flag to produce non-zero-padded output - (for example, ``%-d``). - -(10) When used with the :meth:`~.datetime.strptime` method, the leading zero is optional for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. + When used with the :meth:`~.datetime.strftime` method, the leading zero is optional + for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, + ``%W``, ``%V`` and ``%y``. The ``%-`` flag (for example, ``%-d``) will produce + non-zero-padded output, except for ``%-y`` on Apple platforms, which is still + zero-padded. + (11) When parsing a month and day using :meth:`~.datetime.strptime`, always include a year in the format. If the value you need to parse lacks a year, diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 50066df792fc7d..3475c2740931d6 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -293,7 +293,7 @@ def _wrap_strftime(object, format, timetuple): if i < n: next_ch = format[i] i += 1 - if sys.platform.startswith('win') or sys.platform.startswith('android'): + if sys.platform in ['win32', 'android']: push(_make_dash_replacement(next_ch, timetuple)) else: push('%-' + next_ch) From a51dad631ac7caafd45e14e2d3b495d9c4233ca8 Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 19 Sep 2025 07:47:59 +0300 Subject: [PATCH 08/19] Update notes numbers --- Doc/library/datetime.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index afb4bbc03cff17..f047eb99f27417 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2772,7 +2772,7 @@ Notes: non-zero-padded output, except for ``%-y`` on Apple platforms, which is still zero-padded. -(11) +(10) When parsing a month and day using :meth:`~.datetime.strptime`, always include a year in the format. If the value you need to parse lacks a year, append an explicit dummy leap year. Otherwise your code will raise an From 0e5ef8b6389aac0dae25db5da29cf41c70b49b5f Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 19 Sep 2025 13:46:26 +0300 Subject: [PATCH 09/19] Update datetime.rst --- Doc/library/datetime.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index f047eb99f27417..53736540e6431a 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2766,8 +2766,8 @@ Notes: for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. - When used with the :meth:`~.datetime.strftime` method, the leading zero is optional - for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, + When used with the :meth:`~.datetime.strftime` method, leading zeroes are included + by default for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. The ``%-`` flag (for example, ``%-d``) will produce non-zero-padded output, except for ``%-y`` on Apple platforms, which is still zero-padded. From 375cf41de30a7dbc0befe73dc073989411535bdf Mon Sep 17 00:00:00 2001 From: marat Date: Tue, 23 Sep 2025 18:49:25 +0300 Subject: [PATCH 10/19] Fix refleaks and tests for FreeBSD --- Doc/library/datetime.rst | 4 ++-- Lib/test/datetimetester.py | 2 +- Modules/_datetimemodule.c | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 53736540e6431a..7c169bac5b2e3b 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2769,8 +2769,8 @@ Notes: When used with the :meth:`~.datetime.strftime` method, leading zeroes are included by default for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. The ``%-`` flag (for example, ``%-d``) will produce - non-zero-padded output, except for ``%-y`` on Apple platforms, which is still - zero-padded. + non-zero-padded output, except for ``%-y`` on Apple platforms and FreeBSD, + which is still zero-padded. (10) When parsing a month and day using :meth:`~.datetime.strptime`, always diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 40120c36fa61e9..618bbce6b931d3 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1590,7 +1590,7 @@ def test_strftime(self): self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 # See gh-137165 - if platform.system() in ('Darwin', 'iOS'): + if platform.system() in ('Darwin', 'iOS', 'FreeBSD'): self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") else: if platform.system() == 'Windows': diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 25add396a22eef..df5c7083c32542 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1886,6 +1886,7 @@ make_dash_replacement(PyObject *object, Py_UCS4 ch, PyObject *timetuple) } if (PyUnicode_GET_LENGTH(stripped) == 0) { + Py_DECREF(stripped); stripped = PyUnicode_FromString("0"); } From 40d0e1c149dbff7ff31a2e78be4b7c4bf5317ff0 Mon Sep 17 00:00:00 2001 From: marat Date: Wed, 24 Sep 2025 10:48:57 +0300 Subject: [PATCH 11/19] Fix refleaks --- Modules/_datetimemodule.c | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index df5c7083c32542..950591b3415603 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -2055,10 +2055,25 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, 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) { + + PyObject *tmp = make_dash_replacement(object, next_ch, timetuple); + if (tmp == NULL) { + Py_DECREF(tmp); goto Error; } + + if (PyUnicodeWriter_WriteSubstring(writer, format, start, end) < 0) { + Py_DECREF(tmp); + goto Error; + } + start = i; + if (PyUnicodeWriter_WriteStr(writer, tmp) < 0) { + Py_DECREF(tmp); + goto Error; + } + + Py_DECREF(tmp); + continue; } #endif else { From 2054356e5e96ff1882d25ec45316c0101f07cd97 Mon Sep 17 00:00:00 2001 From: marat Date: Wed, 24 Sep 2025 11:03:29 +0300 Subject: [PATCH 12/19] Fix linting --- Modules/_datetimemodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 950591b3415603..22c3186c746073 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -2055,7 +2055,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, else if (ch == '-' && i < flen) { Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); i++; - + PyObject *tmp = make_dash_replacement(object, next_ch, timetuple); if (tmp == NULL) { Py_DECREF(tmp); From 6ff5f4f385b895bacf6b01d7eb2c8b5aa4d6276f Mon Sep 17 00:00:00 2001 From: marat Date: Thu, 25 Sep 2025 21:27:57 +0300 Subject: [PATCH 13/19] Refactor wrap_strftime func --- Modules/_datetimemodule.c | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 22c3186c746073..7f55bc50b78fcb 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1959,6 +1959,8 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, i++; /* A % has been seen and ch is the character after it. */ PyObject *replacement = NULL; + int need_decref_replacement = 0; + if (ch == 'z') { /* %z -> +HHMM */ if (zreplacement == NULL) { @@ -2058,22 +2060,11 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, PyObject *tmp = make_dash_replacement(object, next_ch, timetuple); if (tmp == NULL) { - Py_DECREF(tmp); - goto Error; - } - - if (PyUnicodeWriter_WriteSubstring(writer, format, start, end) < 0) { - Py_DECREF(tmp); - goto Error; - } - start = i; - if (PyUnicodeWriter_WriteStr(writer, tmp) < 0) { - Py_DECREF(tmp); goto Error; } - Py_DECREF(tmp); - continue; + replacement = tmp; + need_decref_replacement = 1; } #endif else { @@ -2083,12 +2074,21 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, assert(replacement != NULL); assert(PyUnicode_Check(replacement)); if (PyUnicodeWriter_WriteSubstring(writer, format, start, end) < 0) { + if (need_decref_replacement) { + Py_DECREF(replacement); + } goto Error; } start = i; if (PyUnicodeWriter_WriteStr(writer, replacement) < 0) { + if (need_decref_replacement) { + Py_DECREF(replacement); + } goto Error; } + if (need_decref_replacement) { + Py_DECREF(replacement); + } } /* end while() */ PyObject *newformat; From d5f2014681d7d9d8eb2a692388ba9993d043e2a3 Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 26 Sep 2025 16:05:20 +0300 Subject: [PATCH 14/19] Make replacement less complex --- Modules/_datetimemodule.c | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 7f55bc50b78fcb..6d76bc10ed3de9 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1921,6 +1921,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, PyObject *colonzreplacement = NULL; /* py string, replacement for %:z */ PyObject *Zreplacement = NULL; /* py string, replacement for %Z */ PyObject *freplacement = NULL; /* py string, replacement for %f */ + PyObject *dash_replacement = NULL; /* py string, replacement for %- */ assert(object && format && timetuple); assert(PyUnicode_Check(format)); @@ -2058,13 +2059,12 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); i++; - PyObject *tmp = make_dash_replacement(object, next_ch, timetuple); - if (tmp == NULL) { + Py_XDECREF(dash_replacement); + dash_replacement = make_dash_replacement(object, next_ch, timetuple); + if (dash_replacement == NULL) { goto Error; } - - replacement = tmp; - need_decref_replacement = 1; + replacement = dash_replacement; } #endif else { @@ -2074,21 +2074,12 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, assert(replacement != NULL); assert(PyUnicode_Check(replacement)); if (PyUnicodeWriter_WriteSubstring(writer, format, start, end) < 0) { - if (need_decref_replacement) { - Py_DECREF(replacement); - } goto Error; } start = i; if (PyUnicodeWriter_WriteStr(writer, replacement) < 0) { - if (need_decref_replacement) { - Py_DECREF(replacement); - } goto Error; } - if (need_decref_replacement) { - Py_DECREF(replacement); - } } /* end while() */ PyObject *newformat; @@ -2115,6 +2106,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_XDECREF(colonzreplacement); Py_XDECREF(Zreplacement); Py_XDECREF(strftime); + Py_XDECREF(dash_replacement); return result; Error: From 0d53cedac90a0ad7810540d8c8cd30a1353dc44b Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 26 Sep 2025 16:29:13 +0300 Subject: [PATCH 15/19] Remove useless variable --- Modules/_datetimemodule.c | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 6d76bc10ed3de9..98fa5eb62f156a 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1960,7 +1960,6 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, i++; /* A % has been seen and ch is the character after it. */ PyObject *replacement = NULL; - int need_decref_replacement = 0; if (ch == 'z') { /* %z -> +HHMM */ From 9212a5affcd796423a0b9fafd99a77e578c7840d Mon Sep 17 00:00:00 2001 From: marat Date: Wed, 1 Oct 2025 23:32:15 +0300 Subject: [PATCH 16/19] Docs formatting and code style --- Doc/library/datetime.rst | 9 +++++---- Modules/_datetimemodule.c | 3 +-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 7c169bac5b2e3b..6c92a2505f07d3 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2766,10 +2766,11 @@ Notes: for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. - When used with the :meth:`~.datetime.strftime` method, leading zeroes are included - by default for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, - ``%W``, ``%V`` and ``%y``. The ``%-`` flag (for example, ``%-d``) will produce - non-zero-padded output, except for ``%-y`` on Apple platforms and FreeBSD, + When used with the :meth:`~.datetime.strftime` method, leading zeroes + are included by default for formats ``%d``, ``%m``, ``%H``, ``%I``, + ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. + The ``%-`` flag (for example, ``%-d``) will produce non-zero-padded + output, except for ``%-y`` on Apple platforms and FreeBSD, which is still zero-padded. (10) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 98fa5eb62f156a..7bab779a37d15a 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1960,7 +1960,6 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, i++; /* A % has been seen and ch is the character after it. */ PyObject *replacement = NULL; - if (ch == 'z') { /* %z -> +HHMM */ if (zreplacement == NULL) { @@ -2104,8 +2103,8 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_XDECREF(zreplacement); Py_XDECREF(colonzreplacement); Py_XDECREF(Zreplacement); - Py_XDECREF(strftime); Py_XDECREF(dash_replacement); + Py_XDECREF(strftime); return result; Error: From f74ad49b0b5cacf0f57ddee700f2803794c051d6 Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Wed, 21 Oct 2026 10:23:02 -0700 Subject: [PATCH 17/19] gh-137165: Only emulate `%-` for the documented numeric codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows and Android, every `%-X` was emulated by formatting `%X` and stripping leading zeros. That misbehaved for anything outside the documented numeric codes: non-ASCII was narrowed to a byte (`%-Ť` formatted the day, `%-é` raised OverflowError), `%-%` stuffed an unescaped `%` into the format (`%-%d` gave the day), and `%-z`/`%-Z` bypassed datetime's tzinfo handling. Emulate only `%-d`, `%-m`, `%-H`, `%-I`, `%-M`, `%-S`, `%-j`, `%-U`, `%-W`, `%-V` and `%-y`, and leave everything else to the platform as before. Gate the pure-Python branch the same way, so it no longer consumes `%-%` as a unit on other platforms, which made `%-%f` differ from the C implementation. Use the cached time.strftime instead of importing it on every call. Co-authored-by: Muse Code (Meta Muse Spark) --- Lib/_pydatetime.py | 16 ++++----- Lib/test/datetimetester.py | 23 ++++++++++++ Modules/_datetimemodule.c | 73 +++++++++++++++++--------------------- 3 files changed, 61 insertions(+), 51 deletions(-) diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 8124f155a85610..26de468a500700 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -296,16 +296,12 @@ 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 in ['win32', 'android']: - push(_make_dash_replacement(next_ch, timetuple)) - else: - push('%-' + next_ch) - else: - push('%-') + # The platform's strftime() lacks the %- flag (no zero + # padding), so emulate it for the documented numeric codes. + elif (ch == '-' and i < n and format[i] in 'dmHIMSjUWVy' and + sys.platform in ['win32', 'android']): + push(_make_dash_replacement(format[i], timetuple)) + i += 1 else: push('%') push(ch) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 529f3dfb80ce40..e02f6884cfc706 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1662,6 +1662,29 @@ def test_strftime_trailing_percent(self): _time.strftime("m:03 d:02 y:05 %", t.timetuple()), ) + def test_strftime_dash_flag_passthrough(self): + # gh-137165: The %- flag is only emulated for the documented numeric + # codes. Anything else after %- is left to the platform's strftime, + # so check that datetime's strftime acts the same as time.strftime. + t = self.theclass(2005, 3, 2) + tt = t.timetuple() + + def outcome(func, *args): + try: + return func(*args) + except ValueError: + return ValueError + + for fmt in ['%-', '%-#', '%-e', '%-Y', '%-z', '%-Z', '%-%d', '%-\0', + '%-é', '%-Ť', '%-中']: # low byte of 'Ť' is 'd' + with self.subTest(fmt=fmt): + self.assertEqual(outcome(t.strftime, fmt), + outcome(_time.strftime, fmt, tt)) + + # Format codes handled by datetime itself still work after %-. + self.assertEqual(outcome(t.strftime, '%-%f'), + outcome(_time.strftime, '%-000000', tt)) + def test_format(self): dt = self.theclass(2007, 9, 10) self.assertEqual(dt.__format__(''), str(dt)) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 2d86ff39bc5f4d..9fb973b821857b 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1877,50 +1877,38 @@ make_freplacement(PyObject *object) } #if defined(MS_WINDOWS) || defined(__ANDROID__) -static PyObject * -make_dash_replacement(PyObject *object, Py_UCS4 ch, PyObject *timetuple) +/* The documented numeric format codes for which the %- flag (no zero + * padding) is emulated. Their output is digits only, so it needs no + * escaping before being stuffed into the format. + */ +static int +is_dash_emulated(Py_UCS4 ch) { - PyObject *strftime = NULL; - PyObject *fmt_obj = NULL; - PyObject *res = NULL; - PyObject *stripped = NULL; - - strftime = PyImport_ImportModuleAttrString("time", "strftime"); - if (strftime == NULL) { - goto error; - } - - fmt_obj = PyUnicode_FromFormat("%%%c", (char)ch); - if (fmt_obj == NULL) { - goto error; + switch (ch) { + case 'd': case 'm': case 'H': case 'I': case 'M': case 'S': + case 'j': case 'U': case 'W': case 'V': case 'y': + return 1; + default: + return 0; } +} - res = PyObject_CallFunctionObjArgs(strftime, fmt_obj, timetuple, NULL); +static PyObject * +make_dash_replacement(PyObject *strftime, Py_UCS4 ch, PyObject *timetuple) +{ + assert(is_dash_emulated(ch)); + char fmt[3] = {'%', (char)ch, '\0'}; + PyObject *res = PyObject_CallFunction(strftime, "sO", fmt, timetuple); if (res == NULL) { - goto error; - } - - stripped = PyObject_CallMethod(res, "lstrip", "s", "0"); - if (stripped == NULL) { - goto error; - } - - if (PyUnicode_GET_LENGTH(stripped) == 0) { - Py_DECREF(stripped); - stripped = PyUnicode_FromString("0"); + return NULL; } - Py_DECREF(fmt_obj); - Py_DECREF(strftime); + PyObject *stripped = PyObject_CallMethod(res, "lstrip", "s", "0"); Py_DECREF(res); + if (stripped != NULL && PyUnicode_GET_LENGTH(stripped) == 0) { + Py_SETREF(stripped, PyUnicode_FromString("0")); + } return stripped; - -error: - Py_XDECREF(fmt_obj); - Py_XDECREF(strftime); - Py_XDECREF(res); - Py_XDECREF(stripped); - return NULL; } #endif @@ -2096,20 +2084,23 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } continue; } - #if defined(MS_WINDOWS) || defined(__ANDROID__) - /* non-0-pad Windows and Android support */ - else if (ch == '-' && i < flen) { +#if defined(MS_WINDOWS) || defined(__ANDROID__) + else if (ch == '-' && i < flen + && is_dash_emulated(PyUnicode_READ_CHAR(format, i))) + { + /* %-d etc. -> no zero padding; the platform lacks this flag */ Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); i++; Py_XDECREF(dash_replacement); - dash_replacement = make_dash_replacement(object, next_ch, timetuple); + dash_replacement = make_dash_replacement(strftime, next_ch, + timetuple); if (dash_replacement == NULL) { goto Error; } replacement = dash_replacement; } - #endif +#endif else { /* percent followed by something else */ continue; From ddf17eca39bb5865ce17b18fb0d437865187ffd6 Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Thu, 22 Oct 2026 17:13:33 -0700 Subject: [PATCH 18/19] gh-137165: Complete BSD support for non-zero-padded strftime Emulate documented numeric dash flags on OpenBSD and account for NetBSD's native zero padding of the short year. Cover 12-hour formatting at midnight, noon and single-digit hours, document the version introducing support, and include datetime.time.strftime in the NEWS entry. Co-authored-by: Muse Code (Meta Muse Spark) --- Doc/library/datetime.rst | 5 ++++- Lib/_pydatetime.py | 3 ++- Lib/test/datetimetester.py | 11 ++++++++++- .../2025-09-18-17-08-08.gh-issue-137165.sJase0.rst | 4 ++-- Modules/_datetimemodule.c | 4 ++-- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index b793e5bd8e22f4..aac683c649a181 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2923,9 +2923,12 @@ Notes: are included by default for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. The ``%-`` flag (for example, ``%-d``) will produce non-zero-padded - output, except for ``%-y`` on Apple platforms and FreeBSD, + output, except for ``%-y`` on Apple platforms, FreeBSD and NetBSD, which is still zero-padded. + .. versionchanged:: 3.16 + Support for the ``%-`` flag was added on Windows, Android and OpenBSD. + (10) When parsing a month and day using :meth:`~.datetime.strptime`, always include a year in the format. If the value you need to parse lacks a year, diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 26de468a500700..c4c390f84410d5 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -299,7 +299,8 @@ def _wrap_strftime(object, format, timetuple): # The platform's strftime() lacks the %- flag (no zero # padding), so emulate it for the documented numeric codes. elif (ch == '-' and i < n and format[i] in 'dmHIMSjUWVy' and - sys.platform in ['win32', 'android']): + (sys.platform in ('win32', 'android') or + sys.platform.startswith('openbsd'))): push(_make_dash_replacement(format[i], timetuple)) i += 1 else: diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index e02f6884cfc706..826b8753546303 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1600,7 +1600,7 @@ def test_strftime(self): self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 # See gh-137165 - if platform.system() in ('Darwin', 'iOS', 'FreeBSD'): + if platform.system() in ('Darwin', 'iOS', 'FreeBSD', 'NetBSD'): self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") else: if platform.system() == 'Windows': @@ -4166,6 +4166,15 @@ def test_strftime(self): # gh-85432: The parameter was named "fmt" in the pure-Python impl. t.strftime(format="%f") + def test_strftime_dash_flag_12_hour(self): + # gh-137165: Midnight and noon are both 12 in the 12-hour clock. + for hour, expected in ((0, '12'), (1, '1'), (12, '12'), + (13, '1'), (23, '11')): + with self.subTest(hour=hour): + t = self.theclass(hour) + self.assertEqual(t.strftime('%-I'), expected) + self.assertEqual(t.strftime('%I'), expected.zfill(2)) + def test_strftime_special(self): t = self.theclass(1, 2, 3, 4) s1 = t.strftime('%I%p%Z') diff --git a/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst index 13ed317e60dfb8..ff5cdace91f565 100644 --- a/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst +++ b/Misc/NEWS.d/next/Library/2025-09-18-17-08-08.gh-issue-137165.sJase0.rst @@ -1,3 +1,3 @@ Standardized non-zero-padded numeric formatting for dates and times in -:func:`datetime.datetime.strftime` and :func:`datetime.date.strftime` across -all platforms. +:meth:`datetime.datetime.strftime`, :meth:`datetime.date.strftime` and +:meth:`datetime.time.strftime` across all platforms. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 9fb973b821857b..9a37f32d525208 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1876,7 +1876,7 @@ make_freplacement(PyObject *object) return PyUnicode_FromString(freplacement); } -#if defined(MS_WINDOWS) || defined(__ANDROID__) +#if defined(MS_WINDOWS) || defined(__ANDROID__) || defined(__OpenBSD__) /* The documented numeric format codes for which the %- flag (no zero * padding) is emulated. Their output is digits only, so it needs no * escaping before being stuffed into the format. @@ -2084,7 +2084,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } continue; } -#if defined(MS_WINDOWS) || defined(__ANDROID__) +#if defined(MS_WINDOWS) || defined(__ANDROID__) || defined(__OpenBSD__) else if (ch == '-' && i < flen && is_dash_emulated(PyUnicode_READ_CHAR(format, i))) { From 1022b42b324751d12a5017ee86207e35c27ebf53 Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Fri, 23 Oct 2026 23:20:47 +0200 Subject: [PATCH 19/19] gh-137165: Emulate %- flag on all platforms for consistency Address review findings from gpshead: - Emulate the documented numeric dash flags unconditionally in both C and pure-Python implementations, instead of gating by platform. This removes the hardcoded Windows/Android/OpenBSD list (and its duplication), fixes %-y on Apple, FreeBSD and NetBSD where the native libc ignores the flag, and makes the NEWS claim of "across all platforms" accurate. No probe is needed since emulation via %X + lstrip('0') matches native output where the flag already works. - Tests no longer branch by platform.system(), which misclassified iPadOS, tvOS, watchOS, visionOS and DragonFly. Expectations are now unconditional, with an explanatory comment for %-y. Windows %# checks use sys.platform, and the out-of-order `import platform` is gone. - Docs: clarify the strftime paragraph in note (9) with an explicit "For formatting..." transition (kept under the same note number per mhsmith to avoid table clutter), and document that %- is emulated where the platform lacks it. The %-%-d edge case is left as is: it shares the existing %-%f quirk deliberately pinned by test_strftime_dash_flag_passthrough. Co-authored-by: Muse Code (Meta Muse Spark) --- Doc/library/datetime.rst | 15 ++++++++------- Lib/_pydatetime.py | 10 +++++----- Lib/test/datetimetester.py | 20 ++++++++++---------- Modules/_datetimemodule.c | 12 +++++------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index aac683c649a181..34c7cde9beb710 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2919,15 +2919,16 @@ Notes: for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. - When used with the :meth:`~.datetime.strftime` method, leading zeroes - are included by default for formats ``%d``, ``%m``, ``%H``, ``%I``, - ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. - The ``%-`` flag (for example, ``%-d``) will produce non-zero-padded - output, except for ``%-y`` on Apple platforms, FreeBSD and NetBSD, - which is still zero-padded. + For formatting with the :meth:`~.datetime.strftime` method, leading + zeroes are included by default for formats ``%d``, ``%m``, ``%H``, + ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, ``%V`` and ``%y``. + The ``%-`` flag (for example, ``%-d``) produces non-zero-padded + output on all platforms. .. versionchanged:: 3.16 - Support for the ``%-`` flag was added on Windows, Android and OpenBSD. + The ``%-`` flag is now supported on all platforms. It is emulated + where the platform C library lacks it, including on Windows, Android + and OpenBSD, and for ``%-y`` on Apple platforms, FreeBSD and NetBSD. (10) When parsing a month and day using :meth:`~.datetime.strptime`, always diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index c4c390f84410d5..9ea23a51c643b1 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -296,11 +296,11 @@ def _wrap_strftime(object, format, timetuple): push('{:04}'.format(year)) if ch == 'F': push('-{:02}-{:02}'.format(*timetuple[1:3])) - # The platform's strftime() lacks the %- flag (no zero - # padding), so emulate it for the documented numeric codes. - elif (ch == '-' and i < n and format[i] in 'dmHIMSjUWVy' and - (sys.platform in ('win32', 'android') or - sys.platform.startswith('openbsd'))): + # Emulate the %- flag (no zero padding) for the documented + # numeric codes, so behavior is consistent on all platforms. + # Some libcs lack the flag (Windows, Android, OpenBSD), + # others ignore it for %-y (Apple, FreeBSD, NetBSD). + elif ch == '-' and i < n and format[i] in 'dmHIMSjUWVy': push(_make_dash_replacement(format[i], timetuple)) i += 1 else: diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 826b8753546303..92492fdbc95c31 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -15,7 +15,6 @@ import textwrap import unittest import warnings -import platform from array import array @@ -1599,13 +1598,14 @@ def test_strftime(self): self.assertEqual(t.strftime(""), "") # SF bug #761337 self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 - # See gh-137165 - if platform.system() in ('Darwin', 'iOS', 'FreeBSD', 'NetBSD'): - self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:05") - 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") + # gh-137165: The %- flag (no zero padding) is emulated for the + # documented numeric codes on all platforms, so expectations are + # the same everywhere. In particular, %-y gives "5" here even + # though Apple, FreeBSD and NetBSD libcs still zero-pad it natively. + self.assertEqual(t.strftime("m:%-m d:%-d y:%-y"), "m:3 d:2 y:5") + if sys.platform == 'win32': + # Windows' native %# flag still passes through to the platform. + self.assertEqual(t.strftime("m:%#m d:%#d y:%#y"), "m:3 d:2 y:5") self.assertEqual(t.strftime("%-j. %-U. %-W. %-V."), "61. 9. 9. 9.") @@ -4149,9 +4149,9 @@ def test_strftime(self): # A naive object replaces %z, %:z and %Z with empty strings. self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''") - # See gh-137165 + # gh-137165: %-H, %-M and %-S are emulated on all platforms. self.assertEqual(t.strftime('%-H %-M %-S %f'), "1 2 3 000004") - if platform.system() == 'Windows': + if sys.platform == 'win32': self.assertEqual(t.strftime('%#H %#M %#S %f'), "1 2 3 000004") t_zero = self.theclass(0, 0, 0, 4) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 9a37f32d525208..3ee1592ae2f6e1 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1876,10 +1876,11 @@ make_freplacement(PyObject *object) return PyUnicode_FromString(freplacement); } -#if defined(MS_WINDOWS) || defined(__ANDROID__) || defined(__OpenBSD__) /* The documented numeric format codes for which the %- flag (no zero - * padding) is emulated. Their output is digits only, so it needs no - * escaping before being stuffed into the format. + * padding) is emulated on all platforms, so behavior is consistent. + * Some libcs lack the flag (Windows, Android, OpenBSD), others ignore + * it for %-y (Apple, FreeBSD, NetBSD). Their output is digits only, + * so it needs no escaping before being stuffed into the format. */ static int is_dash_emulated(Py_UCS4 ch) @@ -1910,7 +1911,6 @@ make_dash_replacement(PyObject *strftime, Py_UCS4 ch, PyObject *timetuple) } 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 @@ -2084,11 +2084,10 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } continue; } -#if defined(MS_WINDOWS) || defined(__ANDROID__) || defined(__OpenBSD__) else if (ch == '-' && i < flen && is_dash_emulated(PyUnicode_READ_CHAR(format, i))) { - /* %-d etc. -> no zero padding; the platform lacks this flag */ + /* %-d etc. -> no zero padding, emulated on all platforms */ Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); i++; @@ -2100,7 +2099,6 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } replacement = dash_replacement; } -#endif else { /* percent followed by something else */ continue;