gh-69142: add %:z strftime format code (gh-95983)

datetime.isoformat generates the tzoffset with colons, but there
was no format code to make strftime output the same format.

for simplicity and consistency the %:z formatting behaves mostly
as %z, with the exception of adding colons. this includes the
dynamic behaviour of adding seconds and microseconds only when
needed (when not 0).

this fixes the still open "generate" part of this issue:

https://github.com/python/cpython/issues/69142

Co-authored-by: Kumar Aditya <59607654+kumaraditya303@users.noreply.github.com>
This commit is contained in:
TW 2022-08-28 23:27:42 +02:00 committed by GitHub
parent e860e521ec
commit 023c51d9d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 92 additions and 55 deletions

View file

@ -2443,6 +2443,11 @@ convenience. These parameters all correspond to ISO 8601 date values.
| | Week 01 is the week containing | | | | | Week 01 is the week containing | | |
| | Jan 4. | | | | | 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` 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 method. The ISO 8601 year and ISO 8601 week directives are not interchangeable
@ -2458,6 +2463,9 @@ differences between platforms in handling of unsupported format specifiers.
.. versionadded:: 3.6 .. versionadded:: 3.6
``%G``, ``%u`` and ``%V`` were added. ``%G``, ``%u`` and ``%V`` were added.
.. versionadded:: 3.12
``%:z`` was added.
Technical Detail Technical Detail
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
@ -2530,8 +2538,8 @@ Notes:
available). available).
(6) (6)
For a naive object, the ``%z`` and ``%Z`` format codes are replaced by empty For a naive object, the ``%z``, ``%:z`` and ``%Z`` format codes are replaced
strings. by empty strings.
For an aware object: For an aware object:
@ -2557,6 +2565,10 @@ Notes:
For example, ``'+01:00:00'`` will be parsed as an offset of one hour. For example, ``'+01:00:00'`` will be parsed as an offset of one hour.
In addition, providing ``'Z'`` is identical to ``'+00:00'``. 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`` ``%Z``
In :meth:`strftime`, ``%Z`` is replaced by an empty string if In :meth:`strftime`, ``%Z`` is replaced by an empty string if
:meth:`tzname` returns ``None``; otherwise ``%Z`` is replaced by the :meth:`tzname` returns ``None``; otherwise ``%Z`` is replaced by the

View file

@ -179,7 +179,7 @@ def _format_time(hh, mm, ss, us, timespec='auto'):
else: else:
return fmt.format(hh, mm, ss, us) return fmt.format(hh, mm, ss, us)
def _format_offset(off): def _format_offset(off, sep=':'):
s = '' s = ''
if off is not None: if off is not None:
if off.days < 0: if off.days < 0:
@ -189,9 +189,9 @@ def _format_offset(off):
sign = "+" sign = "+"
hh, mm = divmod(off, timedelta(hours=1)) hh, mm = divmod(off, timedelta(hours=1))
mm, ss = divmod(mm, timedelta(minutes=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: if ss or ss.microseconds:
s += ":%02d" % ss.seconds s += "%s%02d" % (sep, ss.seconds)
if ss.microseconds: if ss.microseconds:
s += '.%06d' % ss.microseconds s += '.%06d' % ss.microseconds
@ -202,9 +202,10 @@ def _wrap_strftime(object, format, timetuple):
# Don't call utcoffset() or tzname() unless actually needed. # Don't call utcoffset() or tzname() unless actually needed.
freplace = None # the string to use for %f freplace = None # the string to use for %f
zreplace = None # the string to use for %z zreplace = None # the string to use for %z
colonzreplace = None # the string to use for %:z
Zreplace = 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 = [] newformat = []
push = newformat.append push = newformat.append
i, n = 0, len(format) i, n = 0, len(format)
@ -222,26 +223,28 @@ def _wrap_strftime(object, format, timetuple):
newformat.append(freplace) newformat.append(freplace)
elif ch == 'z': elif ch == 'z':
if zreplace is None: if zreplace is None:
zreplace = ""
if hasattr(object, "utcoffset"): if hasattr(object, "utcoffset"):
offset = object.utcoffset() zreplace = _format_offset(object.utcoffset(), sep="")
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: else:
zreplace = '%c%02d%02d' % (sign, h, m) zreplace = ""
assert '%' not in zreplace assert '%' not in zreplace
newformat.append(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': elif ch == 'Z':
if Zreplace is None: if Zreplace is None:
Zreplace = "" Zreplace = ""

View file

@ -1463,8 +1463,8 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase):
# test that unicode input is allowed (issue 2782) # test that unicode input is allowed (issue 2782)
self.assertEqual(t.strftime("%m"), "03") self.assertEqual(t.strftime("%m"), "03")
# A naive object replaces %z and %Z w/ empty strings. # A naive object replaces %z, %:z and %Z w/ empty strings.
self.assertEqual(t.strftime("'%z' '%Z'"), "'' ''") self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''")
#make sure that invalid format specifiers are handled correctly #make sure that invalid format specifiers are handled correctly
#self.assertRaises(ValueError, t.strftime, "%e") #self.assertRaises(ValueError, t.strftime, "%e")
@ -1528,7 +1528,7 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase):
for fmt in ["m:%m d:%d y:%y", for fmt in ["m:%m d:%d y:%y",
"m:%m d:%d y:%y H:%H M:%M S:%S", "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(dt.__format__(fmt), dt.strftime(fmt))
self.assertEqual(a.__format__(fmt), dt.strftime(fmt)) self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
@ -2134,7 +2134,7 @@ class TestDateTime(TestDate):
for fmt in ["m:%m d:%d y:%y", for fmt in ["m:%m d:%d y:%y",
"m:%m d:%d y:%y H:%H M:%M S:%S", "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(dt.__format__(fmt), dt.strftime(fmt))
self.assertEqual(a.__format__(fmt), dt.strftime(fmt)) self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
@ -2777,6 +2777,7 @@ class TestDateTime(TestDate):
tz = timezone(-timedelta(hours=2, seconds=s, microseconds=us)) tz = timezone(-timedelta(hours=2, seconds=s, microseconds=us))
t = t.replace(tzinfo=tz) t = t.replace(tzinfo=tz)
self.assertEqual(t.strftime("%z"), "-0200" + z) 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. # bpo-34482: Check that surrogates don't cause a crash.
try: try:
@ -3515,8 +3516,8 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase):
def test_strftime(self): def test_strftime(self):
t = self.theclass(1, 2, 3, 4) t = self.theclass(1, 2, 3, 4)
self.assertEqual(t.strftime('%H %M %S %f'), "01 02 03 000004") self.assertEqual(t.strftime('%H %M %S %f'), "01 02 03 000004")
# A naive object replaces %z and %Z with empty strings. # A naive object replaces %z, %:z and %Z with empty strings.
self.assertEqual(t.strftime("'%z' '%Z'"), "'' ''") self.assertEqual(t.strftime("'%z' '%:z' '%Z'"), "'' '' ''")
# bpo-34482: Check that surrogates don't cause a crash. # bpo-34482: Check that surrogates don't cause a crash.
try: try:
@ -3934,10 +3935,10 @@ class TestTimeTZ(TestTime, TZInfoBase, unittest.TestCase):
self.assertEqual(repr(t4), d + "(0, 0, 0, 40)") self.assertEqual(repr(t4), d + "(0, 0, 0, 40)")
self.assertEqual(repr(t5), d + "(0, 0, 0, 40, tzinfo=utc)") self.assertEqual(repr(t5), d + "(0, 0, 0, 40, tzinfo=utc)")
self.assertEqual(t1.strftime("%H:%M:%S %%Z=%Z %%z=%z"), self.assertEqual(t1.strftime("%H:%M:%S %%Z=%Z %%z=%z %%:z=%:z"),
"07:47:00 %Z=EST %z=-0500") "07:47:00 %Z=EST %z=-0500 %:z=-05:00")
self.assertEqual(t2.strftime("%H:%M:%S %Z %z"), "12:47:00 UTC +0000") 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"), "13:47:00 MET +0100") self.assertEqual(t3.strftime("%H:%M:%S %Z %z %:z"), "13:47:00 MET +0100 +01:00")
yuck = FixedOffset(-1439, "%z %Z %%z%%Z") yuck = FixedOffset(-1439, "%z %Z %%z%%Z")
t1 = time(23, 59, tzinfo=yuck) t1 = time(23, 59, tzinfo=yuck)

View file

@ -0,0 +1 @@
Add ``%:z`` strftime format code (generates tzoffset with colons as separator), see :ref:`strftime-strptime-behavior`.

View file

@ -1506,6 +1506,27 @@ format_utcoffset(char *buf, size_t buflen, const char *sep,
return 0; 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 * static PyObject *
make_Zreplacement(PyObject *object, PyObject *tzinfoarg) make_Zreplacement(PyObject *object, PyObject *tzinfoarg)
{ {
@ -1566,7 +1587,7 @@ make_freplacement(PyObject *object)
/* I sure don't want to reproduce the strftime code from the time module, /* 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 * 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. * preprocessing step on the format string.
* tzinfoarg is the argument to pass to the object's tzinfo method, if * tzinfoarg is the argument to pass to the object's tzinfo method, if
* needed. * needed.
@ -1578,6 +1599,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
PyObject *result = NULL; /* guilty until proved innocent */ PyObject *result = NULL; /* guilty until proved innocent */
PyObject *zreplacement = NULL; /* py string, replacement for %z */ 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 *Zreplacement = NULL; /* py string, replacement for %Z */
PyObject *freplacement = NULL; /* py string, replacement for %f */ PyObject *freplacement = NULL; /* py string, replacement for %f */
@ -1632,32 +1654,29 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
} }
/* A % has been seen and ch is the character after it. */ /* A % has been seen and ch is the character after it. */
else if (ch == 'z') { else if (ch == 'z') {
/* %z -> +HHMM */
if (zreplacement == NULL) { if (zreplacement == NULL) {
/* format utcoffset */ zreplacement = make_somezreplacement(object, "", tzinfoarg);
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) if (zreplacement == NULL)
goto Done; goto Done;
} }
}
assert(zreplacement != NULL); assert(zreplacement != NULL);
assert(PyBytes_Check(zreplacement));
ptoappend = PyBytes_AS_STRING(zreplacement); ptoappend = PyBytes_AS_STRING(zreplacement);
ntoappend = PyBytes_GET_SIZE(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') { else if (ch == 'Z') {
/* format tzname */ /* format tzname */
if (Zreplacement == NULL) { if (Zreplacement == NULL) {
@ -1686,7 +1705,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
ntoappend = PyBytes_GET_SIZE(freplacement); ntoappend = PyBytes_GET_SIZE(freplacement);
} }
else { else {
/* percent followed by neither z nor Z */ /* percent followed by something else */
ptoappend = pin - 2; ptoappend = pin - 2;
ntoappend = 2; ntoappend = 2;
} }
@ -1733,6 +1752,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
Done: Done:
Py_XDECREF(freplacement); Py_XDECREF(freplacement);
Py_XDECREF(zreplacement); Py_XDECREF(zreplacement);
Py_XDECREF(colonzreplacement);
Py_XDECREF(Zreplacement); Py_XDECREF(Zreplacement);
Py_XDECREF(newfmt); Py_XDECREF(newfmt);
return result; return result;