diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 7010f99c54da0a..6c92a2505f07d3 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2766,6 +2766,13 @@ 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, + which is still zero-padded. + (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 b6d68f2372850a..3475c2740931d6 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 in ['win32', '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..618bbce6b931d3 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,16 @@ 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'): + 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 @@ -3890,6 +3901,14 @@ 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 + 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/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..13ed317e60dfb8 --- /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. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 12d316985fceb9..7bab779a37d15a 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1856,6 +1856,54 @@ 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 = 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; + } + + res = PyObject_CallFunctionObjArgs(strftime, fmt_obj, timetuple, NULL); + 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"); + } + + 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 + /* 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 @@ -1873,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)); @@ -2002,6 +2051,20 @@ 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) { + Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); + i++; + + Py_XDECREF(dash_replacement); + dash_replacement = make_dash_replacement(object, next_ch, timetuple); + if (dash_replacement == NULL) { + goto Error; + } + replacement = dash_replacement; + } + #endif else { /* percent followed by something else */ continue; @@ -2040,6 +2103,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_XDECREF(zreplacement); Py_XDECREF(colonzreplacement); Py_XDECREF(Zreplacement); + Py_XDECREF(dash_replacement); Py_XDECREF(strftime); return result;