Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions Lib/_pydatetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
ambv marked this conversation as resolved.

# 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 @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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")
Comment thread
mhsmith marked this conversation as resolved.
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')
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions Modules/_datetimemodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
mhsmith marked this conversation as resolved.
RELEASE_CURRENT_STATE(st, current_mod);
return result;

Expand Down
Loading