Skip to content

Commit b0b836b

Browse files
belm0mdickinson
andauthored
bpo-45995: add "z" format specifer to coerce negative 0 to zero (GH-30049)
Add "z" format specifier to coerce negative 0 to zero. See #90153 (originally https://bugs.python.org/issue45995) for discussion. This covers `str.format()` and f-strings. Old-style string interpolation is not supported. Co-authored-by: Mark Dickinson <dickinsm@gmail.com>
1 parent dd207a6 commit b0b836b

16 files changed

+368
-43
lines changed

Doc/library/string.rst

+10-1
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ non-empty format specification typically modifies the result.
309309
The general form of a *standard format specifier* is:
310310

311311
.. productionlist:: format-spec
312-
format_spec: [[`fill`]`align`][`sign`][#][0][`width`][`grouping_option`][.`precision`][`type`]
312+
format_spec: [[`fill`]`align`][`sign`][z][#][0][`width`][`grouping_option`][.`precision`][`type`]
313313
fill: <any character>
314314
align: "<" | ">" | "=" | "^"
315315
sign: "+" | "-" | " "
@@ -380,6 +380,15 @@ following:
380380
+---------+----------------------------------------------------------+
381381

382382

383+
.. index:: single: z; in string formatting
384+
385+
The ``'z'`` option coerces negative zero floating-point values to positive
386+
zero after rounding to the format precision. This option is only valid for
387+
floating-point presentation types.
388+
389+
.. versionchanged:: 3.11
390+
Added the ``'z'`` option (see also :pep:`682`).
391+
383392
.. index:: single: # (hash); in string formatting
384393

385394
The ``'#'`` option causes the "alternate form" to be used for the

Include/internal/pycore_format.h

+2
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ extern "C" {
1414
* F_BLANK ' '
1515
* F_ALT '#'
1616
* F_ZERO '0'
17+
* F_NO_NEG_0 'z'
1718
*/
1819
#define F_LJUST (1<<0)
1920
#define F_SIGN (1<<1)
2021
#define F_BLANK (1<<2)
2122
#define F_ALT (1<<3)
2223
#define F_ZERO (1<<4)
24+
#define F_NO_NEG_0 (1<<5)
2325

2426
#ifdef __cplusplus
2527
}

Include/pystrtod.h

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ PyAPI_FUNC(double) _Py_parse_inf_or_nan(const char *p, char **endptr);
3232
#define Py_DTSF_ADD_DOT_0 0x02 /* if the result is an integer add ".0" */
3333
#define Py_DTSF_ALT 0x04 /* "alternate" formatting. it's format_code
3434
specific */
35+
#define Py_DTSF_NO_NEG_0 0x08 /* negative zero result is coerced to 0 */
3536

3637
/* PyOS_double_to_string's "type", if non-NULL, will be set to one of: */
3738
#define Py_DTST_FINITE 0

Lib/_pydecimal.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -3795,6 +3795,10 @@ def __format__(self, specifier, context=None, _localeconv=None):
37953795
# represented in fixed point; rescale them to 0e0.
37963796
if not self and self._exp > 0 and spec['type'] in 'fF%':
37973797
self = self._rescale(0, rounding)
3798+
if not self and spec['no_neg_0'] and self._sign:
3799+
adjusted_sign = 0
3800+
else:
3801+
adjusted_sign = self._sign
37983802

37993803
# figure out placement of the decimal point
38003804
leftdigits = self._exp + len(self._int)
@@ -3825,7 +3829,7 @@ def __format__(self, specifier, context=None, _localeconv=None):
38253829

38263830
# done with the decimal-specific stuff; hand over the rest
38273831
# of the formatting to the _format_number function
3828-
return _format_number(self._sign, intpart, fracpart, exp, spec)
3832+
return _format_number(adjusted_sign, intpart, fracpart, exp, spec)
38293833

38303834
def _dec_from_triple(sign, coefficient, exponent, special=False):
38313835
"""Create a decimal instance directly, without any validation,
@@ -6143,14 +6147,15 @@ def _convert_for_comparison(self, other, equality_op=False):
61436147
#
61446148
# A format specifier for Decimal looks like:
61456149
#
6146-
# [[fill]align][sign][#][0][minimumwidth][,][.precision][type]
6150+
# [[fill]align][sign][z][#][0][minimumwidth][,][.precision][type]
61476151

61486152
_parse_format_specifier_regex = re.compile(r"""\A
61496153
(?:
61506154
(?P<fill>.)?
61516155
(?P<align>[<>=^])
61526156
)?
61536157
(?P<sign>[-+ ])?
6158+
(?P<no_neg_0>z)?
61546159
(?P<alt>\#)?
61556160
(?P<zeropad>0)?
61566161
(?P<minimumwidth>(?!0)\d+)?

Lib/pydoc_data/topics.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -6119,7 +6119,7 @@
61196119
'The general form of a *standard format specifier* is:\n'
61206120
'\n'
61216121
' format_spec ::= '
6122-
'[[fill]align][sign][#][0][width][grouping_option][.precision][type]\n'
6122+
'[[fill]align][sign][z][#][0][width][grouping_option][.precision][type]\n'
61236123
' fill ::= <any character>\n'
61246124
' align ::= "<" | ">" | "=" | "^"\n'
61256125
' sign ::= "+" | "-" | " "\n'
@@ -6221,6 +6221,15 @@
62216221
' '
62226222
'+-----------+------------------------------------------------------------+\n'
62236223
'\n'
6224+
'The "\'z\'" option coerces negative zero floating-point '
6225+
'values to positive\n'
6226+
'zero after rounding to the format precision. This option '
6227+
'is only valid for\n'
6228+
'floating-point presentation types.\n'
6229+
'\n'
6230+
'Changed in version 3.11: Added the "\'z\'" option (see also '
6231+
'**PEP 682**).\n'
6232+
'\n'
62246233
'The "\'#\'" option causes the “alternate form” to be used '
62256234
'for the\n'
62266235
'conversion. The alternate form is defined differently for '

Lib/test/test_decimal.py

+60
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,57 @@ def test_formatting(self):
10721072
(',e', '123456', '1.23456e+5'),
10731073
(',E', '123456', '1.23456E+5'),
10741074

1075+
# negative zero: default behavior
1076+
('.1f', '-0', '-0.0'),
1077+
('.1f', '-.0', '-0.0'),
1078+
('.1f', '-.01', '-0.0'),
1079+
1080+
# negative zero: z option
1081+
('z.1f', '0.', '0.0'),
1082+
('z6.1f', '0.', ' 0.0'),
1083+
('z6.1f', '-1.', ' -1.0'),
1084+
('z.1f', '-0.', '0.0'),
1085+
('z.1f', '.01', '0.0'),
1086+
('z.1f', '-.01', '0.0'),
1087+
('z.2f', '0.', '0.00'),
1088+
('z.2f', '-0.', '0.00'),
1089+
('z.2f', '.001', '0.00'),
1090+
('z.2f', '-.001', '0.00'),
1091+
1092+
('z.1e', '0.', '0.0e+1'),
1093+
('z.1e', '-0.', '0.0e+1'),
1094+
('z.1E', '0.', '0.0E+1'),
1095+
('z.1E', '-0.', '0.0E+1'),
1096+
1097+
('z.2e', '-0.001', '-1.00e-3'), # tests for mishandled rounding
1098+
('z.2g', '-0.001', '-0.001'),
1099+
('z.2%', '-0.001', '-0.10%'),
1100+
1101+
('zf', '-0.0000', '0.0000'), # non-normalized form is preserved
1102+
1103+
('z.1f', '-00000.000001', '0.0'),
1104+
('z.1f', '-00000.', '0.0'),
1105+
('z.1f', '-.0000000000', '0.0'),
1106+
1107+
('z.2f', '-00000.000001', '0.00'),
1108+
('z.2f', '-00000.', '0.00'),
1109+
('z.2f', '-.0000000000', '0.00'),
1110+
1111+
('z.1f', '.09', '0.1'),
1112+
('z.1f', '-.09', '-0.1'),
1113+
1114+
(' z.0f', '-0.', ' 0'),
1115+
('+z.0f', '-0.', '+0'),
1116+
('-z.0f', '-0.', '0'),
1117+
(' z.0f', '-1.', '-1'),
1118+
('+z.0f', '-1.', '-1'),
1119+
('-z.0f', '-1.', '-1'),
1120+
1121+
('z>6.1f', '-0.', 'zz-0.0'),
1122+
('z>z6.1f', '-0.', 'zzz0.0'),
1123+
('x>z6.1f', '-0.', 'xxx0.0'),
1124+
('🖤>z6.1f', '-0.', '🖤🖤🖤0.0'), # multi-byte fill char
1125+
10751126
# issue 6850
10761127
('a=-7.0', '0.12345', 'aaaa0.1'),
10771128

@@ -1086,6 +1137,15 @@ def test_formatting(self):
10861137
# bytes format argument
10871138
self.assertRaises(TypeError, Decimal(1).__format__, b'-020')
10881139

1140+
def test_negative_zero_format_directed_rounding(self):
1141+
with self.decimal.localcontext() as ctx:
1142+
ctx.rounding = ROUND_CEILING
1143+
self.assertEqual(format(self.decimal.Decimal('-0.001'), 'z.2f'),
1144+
'0.00')
1145+
1146+
def test_negative_zero_bad_format(self):
1147+
self.assertRaises(ValueError, format, self.decimal.Decimal('1.23'), 'fz')
1148+
10891149
def test_n_format(self):
10901150
Decimal = self.decimal.Decimal
10911151

Lib/test/test_float.py

+10-12
Original file line numberDiff line numberDiff line change
@@ -701,18 +701,16 @@ def test_format(self):
701701
# conversion to string should fail
702702
self.assertRaises(ValueError, format, 3.0, "s")
703703

704-
# other format specifiers shouldn't work on floats,
705-
# in particular int specifiers
706-
for format_spec in ([chr(x) for x in range(ord('a'), ord('z')+1)] +
707-
[chr(x) for x in range(ord('A'), ord('Z')+1)]):
708-
if not format_spec in 'eEfFgGn%':
709-
self.assertRaises(ValueError, format, 0.0, format_spec)
710-
self.assertRaises(ValueError, format, 1.0, format_spec)
711-
self.assertRaises(ValueError, format, -1.0, format_spec)
712-
self.assertRaises(ValueError, format, 1e100, format_spec)
713-
self.assertRaises(ValueError, format, -1e100, format_spec)
714-
self.assertRaises(ValueError, format, 1e-100, format_spec)
715-
self.assertRaises(ValueError, format, -1e-100, format_spec)
704+
# confirm format options expected to fail on floats, such as integer
705+
# presentation types
706+
for format_spec in 'sbcdoxX':
707+
self.assertRaises(ValueError, format, 0.0, format_spec)
708+
self.assertRaises(ValueError, format, 1.0, format_spec)
709+
self.assertRaises(ValueError, format, -1.0, format_spec)
710+
self.assertRaises(ValueError, format, 1e100, format_spec)
711+
self.assertRaises(ValueError, format, -1e100, format_spec)
712+
self.assertRaises(ValueError, format, 1e-100, format_spec)
713+
self.assertRaises(ValueError, format, -1e-100, format_spec)
716714

717715
# issue 3382
718716
self.assertEqual(format(NAN, 'f'), 'nan')

Lib/test/test_format.py

+74
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,80 @@ def test_unicode_in_error_message(self):
546546
with self.assertRaisesRegex(ValueError, str_err):
547547
"{a:%ЫйЯЧ}".format(a='a')
548548

549+
def test_negative_zero(self):
550+
## default behavior
551+
self.assertEqual(f"{-0.:.1f}", "-0.0")
552+
self.assertEqual(f"{-.01:.1f}", "-0.0")
553+
self.assertEqual(f"{-0:.1f}", "0.0") # integers do not distinguish -0
554+
555+
## z sign option
556+
self.assertEqual(f"{0.:z.1f}", "0.0")
557+
self.assertEqual(f"{0.:z6.1f}", " 0.0")
558+
self.assertEqual(f"{-1.:z6.1f}", " -1.0")
559+
self.assertEqual(f"{-0.:z.1f}", "0.0")
560+
self.assertEqual(f"{.01:z.1f}", "0.0")
561+
self.assertEqual(f"{-0:z.1f}", "0.0") # z is allowed for integer input
562+
self.assertEqual(f"{-.01:z.1f}", "0.0")
563+
self.assertEqual(f"{0.:z.2f}", "0.00")
564+
self.assertEqual(f"{-0.:z.2f}", "0.00")
565+
self.assertEqual(f"{.001:z.2f}", "0.00")
566+
self.assertEqual(f"{-.001:z.2f}", "0.00")
567+
568+
self.assertEqual(f"{0.:z.1e}", "0.0e+00")
569+
self.assertEqual(f"{-0.:z.1e}", "0.0e+00")
570+
self.assertEqual(f"{0.:z.1E}", "0.0E+00")
571+
self.assertEqual(f"{-0.:z.1E}", "0.0E+00")
572+
573+
self.assertEqual(f"{-0.001:z.2e}", "-1.00e-03") # tests for mishandled
574+
# rounding
575+
self.assertEqual(f"{-0.001:z.2g}", "-0.001")
576+
self.assertEqual(f"{-0.001:z.2%}", "-0.10%")
577+
578+
self.assertEqual(f"{-00000.000001:z.1f}", "0.0")
579+
self.assertEqual(f"{-00000.:z.1f}", "0.0")
580+
self.assertEqual(f"{-.0000000000:z.1f}", "0.0")
581+
582+
self.assertEqual(f"{-00000.000001:z.2f}", "0.00")
583+
self.assertEqual(f"{-00000.:z.2f}", "0.00")
584+
self.assertEqual(f"{-.0000000000:z.2f}", "0.00")
585+
586+
self.assertEqual(f"{.09:z.1f}", "0.1")
587+
self.assertEqual(f"{-.09:z.1f}", "-0.1")
588+
589+
self.assertEqual(f"{-0.: z.0f}", " 0")
590+
self.assertEqual(f"{-0.:+z.0f}", "+0")
591+
self.assertEqual(f"{-0.:-z.0f}", "0")
592+
self.assertEqual(f"{-1.: z.0f}", "-1")
593+
self.assertEqual(f"{-1.:+z.0f}", "-1")
594+
self.assertEqual(f"{-1.:-z.0f}", "-1")
595+
596+
self.assertEqual(f"{0.j:z.1f}", "0.0+0.0j")
597+
self.assertEqual(f"{-0.j:z.1f}", "0.0+0.0j")
598+
self.assertEqual(f"{.01j:z.1f}", "0.0+0.0j")
599+
self.assertEqual(f"{-.01j:z.1f}", "0.0+0.0j")
600+
601+
self.assertEqual(f"{-0.:z>6.1f}", "zz-0.0") # test fill, esp. 'z' fill
602+
self.assertEqual(f"{-0.:z>z6.1f}", "zzz0.0")
603+
self.assertEqual(f"{-0.:x>z6.1f}", "xxx0.0")
604+
self.assertEqual(f"{-0.:🖤>z6.1f}", "🖤🖤🖤0.0") # multi-byte fill char
605+
606+
def test_specifier_z_error(self):
607+
error_msg = re.compile("Invalid format specifier '.*z.*'")
608+
with self.assertRaisesRegex(ValueError, error_msg):
609+
f"{0:z+f}" # wrong position
610+
with self.assertRaisesRegex(ValueError, error_msg):
611+
f"{0:fz}" # wrong position
612+
613+
error_msg = re.escape("Negative zero coercion (z) not allowed")
614+
with self.assertRaisesRegex(ValueError, error_msg):
615+
f"{0:zd}" # can't apply to int presentation type
616+
with self.assertRaisesRegex(ValueError, error_msg):
617+
f"{'x':zs}" # can't apply to string
618+
619+
error_msg = re.escape("unsupported format character 'z'")
620+
with self.assertRaisesRegex(ValueError, error_msg):
621+
"%z.1f" % 0 # not allowed in old style string interpolation
622+
549623

550624
if __name__ == "__main__":
551625
unittest.main()

Lib/test/test_types.py

+10-12
Original file line numberDiff line numberDiff line change
@@ -524,18 +524,16 @@ def test(f, format_spec, result):
524524
self.assertRaises(TypeError, 3.0.__format__, None)
525525
self.assertRaises(TypeError, 3.0.__format__, 0)
526526

527-
# other format specifiers shouldn't work on floats,
528-
# in particular int specifiers
529-
for format_spec in ([chr(x) for x in range(ord('a'), ord('z')+1)] +
530-
[chr(x) for x in range(ord('A'), ord('Z')+1)]):
531-
if not format_spec in 'eEfFgGn%':
532-
self.assertRaises(ValueError, format, 0.0, format_spec)
533-
self.assertRaises(ValueError, format, 1.0, format_spec)
534-
self.assertRaises(ValueError, format, -1.0, format_spec)
535-
self.assertRaises(ValueError, format, 1e100, format_spec)
536-
self.assertRaises(ValueError, format, -1e100, format_spec)
537-
self.assertRaises(ValueError, format, 1e-100, format_spec)
538-
self.assertRaises(ValueError, format, -1e-100, format_spec)
527+
# confirm format options expected to fail on floats, such as integer
528+
# presentation types
529+
for format_spec in 'sbcdoxX':
530+
self.assertRaises(ValueError, format, 0.0, format_spec)
531+
self.assertRaises(ValueError, format, 1.0, format_spec)
532+
self.assertRaises(ValueError, format, -1.0, format_spec)
533+
self.assertRaises(ValueError, format, 1e100, format_spec)
534+
self.assertRaises(ValueError, format, -1e100, format_spec)
535+
self.assertRaises(ValueError, format, 1e-100, format_spec)
536+
self.assertRaises(ValueError, format, -1e-100, format_spec)
539537

540538
# Alternate float formatting
541539
test(1.0, '.0e', '1e+00')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add a "z" option to the string formatting specification that coerces negative
2+
zero floating-point values to positive zero after rounding to the format
3+
precision. Contributed by John Belmonte.

0 commit comments

Comments
 (0)