Skip to content

Commit 023c51d

Browse files
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: #69142 Co-authored-by: Kumar Aditya <[email protected]>
1 parent e860e52 commit 023c51d

File tree

5 files changed

+92
-55
lines changed

5 files changed

+92
-55
lines changed

Doc/library/datetime.rst

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2443,6 +2443,11 @@ convenience. These parameters all correspond to ISO 8601 date values.
24432443
| | Week 01 is the week containing | | |
24442444
| | Jan 4. | | |
24452445
+-----------+--------------------------------+------------------------+-------+
2446+
| ``%:z`` | UTC offset in the form | (empty), +00:00, | \(6) |
2447+
| | ``±HH:MM[:SS[.ffffff]]`` | -04:00, +10:30, | |
2448+
| | (empty string if the object is | +06:34:15, | |
2449+
| | naive). | -03:07:12.345216 | |
2450+
+-----------+--------------------------------+------------------------+-------+
24462451

24472452
These may not be available on all platforms when used with the :meth:`strftime`
24482453
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.
24582463
.. versionadded:: 3.6
24592464
``%G``, ``%u`` and ``%V`` were added.
24602465

2466+
.. versionadded:: 3.12
2467+
``%:z`` was added.
2468+
24612469
Technical Detail
24622470
^^^^^^^^^^^^^^^^
24632471

@@ -2530,8 +2538,8 @@ Notes:
25302538
available).
25312539

25322540
(6)
2533-
For a naive object, the ``%z`` and ``%Z`` format codes are replaced by empty
2534-
strings.
2541+
For a naive object, the ``%z``, ``%:z`` and ``%Z`` format codes are replaced
2542+
by empty strings.
25352543

25362544
For an aware object:
25372545

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

2568+
``%:z``
2569+
Behaves exactly as ``%z``, but has a colon separator added between
2570+
hours, minutes and seconds.
2571+
25602572
``%Z``
25612573
In :meth:`strftime`, ``%Z`` is replaced by an empty string if
25622574
:meth:`tzname` returns ``None``; otherwise ``%Z`` is replaced by the

Lib/datetime.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def _format_time(hh, mm, ss, us, timespec='auto'):
179179
else:
180180
return fmt.format(hh, mm, ss, us)
181181

182-
def _format_offset(off):
182+
def _format_offset(off, sep=':'):
183183
s = ''
184184
if off is not None:
185185
if off.days < 0:
@@ -189,9 +189,9 @@ def _format_offset(off):
189189
sign = "+"
190190
hh, mm = divmod(off, timedelta(hours=1))
191191
mm, ss = divmod(mm, timedelta(minutes=1))
192-
s += "%s%02d:%02d" % (sign, hh, mm)
192+
s += "%s%02d%s%02d" % (sign, hh, sep, mm)
193193
if ss or ss.microseconds:
194-
s += ":%02d" % ss.seconds
194+
s += "%s%02d" % (sep, ss.seconds)
195195

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

207-
# Scan format for %z and %Z escapes, replacing as needed.
208+
# Scan format for %z, %:z and %Z escapes, replacing as needed.
208209
newformat = []
209210
push = newformat.append
210211
i, n = 0, len(format)
@@ -222,26 +223,28 @@ def _wrap_strftime(object, format, timetuple):
222223
newformat.append(freplace)
223224
elif ch == 'z':
224225
if zreplace is None:
225-
zreplace = ""
226226
if hasattr(object, "utcoffset"):
227-
offset = object.utcoffset()
228-
if offset is not None:
229-
sign = '+'
230-
if offset.days < 0:
231-
offset = -offset
232-
sign = '-'
233-
h, rest = divmod(offset, timedelta(hours=1))
234-
m, rest = divmod(rest, timedelta(minutes=1))
235-
s = rest.seconds
236-
u = offset.microseconds
237-
if u:
238-
zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u)
239-
elif s:
240-
zreplace = '%c%02d%02d%02d' % (sign, h, m, s)
241-
else:
242-
zreplace = '%c%02d%02d' % (sign, h, m)
227+
zreplace = _format_offset(object.utcoffset(), sep="")
228+
else:
229+
zreplace = ""
243230
assert '%' not in zreplace
244231
newformat.append(zreplace)
232+
elif ch == ':':
233+
if i < n:
234+
ch2 = format[i]
235+
i += 1
236+
if ch2 == 'z':
237+
if colonzreplace is None:
238+
if hasattr(object, "utcoffset"):
239+
colonzreplace = _format_offset(object.utcoffset(), sep=":")
240+
else:
241+
colonzreplace = ""
242+
assert '%' not in colonzreplace
243+
newformat.append(colonzreplace)
244+
else:
245+
push('%')
246+
push(ch)
247+
push(ch2)
245248
elif ch == 'Z':
246249
if Zreplace is None:
247250
Zreplace = ""

Lib/test/datetimetester.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,8 +1463,8 @@ def test_strftime(self):
14631463
# test that unicode input is allowed (issue 2782)
14641464
self.assertEqual(t.strftime("%m"), "03")
14651465

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

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

15291529
for fmt in ["m:%m d:%d y:%y",
15301530
"m:%m d:%d y:%y H:%H M:%M S:%S",
1531-
"%z %Z",
1531+
"%z %:z %Z",
15321532
]:
15331533
self.assertEqual(dt.__format__(fmt), dt.strftime(fmt))
15341534
self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
@@ -2134,7 +2134,7 @@ def strftime(self, format_spec):
21342134

21352135
for fmt in ["m:%m d:%d y:%y",
21362136
"m:%m d:%d y:%y H:%H M:%M S:%S",
2137-
"%z %Z",
2137+
"%z %:z %Z",
21382138
]:
21392139
self.assertEqual(dt.__format__(fmt), dt.strftime(fmt))
21402140
self.assertEqual(a.__format__(fmt), dt.strftime(fmt))
@@ -2777,6 +2777,7 @@ def test_more_strftime(self):
27772777
tz = timezone(-timedelta(hours=2, seconds=s, microseconds=us))
27782778
t = t.replace(tzinfo=tz)
27792779
self.assertEqual(t.strftime("%z"), "-0200" + z)
2780+
self.assertEqual(t.strftime("%:z"), "-02:00:" + z)
27802781

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

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

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

39423943
yuck = FixedOffset(-1439, "%z %Z %%z%%Z")
39433944
t1 = time(23, 59, tzinfo=yuck)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ``%:z`` strftime format code (generates tzoffset with colons as separator), see :ref:`strftime-strptime-behavior`.

Modules/_datetimemodule.c

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,27 @@ format_utcoffset(char *buf, size_t buflen, const char *sep,
15061506
return 0;
15071507
}
15081508

1509+
static PyObject *
1510+
make_somezreplacement(PyObject *object, char *sep, PyObject *tzinfoarg)
1511+
{
1512+
char buf[100];
1513+
PyObject *tzinfo = get_tzinfo_member(object);
1514+
1515+
if (tzinfo == Py_None || tzinfo == NULL) {
1516+
return PyBytes_FromStringAndSize(NULL, 0);
1517+
}
1518+
1519+
assert(tzinfoarg != NULL);
1520+
if (format_utcoffset(buf,
1521+
sizeof(buf),
1522+
sep,
1523+
tzinfo,
1524+
tzinfoarg) < 0)
1525+
return NULL;
1526+
1527+
return PyBytes_FromStringAndSize(buf, strlen(buf));
1528+
}
1529+
15091530
static PyObject *
15101531
make_Zreplacement(PyObject *object, PyObject *tzinfoarg)
15111532
{
@@ -1566,7 +1587,7 @@ make_freplacement(PyObject *object)
15661587

15671588
/* I sure don't want to reproduce the strftime code from the time module,
15681589
* so this imports the module and calls it. All the hair is due to
1569-
* giving special meanings to the %z, %Z and %f format codes via a
1590+
* giving special meanings to the %z, %:z, %Z and %f format codes via a
15701591
* preprocessing step on the format string.
15711592
* tzinfoarg is the argument to pass to the object's tzinfo method, if
15721593
* needed.
@@ -1578,6 +1599,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
15781599
PyObject *result = NULL; /* guilty until proved innocent */
15791600

15801601
PyObject *zreplacement = NULL; /* py string, replacement for %z */
1602+
PyObject *colonzreplacement = NULL; /* py string, replacement for %:z */
15811603
PyObject *Zreplacement = NULL; /* py string, replacement for %Z */
15821604
PyObject *freplacement = NULL; /* py string, replacement for %f */
15831605

@@ -1632,32 +1654,29 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
16321654
}
16331655
/* A % has been seen and ch is the character after it. */
16341656
else if (ch == 'z') {
1657+
/* %z -> +HHMM */
16351658
if (zreplacement == NULL) {
1636-
/* format utcoffset */
1637-
char buf[100];
1638-
PyObject *tzinfo = get_tzinfo_member(object);
1639-
zreplacement = PyBytes_FromStringAndSize("", 0);
1640-
if (zreplacement == NULL) goto Done;
1641-
if (tzinfo != Py_None && tzinfo != NULL) {
1642-
assert(tzinfoarg != NULL);
1643-
if (format_utcoffset(buf,
1644-
sizeof(buf),
1645-
"",
1646-
tzinfo,
1647-
tzinfoarg) < 0)
1648-
goto Done;
1649-
Py_DECREF(zreplacement);
1650-
zreplacement =
1651-
PyBytes_FromStringAndSize(buf,
1652-
strlen(buf));
1653-
if (zreplacement == NULL)
1654-
goto Done;
1655-
}
1659+
zreplacement = make_somezreplacement(object, "", tzinfoarg);
1660+
if (zreplacement == NULL)
1661+
goto Done;
16561662
}
16571663
assert(zreplacement != NULL);
1664+
assert(PyBytes_Check(zreplacement));
16581665
ptoappend = PyBytes_AS_STRING(zreplacement);
16591666
ntoappend = PyBytes_GET_SIZE(zreplacement);
16601667
}
1668+
else if (ch == ':' && *pin == 'z' && pin++) {
1669+
/* %:z -> +HH:MM */
1670+
if (colonzreplacement == NULL) {
1671+
colonzreplacement = make_somezreplacement(object, ":", tzinfoarg);
1672+
if (colonzreplacement == NULL)
1673+
goto Done;
1674+
}
1675+
assert(colonzreplacement != NULL);
1676+
assert(PyBytes_Check(colonzreplacement));
1677+
ptoappend = PyBytes_AS_STRING(colonzreplacement);
1678+
ntoappend = PyBytes_GET_SIZE(colonzreplacement);
1679+
}
16611680
else if (ch == 'Z') {
16621681
/* format tzname */
16631682
if (Zreplacement == NULL) {
@@ -1686,7 +1705,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
16861705
ntoappend = PyBytes_GET_SIZE(freplacement);
16871706
}
16881707
else {
1689-
/* percent followed by neither z nor Z */
1708+
/* percent followed by something else */
16901709
ptoappend = pin - 2;
16911710
ntoappend = 2;
16921711
}
@@ -1733,6 +1752,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple,
17331752
Done:
17341753
Py_XDECREF(freplacement);
17351754
Py_XDECREF(zreplacement);
1755+
Py_XDECREF(colonzreplacement);
17361756
Py_XDECREF(Zreplacement);
17371757
Py_XDECREF(newfmt);
17381758
return result;

0 commit comments

Comments
 (0)