import decimal import numbers import itertools import re __all__ = [ 'TRUNCATE', 'ROUND', 'ROUND_UP', 'ROUND_DOWN', 'DECIMAL_PLACES', 'SIGNIFICANT_DIGITS', 'TICK_SIZE', 'NO_PADDING', 'PAD_WITH_ZERO', 'decimal_to_precision', ] # rounding mode TRUNCATE = 0 ROUND = 1 ROUND_UP = 2 ROUND_DOWN = 3 # digits counting mode DECIMAL_PLACES = 2 SIGNIFICANT_DIGITS = 3 TICK_SIZE = 4 # padding mode NO_PADDING = 5 PAD_WITH_ZERO = 6 def decimal_to_precision(n, rounding_mode=ROUND, precision=None, counting_mode=DECIMAL_PLACES, padding_mode=NO_PADDING): assert precision is not None, 'precision should not be None' if isinstance(precision, str): precision = float(precision) assert isinstance(precision, float) or isinstance(precision, decimal.Decimal) or isinstance(precision, numbers.Integral), 'precision has an invalid number' if counting_mode == TICK_SIZE: assert precision > 0, 'negative or zero precision can not be used with TICK_SIZE precisionMode' else: assert isinstance(precision, numbers.Integral) assert rounding_mode in [TRUNCATE, ROUND] assert counting_mode in [DECIMAL_PLACES, SIGNIFICANT_DIGITS, TICK_SIZE] assert padding_mode in [NO_PADDING, PAD_WITH_ZERO] # end of checks context = decimal.getcontext() if counting_mode != TICK_SIZE: precision = min(context.prec - 2, precision) # all default except decimal.Underflow (raised when a number is rounded to zero) context.traps[decimal.Underflow] = True context.rounding = decimal.ROUND_HALF_UP # rounds 0.5 away from zero dec = decimal.Decimal(str(n)) precision_dec = decimal.Decimal(str(precision)) string = '{:f}'.format(dec) # convert to string using .format to avoid engineering notation precise = None def power_of_10(x): return decimal.Decimal('10') ** (-x) if precision < 0: if counting_mode == TICK_SIZE: raise ValueError('TICK_SIZE cant be used with negative numPrecisionDigits') to_nearest = power_of_10(precision) if rounding_mode == ROUND: return "{:f}".format(to_nearest * decimal.Decimal(decimal_to_precision(dec / to_nearest, rounding_mode, 0, DECIMAL_PLACES, padding_mode))) elif rounding_mode == TRUNCATE: return decimal_to_precision(dec - dec % to_nearest, rounding_mode, 0, DECIMAL_PLACES, padding_mode) if counting_mode == TICK_SIZE: # python modulo with negative numbers behaves different than js/php, so use abs first missing = abs(dec) % precision_dec if missing != 0: if rounding_mode == ROUND: if dec > 0: if missing >= precision_dec / 2: dec = dec - missing + precision_dec else: dec = dec - missing else: if missing >= precision_dec / 2: dec = dec + missing - precision_dec else: dec = dec + missing elif rounding_mode == TRUNCATE: if dec < 0: dec = dec + missing else: dec = dec - missing parts = re.sub(r'0+$', '', '{:f}'.format(precision_dec)).split('.') if len(parts) > 1: new_precision = len(parts[1]) else: match = re.search(r'0+$', parts[0]) if match is None: new_precision = 0 else: new_precision = - len(match.group(0)) return decimal_to_precision('{:f}'.format(dec), ROUND, new_precision, DECIMAL_PLACES, padding_mode) if rounding_mode == ROUND: if counting_mode == DECIMAL_PLACES: precise = '{:f}'.format(dec.quantize(power_of_10(precision))) # ROUND_HALF_EVEN is default context elif counting_mode == SIGNIFICANT_DIGITS: q = precision - dec.adjusted() - 1 sigfig = power_of_10(q) if q < 0: string_to_precision = string[:precision] # string_to_precision is '' when we have zero precision below = sigfig * decimal.Decimal(string_to_precision if string_to_precision else '0') above = below + sigfig precise = '{:f}'.format(min((below, above), key=lambda x: abs(x - dec))) else: precise = '{:f}'.format(dec.quantize(sigfig)) if precise.startswith('-0') and all(c in '0.' for c in precise[1:]): precise = precise[1:] elif rounding_mode == TRUNCATE: # Slice a string if counting_mode == DECIMAL_PLACES: before, after = string.split('.') if '.' in string else (string, '') precise = before + '.' + after[:precision] elif counting_mode == SIGNIFICANT_DIGITS: if precision == 0: return '0' dot = string.index('.') if '.' in string else len(string) start = dot - dec.adjusted() end = start + precision # need to clarify these conditionals if dot >= end: end -= 1 if precision >= len(string.replace('.', '')): precise = string else: precise = string[:end].ljust(dot, '0') if precise.startswith('-0') and all(c in '0.' for c in precise[1:]): precise = precise[1:] precise = precise.rstrip('.') if padding_mode == NO_PADDING: return precise.rstrip('0').rstrip('.') if '.' in precise else precise elif padding_mode == PAD_WITH_ZERO: if '.' in precise: if counting_mode == DECIMAL_PLACES: before, after = precise.split('.') return before + '.' + after.ljust(precision, '0') elif counting_mode == SIGNIFICANT_DIGITS: fsfg = len(list(itertools.takewhile(lambda x: x == '.' or x == '0', precise))) if '.' in precise[fsfg:]: precision += 1 return precise[:fsfg] + precise[fsfg:].rstrip('0').ljust(precision, '0') else: if counting_mode == SIGNIFICANT_DIGITS: if precision > len(precise): return precise + '.' + (precision - len(precise)) * '0' elif counting_mode == DECIMAL_PLACES: if precision > 0: return precise + '.' + precision * '0' return precise def number_to_string(x): # avoids scientific notation for too large and too small numbers if x is None: return None d = decimal.Decimal(str(x)) formatted = '{:f}'.format(d) return formatted.rstrip('0').rstrip('.') if '.' in formatted else formatted