Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit b0b836b

Browse filesBrowse 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
Copy full SHA for b0b836b

File tree

16 files changed

+368
-43
lines changed
Filter options

16 files changed

+368
-43
lines changed

‎Doc/library/string.rst

Copy file name to clipboardExpand all lines: Doc/library/string.rst
+10-1Lines changed: 10 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: Include/internal/pycore_format.h
+2Lines changed: 2 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: Include/pystrtod.h
+1Lines changed: 1 addition & 0 deletions
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

Copy file name to clipboardExpand all lines: Lib/_pydecimal.py
+7-2Lines changed: 7 additions & 2 deletions
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

Copy file name to clipboardExpand all lines: Lib/pydoc_data/topics.py
+10-1Lines changed: 10 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: Lib/test/test_decimal.py
+60Lines changed: 60 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: Lib/test/test_float.py
+10-12Lines changed: 10 additions & 12 deletions
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

Copy file name to clipboardExpand all lines: Lib/test/test_format.py
+74Lines changed: 74 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: Lib/test/test_types.py
+10-12Lines changed: 10 additions & 12 deletions
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')
+3Lines changed: 3 additions & 0 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.