Skip to content

gh-69142: add %:z strftime format code #95983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Aug 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 14 additions & 2 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,11 @@ convenience. These parameters all correspond to ISO 8601 date values.
| | Week 01 is the week containing | | |
| | Jan 4. | | |
+-----------+--------------------------------+------------------------+-------+
| ``%:z`` | UTC offset in the form | (empty), +00:00, | \(6) |
| | ``±HH:MM[:SS[.ffffff]]`` | -04:00, +10:30, | |
| | (empty string if the object is | +06:34:15, | |
| | naive). | -03:07:12.345216 | |
+-----------+--------------------------------+------------------------+-------+

These may not be available on all platforms when used with the :meth:`strftime`
method. The ISO 8601 year and ISO 8601 week directives are not interchangeable
Expand All @@ -2458,6 +2463,9 @@ differences between platforms in handling of unsupported format specifiers.
.. versionadded:: 3.6
``%G``, ``%u`` and ``%V`` were added.

.. versionadded:: 3.12
``%:z`` was added.

Technical Detail
^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -2530,8 +2538,8 @@ Notes:
available).

(6)
For a naive object, the ``%z`` and ``%Z`` format codes are replaced by empty
strings.
For a naive object, the ``%z``, ``%:z`` and ``%Z`` format codes are replaced
by empty strings.

For an aware object:

Expand All @@ -2557,6 +2565,10 @@ Notes:
For example, ``'+01:00:00'`` will be parsed as an offset of one hour.
In addition, providing ``'Z'`` is identical to ``'+00:00'``.

``%:z``
Behaves exactly as ``%z``, but has a colon separator added between
hours, minutes and seconds.

``%Z``
In :meth:`strftime`, ``%Z`` is replaced by an empty string if
:meth:`tzname` returns ``None``; otherwise ``%Z`` is replaced by the
Expand Down
45 changes: 24 additions & 21 deletions Lib/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def _format_time(hh, mm, ss, us, timespec='auto'):
else:
return fmt.format(hh, mm, ss, us)

def _format_offset(off):
def _format_offset(off, sep=':'):
s = ''
if off is not None:
if off.days < 0:
Expand All @@ -189,9 +189,9 @@ def _format_offset(off):
sign = "+"
hh, mm = divmod(off, timedelta(hours=1))
mm, ss = divmod(mm, timedelta(minutes=1))
s += "%s%02d:%02d" % (sign, hh, mm)
s += "%s%02d%s%02d" % (sign, hh, sep, mm)
if ss or ss.microseconds:
s += ":%02d" % ss.seconds
s += "%s%02d" % (sep, ss.seconds)

if ss.microseconds:
s += '.%06d' % ss.microseconds
Expand All @@ -202,9 +202,10 @@ def _wrap_strftime(object, format, timetuple):
# Don't call utcoffset() or tzname() unless actually needed.
freplace = None # the string to use for %f
zreplace = None # the string to use for %z
colonzreplace = None # the string to use for %:z
Zreplace = None # the string to use for %Z

# Scan format for %z and %Z escapes, replacing as needed.
# Scan format for %z, %:z and %Z escapes, replacing as needed.
newformat = []
push = newformat.append
i, n = 0, len(format)
Expand All @@ -222,26 +223,28 @@ def _wrap_strftime(object, format, timetuple):
newformat.append(freplace)
elif ch == 'z':
if zreplace is None:
zreplace = ""
if hasattr(object, "utcoffset"):
offset = object.utcoffset()
if offset is not None:
sign = '+'
if offset.days < 0:
offset = -offset
sign = '-'
h, rest = divmod(offset, timedelta(hours=1))
m, rest = divmod(rest, timedelta(minutes=1))
s = rest.seconds
u = offset.microseconds
if u:
zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u)
elif s:
zreplace = '%c%02d%02d%02d' % (sign, h, m, s)
else:
zreplace = '%c%02d%02d' % (sign, h, m)
zreplace = _format_offset(object.utcoffset(), sep="")
else:
zreplace = ""
assert '%' not in zreplace
newformat.append(zreplace)
elif ch == ':':
if i < n:
ch2 = format[i]
i += 1
if ch2 == 'z':
if colonzreplace is None:
if hasattr(object, "utcoffset"):
colonzreplace = _format_offset(object.utcoffset(), sep=":")
else:
colonzreplace = ""
assert '%' not in colonzreplace
newformat.append(colonzreplace)
else:
push('%')
push(ch)
push(ch2)
elif ch == 'Z':
if Zreplace is None:
Zreplace = ""
Expand Down
21 changes: 11 additions & 10 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -1463,8 +1463,8 @@ def test_strftime(self):
# test that unicode input is allowed (issue 2782)
self.assertEqual(t.strftime("%m"), "03")

# A naive object replaces %z and %Z w/ empty strings.
self.assertEqual(t.strftime("'%z' '%Z'"), "'' ''")
# A naive object replaces %z, %:z and %Z w/ empty strings.
self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''")

#make sure that invalid format specifiers are handled correctly
#self.assertRaises(ValueError, t.strftime, "%e")
Expand Down Expand Up @@ -1528,7 +1528,7 @@ def strftime(self, format_spec):

for fmt in ["m:%m d:%d y:%y",
"m:%m d:%d y:%y H:%H M:%M S:%S",
"%z %Z",
"%z %:z %Z",
]:
self.assertEqual(dt.__format__(fmt), dt.strftime(fmt))
self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
Expand Down Expand Up @@ -2134,7 +2134,7 @@ def strftime(self, format_spec):

for fmt in ["m:%m d:%d y:%y",
"m:%m d:%d y:%y H:%H M:%M S:%S",
"%z %Z",
"%z %:z %Z",
]:
self.assertEqual(dt.__format__(fmt), dt.strftime(fmt))
self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
Expand Down Expand Up @@ -2777,6 +2777,7 @@ def test_more_strftime(self):
tz = timezone(-timedelta(hours=2, seconds=s, microseconds=us))
t = t.replace(tzinfo=tz)
self.assertEqual(t.strftime("%z"), "-0200" + z)
self.assertEqual(t.strftime("%:z"), "-02:00:" + z)

# bpo-34482: Check that surrogates don't cause a crash.
try:
Expand Down Expand Up @@ -3515,8 +3516,8 @@ def test_1653736(self):
def test_strftime(self):
t = self.theclass(1, 2, 3, 4)
self.assertEqual(t.strftime('%H %M %S %f'), "01 02 03 000004")
# A naive object replaces %z and %Z with empty strings.
self.assertEqual(t.strftime("'%z' '%Z'"), "'' ''")
# A naive object replaces %z, %:z and %Z with empty strings.
self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''")

# bpo-34482: Check that surrogates don't cause a crash.
try:
Expand Down Expand Up @@ -3934,10 +3935,10 @@ def test_zones(self):
self.assertEqual(repr(t4), d + "(0, 0, 0, 40)")
self.assertEqual(repr(t5), d + "(0, 0, 0, 40, tzinfo=utc)")

self.assertEqual(t1.strftime("%H:%M:%S %%Z=%Z %%z=%z"),
"07:47:00 %Z=EST %z=-0500")
self.assertEqual(t2.strftime("%H:%M:%S %Z %z"), "12:47:00 UTC +0000")
self.assertEqual(t3.strftime("%H:%M:%S %Z %z"), "13:47:00 MET +0100")
self.assertEqual(t1.strftime("%H:%M:%S %%Z=%Z %%z=%z %%:z=%:z"),
"07:47:00 %Z=EST %z=-0500 %:z=-05:00")
self.assertEqual(t2.strftime("%H:%M:%S %Z %z %:z"), "12:47:00 UTC +0000 +00:00")
self.assertEqual(t3.strftime("%H:%M:%S %Z %z %:z"), "13:47:00 MET +0100 +01:00")

yuck = FixedOffset(-1439, "%z %Z %%z%%Z")
t1 = time(23, 59, tzinfo=yuck)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``%:z`` strftime format code (generates tzoffset with colons as separator), see :ref:`strftime-strptime-behavior`.
64 changes: 42 additions & 22 deletions Modules/_datetimemodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,27 @@ format_utcoffset(char *buf, size_t buflen, const char *sep,
return 0;
}

static PyObject *
make_somezreplacement(PyObject *object, char *sep, PyObject *tzinfoarg)
{
char buf[100];
PyObject *tzinfo = get_tzinfo_member(object);

if (tzinfo == Py_None || tzinfo == NULL) {
return PyBytes_FromStringAndSize(NULL, 0);
}

assert(tzinfoarg != NULL);
if (format_utcoffset(buf,
sizeof(buf),
sep,
tzinfo,
tzinfoarg) < 0)
return NULL;

return PyBytes_FromStringAndSize(buf, strlen(buf));
}

static PyObject *
make_Zreplacement(PyObject *object, PyObject *tzinfoarg)
{
Expand Down Expand Up @@ -1566,7 +1587,7 @@ make_freplacement(PyObject *object)

/* 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 and %f format codes via a
* giving special meanings to the %z, %:z, %Z and %f format codes via a
* preprocessing step on the format string.
* tzinfoarg is the argument to pass to the object's tzinfo method, if
* needed.
Expand All @@ -1578,6 +1599,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
PyObject *result = NULL; /* guilty until proved innocent */

PyObject *zreplacement = NULL; /* py string, replacement for %z */
PyObject *colonzreplacement = NULL; /* py string, replacement for %:z */
PyObject *Zreplacement = NULL; /* py string, replacement for %Z */
PyObject *freplacement = NULL; /* py string, replacement for %f */

Expand Down Expand Up @@ -1632,32 +1654,29 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
}
/* A % has been seen and ch is the character after it. */
else if (ch == 'z') {
/* %z -> +HHMM */
if (zreplacement == NULL) {
/* format utcoffset */
char buf[100];
PyObject *tzinfo = get_tzinfo_member(object);
zreplacement = PyBytes_FromStringAndSize("", 0);
if (zreplacement == NULL) goto Done;
if (tzinfo != Py_None && tzinfo != NULL) {
assert(tzinfoarg != NULL);
if (format_utcoffset(buf,
sizeof(buf),
"",
tzinfo,
tzinfoarg) < 0)
goto Done;
Py_DECREF(zreplacement);
zreplacement =
PyBytes_FromStringAndSize(buf,
strlen(buf));
if (zreplacement == NULL)
goto Done;
}
zreplacement = make_somezreplacement(object, "", tzinfoarg);
if (zreplacement == NULL)
goto Done;
}
assert(zreplacement != NULL);
assert(PyBytes_Check(zreplacement));
ptoappend = PyBytes_AS_STRING(zreplacement);
ntoappend = PyBytes_GET_SIZE(zreplacement);
}
else if (ch == ':' && *pin == 'z' && pin++) {
/* %:z -> +HH:MM */
if (colonzreplacement == NULL) {
colonzreplacement = make_somezreplacement(object, ":", tzinfoarg);
if (colonzreplacement == NULL)
goto Done;
}
assert(colonzreplacement != NULL);
assert(PyBytes_Check(colonzreplacement));
ptoappend = PyBytes_AS_STRING(colonzreplacement);
ntoappend = PyBytes_GET_SIZE(colonzreplacement);
}
else if (ch == 'Z') {
/* format tzname */
if (Zreplacement == NULL) {
Expand Down Expand Up @@ -1686,7 +1705,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
ntoappend = PyBytes_GET_SIZE(freplacement);
}
else {
/* percent followed by neither z nor Z */
/* percent followed by something else */
ptoappend = pin - 2;
ntoappend = 2;
}
Expand Down Expand Up @@ -1733,6 +1752,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
Done:
Py_XDECREF(freplacement);
Py_XDECREF(zreplacement);
Py_XDECREF(colonzreplacement);
Py_XDECREF(Zreplacement);
Py_XDECREF(newfmt);
return result;
Expand Down