Skip to content

Commit

Permalink
bpo-45995: add "z" format specifer to coerce negative 0 to zero (GH-3…
Browse files Browse the repository at this point in the history
…0049)

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 <[email protected]>
  • Loading branch information
belm0 and mdickinson authored Apr 11, 2022
1 parent dd207a6 commit b0b836b
Show file tree
Hide file tree
Showing 16 changed files with 368 additions and 43 deletions.
11 changes: 10 additions & 1 deletion Doc/library/string.rst
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ non-empty format specification typically modifies the result.
The general form of a *standard format specifier* is:

.. productionlist:: format-spec
format_spec: [[`fill`]`align`][`sign`][#][0][`width`][`grouping_option`][.`precision`][`type`]
format_spec: [[`fill`]`align`][`sign`][z][#][0][`width`][`grouping_option`][.`precision`][`type`]
fill: <any character>
align: "<" | ">" | "=" | "^"
sign: "+" | "-" | " "
Expand Down Expand Up @@ -380,6 +380,15 @@ following:
+---------+----------------------------------------------------------+


.. index:: single: z; in string formatting

The ``'z'`` option coerces negative zero floating-point values to positive
zero after rounding to the format precision. This option is only valid for
floating-point presentation types.

.. versionchanged:: 3.11
Added the ``'z'`` option (see also :pep:`682`).

.. index:: single: # (hash); in string formatting

The ``'#'`` option causes the "alternate form" to be used for the
Expand Down
2 changes: 2 additions & 0 deletions Include/internal/pycore_format.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ extern "C" {
* F_BLANK ' '
* F_ALT '#'
* F_ZERO '0'
* F_NO_NEG_0 'z'
*/
#define F_LJUST (1<<0)
#define F_SIGN (1<<1)
#define F_BLANK (1<<2)
#define F_ALT (1<<3)
#define F_ZERO (1<<4)
#define F_NO_NEG_0 (1<<5)

#ifdef __cplusplus
}
Expand Down
1 change: 1 addition & 0 deletions Include/pystrtod.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ PyAPI_FUNC(double) _Py_parse_inf_or_nan(const char *p, char **endptr);
#define Py_DTSF_ADD_DOT_0 0x02 /* if the result is an integer add ".0" */
#define Py_DTSF_ALT 0x04 /* "alternate" formatting. it's format_code
specific */
#define Py_DTSF_NO_NEG_0 0x08 /* negative zero result is coerced to 0 */

/* PyOS_double_to_string's "type", if non-NULL, will be set to one of: */
#define Py_DTST_FINITE 0
Expand Down
9 changes: 7 additions & 2 deletions Lib/_pydecimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3795,6 +3795,10 @@ def __format__(self, specifier, context=None, _localeconv=None):
# represented in fixed point; rescale them to 0e0.
if not self and self._exp > 0 and spec['type'] in 'fF%':
self = self._rescale(0, rounding)
if not self and spec['no_neg_0'] and self._sign:
adjusted_sign = 0
else:
adjusted_sign = self._sign

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

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

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

_parse_format_specifier_regex = re.compile(r"""\A
(?:
(?P<fill>.)?
(?P<align>[<>=^])
)?
(?P<sign>[-+ ])?
(?P<no_neg_0>z)?
(?P<alt>\#)?
(?P<zeropad>0)?
(?P<minimumwidth>(?!0)\d+)?
Expand Down
11 changes: 10 additions & 1 deletion Lib/pydoc_data/topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6119,7 +6119,7 @@
'The general form of a *standard format specifier* is:\n'
'\n'
' format_spec ::= '
'[[fill]align][sign][#][0][width][grouping_option][.precision][type]\n'
'[[fill]align][sign][z][#][0][width][grouping_option][.precision][type]\n'
' fill ::= <any character>\n'
' align ::= "<" | ">" | "=" | "^"\n'
' sign ::= "+" | "-" | " "\n'
Expand Down Expand Up @@ -6221,6 +6221,15 @@
' '
'+-----------+------------------------------------------------------------+\n'
'\n'
'The "\'z\'" option coerces negative zero floating-point '
'values to positive\n'
'zero after rounding to the format precision. This option '
'is only valid for\n'
'floating-point presentation types.\n'
'\n'
'Changed in version 3.11: Added the "\'z\'" option (see also '
'**PEP 682**).\n'
'\n'
'The "\'#\'" option causes the “alternate form” to be used '
'for the\n'
'conversion. The alternate form is defined differently for '
Expand Down
60 changes: 60 additions & 0 deletions Lib/test/test_decimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,57 @@ def test_formatting(self):
(',e', '123456', '1.23456e+5'),
(',E', '123456', '1.23456E+5'),

# negative zero: default behavior
('.1f', '-0', '-0.0'),
('.1f', '-.0', '-0.0'),
('.1f', '-.01', '-0.0'),

# negative zero: z option
('z.1f', '0.', '0.0'),
('z6.1f', '0.', ' 0.0'),
('z6.1f', '-1.', ' -1.0'),
('z.1f', '-0.', '0.0'),
('z.1f', '.01', '0.0'),
('z.1f', '-.01', '0.0'),
('z.2f', '0.', '0.00'),
('z.2f', '-0.', '0.00'),
('z.2f', '.001', '0.00'),
('z.2f', '-.001', '0.00'),

('z.1e', '0.', '0.0e+1'),
('z.1e', '-0.', '0.0e+1'),
('z.1E', '0.', '0.0E+1'),
('z.1E', '-0.', '0.0E+1'),

('z.2e', '-0.001', '-1.00e-3'), # tests for mishandled rounding
('z.2g', '-0.001', '-0.001'),
('z.2%', '-0.001', '-0.10%'),

('zf', '-0.0000', '0.0000'), # non-normalized form is preserved

('z.1f', '-00000.000001', '0.0'),
('z.1f', '-00000.', '0.0'),
('z.1f', '-.0000000000', '0.0'),

('z.2f', '-00000.000001', '0.00'),
('z.2f', '-00000.', '0.00'),
('z.2f', '-.0000000000', '0.00'),

('z.1f', '.09', '0.1'),
('z.1f', '-.09', '-0.1'),

(' z.0f', '-0.', ' 0'),
('+z.0f', '-0.', '+0'),
('-z.0f', '-0.', '0'),
(' z.0f', '-1.', '-1'),
('+z.0f', '-1.', '-1'),
('-z.0f', '-1.', '-1'),

('z>6.1f', '-0.', 'zz-0.0'),
('z>z6.1f', '-0.', 'zzz0.0'),
('x>z6.1f', '-0.', 'xxx0.0'),
('🖤>z6.1f', '-0.', '🖤🖤🖤0.0'), # multi-byte fill char

# issue 6850
('a=-7.0', '0.12345', 'aaaa0.1'),

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

def test_negative_zero_format_directed_rounding(self):
with self.decimal.localcontext() as ctx:
ctx.rounding = ROUND_CEILING
self.assertEqual(format(self.decimal.Decimal('-0.001'), 'z.2f'),
'0.00')

def test_negative_zero_bad_format(self):
self.assertRaises(ValueError, format, self.decimal.Decimal('1.23'), 'fz')

def test_n_format(self):
Decimal = self.decimal.Decimal

Expand Down
22 changes: 10 additions & 12 deletions Lib/test/test_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,18 +701,16 @@ def test_format(self):
# conversion to string should fail
self.assertRaises(ValueError, format, 3.0, "s")

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

# issue 3382
self.assertEqual(format(NAN, 'f'), 'nan')
Expand Down
74 changes: 74 additions & 0 deletions Lib/test/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,80 @@ def test_unicode_in_error_message(self):
with self.assertRaisesRegex(ValueError, str_err):
"{a:%ЫйЯЧ}".format(a='a')

def test_negative_zero(self):
## default behavior
self.assertEqual(f"{-0.:.1f}", "-0.0")
self.assertEqual(f"{-.01:.1f}", "-0.0")
self.assertEqual(f"{-0:.1f}", "0.0") # integers do not distinguish -0

## z sign option
self.assertEqual(f"{0.:z.1f}", "0.0")
self.assertEqual(f"{0.:z6.1f}", " 0.0")
self.assertEqual(f"{-1.:z6.1f}", " -1.0")
self.assertEqual(f"{-0.:z.1f}", "0.0")
self.assertEqual(f"{.01:z.1f}", "0.0")
self.assertEqual(f"{-0:z.1f}", "0.0") # z is allowed for integer input
self.assertEqual(f"{-.01:z.1f}", "0.0")
self.assertEqual(f"{0.:z.2f}", "0.00")
self.assertEqual(f"{-0.:z.2f}", "0.00")
self.assertEqual(f"{.001:z.2f}", "0.00")
self.assertEqual(f"{-.001:z.2f}", "0.00")

self.assertEqual(f"{0.:z.1e}", "0.0e+00")
self.assertEqual(f"{-0.:z.1e}", "0.0e+00")
self.assertEqual(f"{0.:z.1E}", "0.0E+00")
self.assertEqual(f"{-0.:z.1E}", "0.0E+00")

self.assertEqual(f"{-0.001:z.2e}", "-1.00e-03") # tests for mishandled
# rounding
self.assertEqual(f"{-0.001:z.2g}", "-0.001")
self.assertEqual(f"{-0.001:z.2%}", "-0.10%")

self.assertEqual(f"{-00000.000001:z.1f}", "0.0")
self.assertEqual(f"{-00000.:z.1f}", "0.0")
self.assertEqual(f"{-.0000000000:z.1f}", "0.0")

self.assertEqual(f"{-00000.000001:z.2f}", "0.00")
self.assertEqual(f"{-00000.:z.2f}", "0.00")
self.assertEqual(f"{-.0000000000:z.2f}", "0.00")

self.assertEqual(f"{.09:z.1f}", "0.1")
self.assertEqual(f"{-.09:z.1f}", "-0.1")

self.assertEqual(f"{-0.: z.0f}", " 0")
self.assertEqual(f"{-0.:+z.0f}", "+0")
self.assertEqual(f"{-0.:-z.0f}", "0")
self.assertEqual(f"{-1.: z.0f}", "-1")
self.assertEqual(f"{-1.:+z.0f}", "-1")
self.assertEqual(f"{-1.:-z.0f}", "-1")

self.assertEqual(f"{0.j:z.1f}", "0.0+0.0j")
self.assertEqual(f"{-0.j:z.1f}", "0.0+0.0j")
self.assertEqual(f"{.01j:z.1f}", "0.0+0.0j")
self.assertEqual(f"{-.01j:z.1f}", "0.0+0.0j")

self.assertEqual(f"{-0.:z>6.1f}", "zz-0.0") # test fill, esp. 'z' fill
self.assertEqual(f"{-0.:z>z6.1f}", "zzz0.0")
self.assertEqual(f"{-0.:x>z6.1f}", "xxx0.0")
self.assertEqual(f"{-0.:🖤>z6.1f}", "🖤🖤🖤0.0") # multi-byte fill char

def test_specifier_z_error(self):
error_msg = re.compile("Invalid format specifier '.*z.*'")
with self.assertRaisesRegex(ValueError, error_msg):
f"{0:z+f}" # wrong position
with self.assertRaisesRegex(ValueError, error_msg):
f"{0:fz}" # wrong position

error_msg = re.escape("Negative zero coercion (z) not allowed")
with self.assertRaisesRegex(ValueError, error_msg):
f"{0:zd}" # can't apply to int presentation type
with self.assertRaisesRegex(ValueError, error_msg):
f"{'x':zs}" # can't apply to string

error_msg = re.escape("unsupported format character 'z'")
with self.assertRaisesRegex(ValueError, error_msg):
"%z.1f" % 0 # not allowed in old style string interpolation


if __name__ == "__main__":
unittest.main()
22 changes: 10 additions & 12 deletions Lib/test/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,18 +524,16 @@ def test(f, format_spec, result):
self.assertRaises(TypeError, 3.0.__format__, None)
self.assertRaises(TypeError, 3.0.__format__, 0)

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

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

0 comments on commit b0b836b

Please sign in to comment.