diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index af904a05a47f06..34c7cde9beb710 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2919,6 +2919,17 @@ Notes: for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. + 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 + 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 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 f5ac965d3cfedf..9ea23a51c643b1 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. @@ -291,6 +296,13 @@ def _wrap_strftime(object, format, timetuple): push('{:04}'.format(year)) if ch == 'F': push('-{:02}-{:02}'.format(*timetuple[1:3])) + # 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: push('%') push(ch) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 09a0ef5d223d2b..92492fdbc95c31 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1598,6 +1598,17 @@ def test_strftime(self): self.assertEqual(t.strftime(""), "") # SF bug #761337 self.assertEqual(t.strftime('x'*1000), 'x'*1000) # SF bug #1556784 + # 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.") + 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 @@ -1651,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)) @@ -4115,6 +4149,14 @@ def test_strftime(self): # A naive object replaces %z, %:z and %Z with empty strings. self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''") + # gh-137165: %-H, %-M and %-S are emulated on all platforms. + self.assertEqual(t.strftime('%-H %-M %-S %f'), "1 2 3 000004") + if sys.platform == 'win32': + 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') @@ -4124,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 new file mode 100644 index 00000000000000..ff5cdace91f565 --- /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 +: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 511b444037ceac..3ee1592ae2f6e1 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1876,6 +1876,42 @@ make_freplacement(PyObject *object) return PyUnicode_FromString(freplacement); } +/* The documented numeric format codes for which the %- flag (no zero + * 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) +{ + 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; + } +} + +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) { + return NULL; + } + + 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; +} + /* 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 @@ -1893,6 +1929,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)); @@ -2047,6 +2084,21 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } continue; } + else if (ch == '-' && i < flen + && is_dash_emulated(PyUnicode_READ_CHAR(format, i))) + { + /* %-d etc. -> no zero padding, emulated on all platforms */ + Py_UCS4 next_ch = PyUnicode_READ_CHAR(format, i); + i++; + + Py_XDECREF(dash_replacement); + dash_replacement = make_dash_replacement(strftime, next_ch, + timetuple); + if (dash_replacement == NULL) { + goto Error; + } + replacement = dash_replacement; + } else { /* percent followed by something else */ continue; @@ -2085,6 +2137,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_XDECREF(zreplacement); Py_XDECREF(colonzreplacement); Py_XDECREF(Zreplacement); + Py_XDECREF(dash_replacement); RELEASE_CURRENT_STATE(st, current_mod); return result;