Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-45995: add "z" format specifer to coerce negative 0 to zero #30049

Merged
merged 25 commits into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a633e88
bpo-45995: add "z" format specifer to coerce negative 0 to zero
belm0 Dec 7, 2021
636da05
formatting
belm0 Dec 11, 2021
3f4085d
implementation for Decimal
belm0 Dec 13, 2021
68a049e
📜🤖 Added by blurb_it.
blurb-it[bot] Dec 14, 2021
ad32be5
consistent flag names
belm0 Dec 14, 2021
3dcaf5f
add test case for integer value with z option
belm0 Feb 3, 2022
6b9ab3b
reference pending PEP
belm0 Feb 4, 2022
e243568
Apply some formatting and doc suggestions
belm0 Mar 16, 2022
be4fda2
revise "z" option description
belm0 Mar 19, 2022
043d76a
add test cases for explicit sign option
belm0 Mar 19, 2022
104e023
revise tests for format options expected to fail on floats
belm0 Mar 19, 2022
61c64df
"float presentation" -> "floating-point presentation"
belm0 Mar 19, 2022
76d61ae
news file terminating newline
belm0 Mar 19, 2022
33fe72c
add test coverage for Decimal bugs
belm0 Mar 19, 2022
9393136
Decimal: handle 'z' fill character correctly
belm0 Mar 21, 2022
f88f7fc
Decimal: const qualifier on fmt variable
belm0 Mar 21, 2022
20c9cf1
fix rounding of 'e', 'g', and '%' presentation types for Decimal
belm0 Mar 23, 2022
bf1a891
fix Decimal directed rounding
belm0 Mar 23, 2022
3f5b392
consistency among tests
belm0 Mar 23, 2022
8d7a745
fix stack-use-after-scope sanitizer error
belm0 Mar 23, 2022
2a24e61
clarify Decimal strategy
belm0 Mar 23, 2022
0cbff6a
fix Decimal format parsing
belm0 Apr 6, 2022
8e7b51c
fix Decimal when no precision is specified
belm0 Apr 7, 2022
418ab76
fix comment typo
belm0 Apr 7, 2022
3ee6f6b
add attribution to news blurb
belm0 Apr 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
belm0 marked this conversation as resolved.
Show resolved Hide resolved

# 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 @@ -6140,7 +6140,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 @@ -6242,6 +6242,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 @@ -1071,6 +1071,57 @@ def test_formatting(self):
(',e', '123456', '1.23456e+5'),
(',E', '123456', '1.23456E+5'),

# negative zero: default behavior
belm0 marked this conversation as resolved.
Show resolved Hide resolved
('.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 @@ -1085,6 +1136,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':
belm0 marked this conversation as resolved.
Show resolved Hide resolved
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")
belm0 marked this conversation as resolved.
Show resolved Hide resolved

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")

belm0 marked this conversation as resolved.
Show resolved Hide resolved
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