Add __format__ method to Decimal, to support PEP 3101

This commit is contained in:
Mark Dickinson 2008-02-29 02:16:37 +00:00
parent b4cbc98c39
commit 1ddf1d8482
3 changed files with 335 additions and 0 deletions

View File

@ -2380,6 +2380,29 @@ class Decimal(object):
coeff = str(int(coeff)+1)
return _dec_from_triple(self._sign, coeff, exp)
def _round(self, places, rounding):
"""Round a nonzero, nonspecial Decimal to a fixed number of
significant figures, using the given rounding mode.
Infinities, NaNs and zeros are returned unaltered.
This operation is quiet: it raises no flags, and uses no
information from the context.
"""
if places <= 0:
raise ValueError("argument should be at least 1 in _round")
if self._is_special or not self:
return Decimal(self)
ans = self._rescale(self.adjusted()+1-places, rounding)
# it can happen that the rescale alters the adjusted exponent;
# for example when rounding 99.97 to 3 significant figures.
# When this happens we end up with an extra 0 at the end of
# the number; a second rescale fixes this.
if ans.adjusted() != self.adjusted():
ans = ans._rescale(ans.adjusted()+1-places, rounding)
return ans
def to_integral_exact(self, rounding=None, context=None):
"""Rounds to a nearby integer.
@ -3431,6 +3454,95 @@ class Decimal(object):
return self # My components are also immutable
return self.__class__(str(self))
# PEP 3101 support. See also _parse_format_specifier and _format_align
def __format__(self, specifier, context=None):
"""Format a Decimal class according to the given specifier.
The specifier should be a standard format specifier, with the
form described in PEP 3101. Formatting types 'e', 'E', 'f',
'F', 'g', 'G', and '%' are supported. If the formatting type
is omitted it defaults to 'g' or 'G', depending on the value
of context.capitals.
At this time the 'n' format specifier type (which is supposed
to use the current locale) is not supported.
"""
# Note: PEP 3101 says that if the type is not present then
# there should be at least one digit after the decimal point.
# We take the liberty of ignoring this requirement for
# Decimal---it's presumably there to make sure that
# format(float, '') behaves similarly to str(float).
if context is None:
context = getcontext()
spec = _parse_format_specifier(specifier)
# special values don't care about the type or precision...
if self._is_special:
return _format_align(str(self), spec)
# a type of None defaults to 'g' or 'G', depending on context
# if type is '%', adjust exponent of self accordingly
if spec['type'] is None:
spec['type'] = ['g', 'G'][context.capitals]
elif spec['type'] == '%':
self = _dec_from_triple(self._sign, self._int, self._exp+2)
# round if necessary, taking rounding mode from the context
rounding = context.rounding
precision = spec['precision']
if precision is not None:
if spec['type'] in 'eE':
self = self._round(precision+1, rounding)
elif spec['type'] in 'gG':
if len(self._int) > precision:
self = self._round(precision, rounding)
elif spec['type'] in 'fF%':
self = self._rescale(-precision, rounding)
# special case: zeros with a positive exponent can't be
# represented in fixed point; rescale them to 0e0.
elif not self and self._exp > 0 and spec['type'] in 'fF%':
self = self._rescale(0, rounding)
# figure out placement of the decimal point
leftdigits = self._exp + len(self._int)
if spec['type'] in 'fF%':
dotplace = leftdigits
elif spec['type'] in 'eE':
if not self and precision is not None:
dotplace = 1 - precision
else:
dotplace = 1
elif spec['type'] in 'gG':
if self._exp <= 0 and leftdigits > -6:
dotplace = leftdigits
else:
dotplace = 1
# figure out main part of numeric string...
if dotplace <= 0:
num = '0.' + '0'*(-dotplace) + self._int
elif dotplace >= len(self._int):
# make sure we're not padding a '0' with extra zeros on the right
assert dotplace==len(self._int) or self._int != '0'
num = self._int + '0'*(dotplace-len(self._int))
else:
num = self._int[:dotplace] + '.' + self._int[dotplace:]
# ...then the trailing exponent, or trailing '%'
if leftdigits != dotplace or spec['type'] in 'eE':
echar = {'E': 'E', 'e': 'e', 'G': 'E', 'g': 'e'}[spec['type']]
num = num + "{0}{1:+}".format(echar, leftdigits-dotplace)
elif spec['type'] == '%':
num = num + '%'
# add sign
if self._sign == 1:
num = '-' + num
return _format_align(num, spec)
def _dec_from_triple(sign, coefficient, exponent, special=False):
"""Create a decimal instance directly, without any validation,
normalization (e.g. removal of leading zeros) or argument
@ -5250,8 +5362,136 @@ _parser = re.compile(r""" # A numeric string consists of:
_all_zeros = re.compile('0*$').match
_exact_half = re.compile('50*$').match
##### PEP3101 support functions ##############################################
# The functions parse_format_specifier and format_align have little to do
# with the Decimal class, and could potentially be reused for other pure
# Python numeric classes that want to implement __format__
#
# A format specifier for Decimal looks like:
#
# [[fill]align][sign][0][minimumwidth][.precision][type]
#
_parse_format_specifier_regex = re.compile(r"""\A
(?:
(?P<fill>.)?
(?P<align>[<>=^])
)?
(?P<sign>[-+ ])?
(?P<zeropad>0)?
(?P<minimumwidth>(?!0)\d+)?
(?:\.(?P<precision>0|(?!0)\d+))?
(?P<type>[eEfFgG%])?
\Z
""", re.VERBOSE)
del re
def _parse_format_specifier(format_spec):
"""Parse and validate a format specifier.
Turns a standard numeric format specifier into a dict, with the
following entries:
fill: fill character to pad field to minimum width
align: alignment type, either '<', '>', '=' or '^'
sign: either '+', '-' or ' '
minimumwidth: nonnegative integer giving minimum width
precision: nonnegative integer giving precision, or None
type: one of the characters 'eEfFgG%', or None
unicode: either True or False (always True for Python 3.x)
"""
m = _parse_format_specifier_regex.match(format_spec)
if m is None:
raise ValueError("Invalid format specifier: " + format_spec)
# get the dictionary
format_dict = m.groupdict()
# defaults for fill and alignment
fill = format_dict['fill']
align = format_dict['align']
if format_dict.pop('zeropad') is not None:
# in the face of conflict, refuse the temptation to guess
if fill is not None and fill != '0':
raise ValueError("Fill character conflicts with '0'"
" in format specifier: " + format_spec)
if align is not None and align != '=':
raise ValueError("Alignment conflicts with '0' in "
"format specifier: " + format_spec)
fill = '0'
align = '='
format_dict['fill'] = fill or ' '
format_dict['align'] = align or '<'
if format_dict['sign'] is None:
format_dict['sign'] = '-'
# turn minimumwidth and precision entries into integers.
# minimumwidth defaults to 0; precision remains None if not given
format_dict['minimumwidth'] = int(format_dict['minimumwidth'] or '0')
if format_dict['precision'] is not None:
format_dict['precision'] = int(format_dict['precision'])
# if format type is 'g' or 'G' then a precision of 0 makes little
# sense; convert it to 1. Same if format type is unspecified.
if format_dict['precision'] == 0:
if format_dict['type'] in 'gG' or format_dict['type'] is None:
format_dict['precision'] = 1
# record whether return type should be str or unicode
format_dict['unicode'] = isinstance(format_spec, unicode)
return format_dict
def _format_align(body, spec_dict):
"""Given an unpadded, non-aligned numeric string, add padding and
aligment to conform with the given format specifier dictionary (as
output from parse_format_specifier).
It's assumed that if body is negative then it starts with '-'.
Any leading sign ('-' or '+') is stripped from the body before
applying the alignment and padding rules, and replaced in the
appropriate position.
"""
# figure out the sign; we only examine the first character, so if
# body has leading whitespace the results may be surprising.
if len(body) > 0 and body[0] in '-+':
sign = body[0]
body = body[1:]
else:
sign = ''
if sign != '-':
if spec_dict['sign'] in ' +':
sign = spec_dict['sign']
else:
sign = ''
# how much extra space do we have to play with?
minimumwidth = spec_dict['minimumwidth']
fill = spec_dict['fill']
padding = fill*(max(minimumwidth - (len(sign+body)), 0))
align = spec_dict['align']
if align == '<':
result = padding + sign + body
elif align == '>':
result = sign + body + padding
elif align == '=':
result = sign + padding + body
else: #align == '^'
half = len(padding)//2
result = padding[:half] + sign + body + padding[half:]
# make sure that result is unicode if necessary
if spec_dict['unicode']:
result = unicode(result)
return result
##### Useful Constants (internal use only) ################################

View File

@ -615,6 +615,98 @@ class DecimalImplicitConstructionTest(unittest.TestCase):
self.assertEqual(eval('Decimal(10)' + sym + 'E()'),
'10' + rop + 'str')
class DecimalFormatTest(unittest.TestCase):
'''Unit tests for the format function.'''
def test_formatting(self):
# triples giving a format, a Decimal, and the expected result
test_values = [
('e', '0E-15', '0e-15'),
('e', '2.3E-15', '2.3e-15'),
('e', '2.30E+2', '2.30e+2'), # preserve significant zeros
('e', '2.30000E-15', '2.30000e-15'),
('e', '1.23456789123456789e40', '1.23456789123456789e+40'),
('e', '1.5', '1.5e+0'),
('e', '0.15', '1.5e-1'),
('e', '0.015', '1.5e-2'),
('e', '0.0000000000015', '1.5e-12'),
('e', '15.0', '1.50e+1'),
('e', '-15', '-1.5e+1'),
('e', '0', '0e+0'),
('e', '0E1', '0e+1'),
('e', '0.0', '0e-1'),
('e', '0.00', '0e-2'),
('.6e', '0E-15', '0.000000e-9'),
('.6e', '0', '0.000000e+6'),
('.6e', '9.999999', '9.999999e+0'),
('.6e', '9.9999999', '1.000000e+1'),
('.6e', '-1.23e5', '-1.230000e+5'),
('.6e', '1.23456789e-3', '1.234568e-3'),
('f', '0', '0'),
('f', '0.0', '0.0'),
('f', '0E-2', '0.00'),
('f', '0.00E-8', '0.0000000000'),
('f', '0E1', '0'), # loses exponent information
('f', '3.2E1', '32'),
('f', '3.2E2', '320'),
('f', '3.20E2', '320'),
('f', '3.200E2', '320.0'),
('f', '3.2E-6', '0.0000032'),
('.6f', '0E-15', '0.000000'), # all zeros treated equally
('.6f', '0E1', '0.000000'),
('.6f', '0', '0.000000'),
('.0f', '0', '0'), # no decimal point
('.0f', '0e-2', '0'),
('.0f', '3.14159265', '3'),
('.1f', '3.14159265', '3.1'),
('.4f', '3.14159265', '3.1416'),
('.6f', '3.14159265', '3.141593'),
('.7f', '3.14159265', '3.1415926'), # round-half-even!
('.8f', '3.14159265', '3.14159265'),
('.9f', '3.14159265', '3.141592650'),
('g', '0', '0'),
('g', '0.0', '0.0'),
('g', '0E1', '0e+1'),
('G', '0E1', '0E+1'),
('g', '0E-5', '0.00000'),
('g', '0E-6', '0.000000'),
('g', '0E-7', '0e-7'),
('g', '-0E2', '-0e+2'),
('.0g', '3.14159265', '3'), # 0 sig fig -> 1 sig fig
('.1g', '3.14159265', '3'),
('.2g', '3.14159265', '3.1'),
('.5g', '3.14159265', '3.1416'),
('.7g', '3.14159265', '3.141593'),
('.8g', '3.14159265', '3.1415926'), # round-half-even!
('.9g', '3.14159265', '3.14159265'),
('.10g', '3.14159265', '3.14159265'), # don't pad
('%', '0E1', '0%'),
('%', '0E0', '0%'),
('%', '0E-1', '0%'),
('%', '0E-2', '0%'),
('%', '0E-3', '0.0%'),
('%', '0E-4', '0.00%'),
('.3%', '0', '0.000%'), # all zeros treated equally
('.3%', '0E10', '0.000%'),
('.3%', '0E-10', '0.000%'),
('.3%', '2.34', '234.000%'),
('.3%', '1.234567', '123.457%'),
('.0%', '1.23', '123%'),
('e', 'NaN', 'NaN'),
('f', '-NaN123', '-NaN123'),
('+g', 'NaN456', '+NaN456'),
('.3e', 'Inf', 'Infinity'),
('.16f', '-Inf', '-Infinity'),
('.0g', '-sNaN', '-sNaN'),
('', '1.00', '1.00'),
]
for fmt, d, result in test_values:
self.assertEqual(format(Decimal(d), fmt), result)
class DecimalArithmeticOperatorsTest(unittest.TestCase):
'''Unit tests for all arithmetic operators, binary and unary.'''
@ -1363,6 +1455,7 @@ def test_main(arith=False, verbose=None, todo_tests=None, debug=None):
DecimalExplicitConstructionTest,
DecimalImplicitConstructionTest,
DecimalArithmeticOperatorsTest,
DecimalFormatTest,
DecimalUseOfContextTest,
DecimalUsabilityTest,
DecimalPythonAPItests,

View File

@ -441,6 +441,8 @@ Core and builtins
Library
-------
- Add a __format__ method to Decimal, to support PEP 3101.
- Add a timing parameter when using trace.Trace to print out timestamps.
- #1627: httplib now ignores negative Content-Length headers.