From 1484ccc4049dc42a5a8a71713253894ade401573 Mon Sep 17 00:00:00 2001 From: Martin Kosek Date: Thu, 7 Jun 2012 09:25:19 +0200 Subject: Decimal parameter conversion and normalization Parameter Decimal does not have a sufficient value checks. Some values cause Decimal parameter with a custom precision to crash with an unhandled exception. Improve parameter conversion and normalization operations to handle decimal exceptions more gracefully. Decimal parameter now also has new attributes enabling 2 new validation/normalization methods: * exponential: when False, decimal number is normalized to its non-exponential form * numberclass: a set of allowed decimal number classes (e.g. +Infinity, -Normal, ...) that are enforced for every Decimal parameter value https://fedorahosted.org/freeipa/ticket/2705 --- ipalib/parameters.py | 54 +++++++++++++++++++-- tests/test_ipalib/test_parameters.py | 93 +++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/ipalib/parameters.py b/ipalib/parameters.py index cdc991f48..98b02dd6d 100644 --- a/ipalib/parameters.py +++ b/ipalib/parameters.py @@ -1206,7 +1206,12 @@ class Decimal(Number): kwargs = Param.kwargs + ( ('minvalue', decimal.Decimal, None), ('maxvalue', decimal.Decimal, None), + # round Decimal to given precision ('precision', int, None), + # when False, number is normalized to non-exponential form + ('exponential', bool, False), + # set of allowed decimal number classes + ('numberclass', tuple, ('-Normal', '+Zero', '+Normal')), ) def __init__(self, name, *rules, **kw): @@ -1256,31 +1261,70 @@ class Decimal(Number): maxvalue=self.maxvalue, ) + def _enforce_numberclass(self, value): + #pylint: disable=E1101 + numberclass = value.number_class() + if numberclass not in self.numberclass: + raise ValidationError(name=self.get_param_name(), + error=_("number class '%(cls)s' is not included in a list " + "of allowed number classes: %(allowed)s") \ + % dict(cls=numberclass, + allowed=u', '.join(self.numberclass)) + ) + def _enforce_precision(self, value): assert type(value) is decimal.Decimal if self.precision is not None: quantize_exp = decimal.Decimal(10) ** -self.precision - return value.quantize(quantize_exp) + try: + value = value.quantize(quantize_exp) + except decimal.DecimalException, e: + raise ConversionError(name=self.get_param_name(), + error=unicode(e)) + return value + + def _remove_exponent(self, value): + assert type(value) is decimal.Decimal + if not self.exponential: #pylint: disable=E1101 + try: + # adopted from http://docs.python.org/library/decimal.html + value = value.quantize(decimal.Decimal(1)) \ + if value == value.to_integral() \ + else value.normalize() + except decimal.DecimalException, e: + raise ConversionError(name=self.get_param_name(), + error=unicode(e)) + + return value + + def _test_and_normalize(self, value): + """ + This method is run in conversion and normalization methods to test + that the Decimal number conforms to Parameter boundaries and then + normalizes the value. + """ + self._enforce_numberclass(value) + value = self._remove_exponent(value) + value = self._enforce_precision(value) return value def _convert_scalar(self, value, index=None): if isinstance(value, (basestring, float)): try: value = decimal.Decimal(value) - except Exception, e: + except decimal.DecimalException, e: raise ConversionError(name=self.get_param_name(), index=index, error=unicode(e)) if isinstance(value, decimal.Decimal): - x = self._enforce_precision(value) - return x + return self._test_and_normalize(value) return super(Decimal, self)._convert_scalar(value, index) def _normalize_scalar(self, value): if isinstance(value, decimal.Decimal): - value = self._enforce_precision(value) + return self._test_and_normalize(value) return super(Decimal, self)._normalize_scalar(value) diff --git a/tests/test_ipalib/test_parameters.py b/tests/test_ipalib/test_parameters.py index fc9569e73..0b6fae375 100644 --- a/tests/test_ipalib/test_parameters.py +++ b/tests/test_ipalib/test_parameters.py @@ -32,7 +32,7 @@ from tests.util import dummy_ugettext, assert_equal from tests.data import binary_bytes, utf8_bytes, unicode_str from ipalib import parameters, text, errors, config from ipalib.constants import TYPE_ERROR, CALLABLE_ERROR, NULLS -from ipalib.errors import ValidationError +from ipalib.errors import ValidationError, ConversionError from ipalib import _ from xmlrpclib import MAXINT, MININT @@ -1358,6 +1358,97 @@ class test_Decimal(ClassChecker): assert dummy.called() is True dummy.reset() + def test_precision(self): + """ + Test the `ipalib.parameters.Decimal` precision attribute + """ + # precission is None + param = self.cls('my_number') + + for value in (Decimal('0'), Decimal('4.4'), Decimal('4.67')): + assert_equal( + param(value), + value) + + # precision is 0 + param = self.cls('my_number', precision=0) + for original,expected in ((Decimal('0'), '0'), + (Decimal('1.1'), '1'), + (Decimal('4.67'), '5')): + assert_equal( + str(param(original)), + expected) + + # precision is 1 + param = self.cls('my_number', precision=1) + for original,expected in ((Decimal('0'), '0.0'), + (Decimal('1.1'), '1.1'), + (Decimal('4.67'), '4.7')): + assert_equal( + str(param(original)), + expected) + + # value has too many digits + param = self.cls('my_number', precision=1) + e = raises(ConversionError, param, '123456789012345678901234567890') + + assert str(e) == \ + "invalid 'my_number': quantize result has too many digits for current context" + + def test_exponential(self): + """ + Test the `ipalib.parameters.Decimal` exponential attribute + """ + param = self.cls('my_number', exponential=True) + for original,expected in ((Decimal('0'), '0'), + (Decimal('1E3'), '1E+3'), + (Decimal('3.4E2'), '3.4E+2')): + assert_equal( + str(param(original)), + expected) + + + param = self.cls('my_number', exponential=False) + for original,expected in ((Decimal('0'), '0'), + (Decimal('1E3'), '1000'), + (Decimal('3.4E2'), '340')): + assert_equal( + str(param(original)), + expected) + + def test_numberclass(self): + """ + Test the `ipalib.parameters.Decimal` numberclass attribute + """ + # test default value: '-Normal', '+Zero', '+Normal' + param = self.cls('my_number') + for value,raises_verror in ((Decimal('0'), False), + (Decimal('-0'), True), + (Decimal('1E8'), False), + (Decimal('-1.1'), False), + (Decimal('-Infinity'), True), + (Decimal('+Infinity'), True), + (Decimal('NaN'), True)): + if raises_verror: + raises(ValidationError, param, value) + else: + param(value) + + + param = self.cls('my_number', exponential=True, + numberclass=('-Normal', '+Zero', '+Infinity')) + for value,raises_verror in ((Decimal('0'), False), + (Decimal('-0'), True), + (Decimal('1E8'), True), + (Decimal('-1.1'), False), + (Decimal('-Infinity'), True), + (Decimal('+Infinity'), False), + (Decimal('NaN'), True)): + if raises_verror: + raises(ValidationError, param, value) + else: + param(value) + class test_AccessTime(ClassChecker): """ Test the `ipalib.parameters.AccessTime` class. -- cgit