import copy
import math
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum, auto
from types import MappingProxyType
from typing import Any, Optional, Tuple, Union
METRIC_PREFIX_LOOKUP = MappingProxyType(
{
0: '',
1: 'k',
2: 'M',
3: 'B',
4: 'T',
},
)
MAX_TIER = max(METRIC_PREFIX_LOOKUP.keys())
MAX_DIGITS_IN_DOUBLE_PRECISION: int = 15
MSG_CONTACT_US = 'Please contact the authors.'
class _DecimalPartRenderingMethod(Enum):
NATURAL = auto()
HARD_PRECISION = auto()
SIGNIFICANT_FIGURES = auto()
class _InternalError(Exception):
"""For cases that should not have happened"""
@dataclass
class _IntegerAndDecimalParts:
integer_part_str: str
integer_part_int: int
decimal_part_str: str
decimal_part_float: float
sign: int
multiplier: int = 0 # to keep track of numbers with small absolute values
[docs]class ReadableNumber:
"""
A class to hold a number for human-readable printing.
Showing an arbitrary number in a human-readable way is far from trivial,
because real-world numbers come in various forms. This is why there are
many options in this class for users to tune. That said, the default
options of this class generally give a pretty good result.
Parameters
----------
num : Optional[Union[float, int]]
A number to be printed in a readable format. If ``None``, it
means you are only initializing an "empty" object with printing
options. In that case, you can use the `of()` method later to
print the readable result.
digit_group_size : int
The size of the digit group (in the integer part). For example,
if 3, the number 123456789 will be printed as 123,456,789.
If 0, no grouping will happen. Only non-negative integers
are allowed. (Default: 3)
digit_group_delimiter : str
The symbol to delimit the digit groups. For example, if it
is a space, the number 1234567 will be printed as 1 234 567.
(Default: ",")
decimal_symbol : str
The symbol to use as a decimal "point". Default: "."
precision : Optional[int]
How many digits to keep in the decimal part. For example,
if 3, the number -4.56789 will be printed as -4.568. If 0,
the number -75.924 will be printed as "-76." (including the dot).
If ``None``, the number will be printed in a "natural" way. For
example, 3.1415926 will be printed as "3.1415926". But if the
given number has more than 15 digits after the decimal point,
only the first 15 digits will be preserved due to the limit of
the double-precision system.
significant_figures_after_decimal_point : Optional[int]
How many significant figures to display after the decimal point.
"Significant figures" and "precision" are different concepts. Please
refer to https://en.wikipedia.org/wiki/Significant_figures for more
details. If one of ``precision`` and ``significant_figures`` is not
``None``, the other must be ``None``. If both are ``None``, the given
number will be printed in a "natural" way.
show_decimal_part_if_integer : bool
Whether to show the decimal part if the given number is an integer.
If this is false, it overrides ``precision``. If this is true,
the number of digits to show will be determined by ``precision``
(if ``precision`` is None, 2 digits will be used as per
convention). Default: False.
use_shortform : bool
If true, for large numbers, use notations such as "12.5k", "5.6M",
etc. Currently, the largest supported unit is T (trillion). Numbers
whose absolute values are larger than 1,000 trillion will be
printed as something like "1535663T".
use_exponent_for_large_numbers : bool
Whether to use exponential notation (such as 1.2e+05) to represent
large numbers. Default: False.
large_number_threshold : int
If ``num``'s absolute value is beyond (≥) this cutoff value, we
consider it a large number. Default: 1,000,000.
use_exponent_for_small_numbers : bool
Whether to use exponential notation (such as 1.2e-05) to represent
small numbers. Default: False.
small_number_threshold : float
If ``num``'s absolute value is blow (≤) this cutoff value, we
consider it to be a small number. Default: 1 × 10⁻⁶.
Examples
--------
>>> from readable_number import ReadableNumber
>>> str(ReadableNumber(1234.567))
>>> rn = ReadableNumber() # alternative way to print numbers
>>> rn.of(1234.567)
"""
def __init__(
self,
num: Optional[Union[float, int]] = None,
digit_group_size: int = 3,
digit_group_delimiter: str = ',',
decimal_symbol: str = '.',
precision: Optional[int] = None,
significant_figures_after_decimal_point: Optional[int] = None,
show_decimal_part_if_integer: bool = False,
use_shortform: bool = False,
use_exponent_for_large_numbers: bool = False,
large_number_threshold: int = 1_000_000,
use_exponent_for_small_numbers: bool = False,
small_number_threshold: float = 1e-6,
) -> None:
self.num: Optional[Union[float, int]] = self._convert_to_num(num)
self.digit_group_length = digit_group_size
self.digit_group_delimiter = digit_group_delimiter
self.decimal_symbol = decimal_symbol
self.precision = precision
self.significant_figures_after_decimal_point = (
significant_figures_after_decimal_point
)
self.show_decimal_part_if_integer = show_decimal_part_if_integer
self.use_shortform = use_shortform
self.use_exponent_for_large_numbers = use_exponent_for_large_numbers
self.large_number_threshold = large_number_threshold
self.use_exponent_for_small_numbers = use_exponent_for_small_numbers
self.small_number_threshold = small_number_threshold
self._validate_input_params()
def __deepcopy__(self, memo: Any) -> 'ReadableNumber':
return self.deepcopy()
def __repr__(self) -> str:
return str(self.num)
def __str__(self) -> str:
if self.num is None:
raise ValueError(
'Please initialize the object with an actual number,'
' or use the `of()` method to pass in a number.',
)
if not math.isfinite(self.num):
return str(self.num)
if (
self.use_exponent_for_small_numbers
and 0 < math.fabs(self.num) <= self.small_number_threshold
):
return self._render_number_in_exponential()
if (
self.use_exponent_for_large_numbers
and math.fabs(self.num) >= self.large_number_threshold
):
return self._render_number_in_exponential()
self.num_parts = self._get_integer_and_decimal_parts(self.num)
if self.use_shortform and abs(self.num_parts.integer_part_int) > 1_000:
return self._render_integer_part_with_shortform()
decimal_part: str
if self._is_integer():
if self.show_decimal_part_if_integer:
decimal_part = (
'00'
if self.precision is None
else '0'.zfill(self.precision)
)
return (
self._render_integer_part_in_groups()
+ self.decimal_symbol
+ decimal_part
)
return self._render_integer_part_in_groups()
carry: int # https://en.wikipedia.org/wiki/Carry_(arithmetic)
decimal_part, carry = self._render_decimal_part()
return (
self._render_integer_part_in_groups(carry=carry)
+ self.decimal_symbol
+ decimal_part
)
[docs] def of(self, num: Union[float, int]) -> str:
"""
Print the number ``num`` in a readable format. This method is
useful when you don't want to repeatedly specify the same options
when printing many numbers.
Parameters
----------
num : Union[float, int]
The number to be printed
Returns
-------
str
The number in a readable format
Examples
--------
>>> from readable_number import ReadableNumber
>>> rn = ReadableNumber()
>>> rn.of(1234.567)
"""
self.num = num
return self.__str__()
[docs] def deepcopy(self) -> 'ReadableNumber':
"""Make a deep copy of itself"""
new_instance = self.__class__.__new__(self.__class__)
new_instance.__dict__ = copy.deepcopy(self.__dict__)
return new_instance
def _validate_input_params(self) -> None:
if not isinstance(self.digit_group_length, int):
raise TypeError('`digit_group_size` not an integer')
if self.digit_group_length < 0:
raise ValueError('`digit_group_size` should >= 0')
if not isinstance(self.digit_group_delimiter, str):
raise TypeError('`digit_group_delimiter` not a string')
if self.digit_group_delimiter == '-':
msg = 'Using "-" as `digit_group_delimiter` can cause ambiguity'
raise ValueError(msg)
if not isinstance(self.decimal_symbol, str):
raise TypeError('`decimal_symbol` not a string')
if self.decimal_symbol == '-':
msg = 'Using "-" as `decimal_symbol` can cause ambiguity'
raise ValueError(msg)
if self.precision is not None and not isinstance(self.precision, int):
msg = '`precision` not None and not int'
raise TypeError(msg)
if (
self.precision is not None
and self.significant_figures_after_decimal_point is not None
):
raise ValueError(
'Only one of `precision` and'
' `significant_figures_after_decimal_point` can be non-None.'
)
if self.precision is not None and self.precision < 0:
raise ValueError('`precision` should >= 0')
if (
self.significant_figures_after_decimal_point is not None
and self.significant_figures_after_decimal_point <= 0
):
raise ValueError(
'`significant_figures_after_decimal_point` should > 0'
)
if not isinstance(self.show_decimal_part_if_integer, bool):
raise TypeError('`show_decimal_part_if_integer` not a boolean')
if not isinstance(self.use_shortform, bool):
raise TypeError('`use_shortform` not a boolean')
if not isinstance(self.use_exponent_for_large_numbers, bool):
raise TypeError('`use_exponent_for_large_numbers` not a boolean')
if not isinstance(self.use_exponent_for_small_numbers, bool):
raise TypeError('`use_exponent_for_small_numbers` not a boolean')
def _render_number_in_exponential(self) -> str:
if self.precision is not None:
return f'{self.num:.{self.precision}e}'
if self.significant_figures_after_decimal_point is not None:
sig_fig = self.significant_figures_after_decimal_point
return f'{self.num:.{sig_fig}e}'
temp_result = f'{self.num:.16e}' # 16: max precision in 64-bit system
base_part_str, exp_part_str = temp_result.split('e')
base_part_float = float(base_part_str)
assert abs(base_part_float) < 10, f'Internal error. {MSG_CONTACT_US}'
processed = str(ReadableNumber(base_part_float, precision=None))
return processed + 'e' + exp_part_str
def _render_integer_part_with_shortform(self) -> str:
num_digits = len(str(abs(self.num_parts.integer_part_int)))
tier = (num_digits - 1) // 3
tier = min(tier, MAX_TIER)
unit_name = METRIC_PREFIX_LOOKUP[tier]
prec_: int
if (
self.precision is None
and self.significant_figures_after_decimal_point is None
):
prec_ = 0
elif self.precision is not None:
prec_ = self.precision
elif self.significant_figures_after_decimal_point is not None:
prec_ = self.significant_figures_after_decimal_point
else:
raise _InternalError('This should not have happened')
nn: int = min(prec_, MAX_DIGITS_IN_DOUBLE_PRECISION)
float_part = round(
self.num / 10 ** (tier * 3),
ndigits=prec_,
)
float_part_str = '{:.{prec}f}'.format(
float_part,
prec=nn,
)
return float_part_str + unit_name
def _render_integer_part_in_groups(self, carry: int = 0) -> str:
counter = 0
new_chars = []
# We do `int(self.num_parts.integer_part_str)` because
# self.num_parts.integer_part_int may be negative, but we don't want
# to include the negative sign here. The negative sign is handled
# below (later in this function).
integer_part_str = str(int(self.num_parts.integer_part_str) + carry)
for char in integer_part_str[::-1]:
counter += 1
new_chars.append(char)
if (
self.digit_group_length > 0
and counter % self.digit_group_length == 0
):
new_chars.append(self.digit_group_delimiter)
if (
new_chars[-1] == '-'
and new_chars[-2] == self.digit_group_delimiter
):
new_chars.pop(-2)
elif new_chars[-1] == self.digit_group_delimiter:
new_chars.pop(-1)
temp_result: str = ''.join(new_chars[::-1])
if self.num_parts.sign == -1 and not temp_result.startswith('-'):
return '-' + temp_result
return temp_result
def _render_decimal_part(self) -> Tuple[str, int]:
"""
Render the decimal part.
The 2nd return value means the amount to carry.
See more: https://en.wikipedia.org/wiki/Carry_(arithmetic)
"""
self._sanity_check_for_render_decimal_part()
method: _DecimalPartRenderingMethod
if self.significant_figures_after_decimal_point is not None:
if math.fabs(self.num) >= 1: # type: ignore[arg-type]
# This means `self.significant_figures` has no effect,
# because |num| ≥ 1
method = _DecimalPartRenderingMethod.NATURAL
else:
method = _DecimalPartRenderingMethod.SIGNIFICANT_FIGURES
elif self.precision is not None:
method = _DecimalPartRenderingMethod.HARD_PRECISION
else: # both precision and significant_figures are None
method = _DecimalPartRenderingMethod.NATURAL
rounded_str: str
decimal_part: str
rounded: float
carry: int
if _DecimalPartRenderingMethod.SIGNIFICANT_FIGURES == method:
_num: float = self.num_parts.decimal_part_float # shorthand
sig_fig = self.significant_figures_after_decimal_point
rounded_str = f'{_num:.{sig_fig}g}'
if 'e' not in rounded_str:
decimal_part = rounded_str.split('.')[1]
else:
rn_copy: 'ReadableNumber' = self.deepcopy()
rn_copy.num = float(rounded_str)
decimal_part = str(rn_copy).split('.')[1]
rounded = float(rounded_str)
carry = 1 if rounded >= 10**self.num_parts.multiplier else 0
else:
if _DecimalPartRenderingMethod.NATURAL == method:
nn = min(
MAX_DIGITS_IN_DOUBLE_PRECISION, # cap at this many digits
# if fewer than the upper bound, display naturally:
len(self.num_parts.decimal_part_str),
)
else: # _DecimalPartRenderingMethod.HARD_PRECISION
nn = min(self.precision, MAX_DIGITS_IN_DOUBLE_PRECISION) # type: ignore[type-var, assignment]
rounded_str = '{:.{prec}f}'.format(
self.num_parts.decimal_part_float,
prec=nn,
)
rounded = float(rounded_str)
decimal_part = '' if nn == 0 else rounded_str.split('.')[1][:nn]
carry = 1 if rounded >= 10**self.num_parts.multiplier else 0
if decimal_part.startswith('-'):
raise _InternalError(f"Shouldn't have happened. {MSG_CONTACT_US}")
return self._post_process_decimal_part(decimal_part, carry)
def _sanity_check_for_render_decimal_part(self) -> None:
if (
self.precision is not None
and self.significant_figures_after_decimal_point is not None
):
raise _InternalError(f'Both cannot be non-None. {MSG_CONTACT_US}')
def _post_process_decimal_part(
self,
decimal_part: str,
carry: int,
) -> Tuple[str, int]:
decimal_part_: str = '0' * self.num_parts.multiplier + decimal_part
if self.precision is not None:
precision_ = self.precision
elif self.significant_figures_after_decimal_point is not None:
precision_ = (
self.significant_figures_after_decimal_point
+ self.num_parts.multiplier
)
else:
precision_ = None
decimal_part__: str = self._round_digits(decimal_part_, precision_)
overflow_happened: bool
if precision_ is None:
overflow_happened = len(decimal_part__) > len(decimal_part_)
else:
overflow_happened = len(decimal_part__) > max(
len(decimal_part_), precision_
)
if overflow_happened:
carry += 1
decimal_part_final = decimal_part__[:1]
else:
decimal_part_final = decimal_part__
return decimal_part_final, carry
def _is_integer(self) -> bool:
return int(self.num) == self.num # type: ignore[arg-type]
@classmethod
def _is_sig(cls, char: str) -> bool:
return char in {'1', '2', '3', '4', '5', '6', '7', '8', '9'}
@classmethod
def _convert_to_num(cls, num: Any) -> Optional[Union[float, int]]:
if isinstance(num, (float, int, type(None))):
return num
if isinstance(num, complex):
raise TypeError('Complex numbers are not supported.')
return float(num) # it would naturally fail if `num` is not valid
@classmethod
def _get_integer_and_decimal_parts(
cls,
num: Union[float, int],
) -> _IntegerAndDecimalParts:
if num > 0:
sign = 1
neg_sign = ''
elif num < 0:
sign = -1
neg_sign = '-'
else:
sign = 0
neg_sign = ''
mantissa, exponent = cls._decompose_float(num)
how_many_leading_0s_after_decimal_point: int = -exponent - 1
if math.fabs(num) < 0.1:
multiplier = how_many_leading_0s_after_decimal_point
# More accurate than "num *= 10 ** multiplier"
num = float(Decimal(num) * Decimal(10**multiplier))
else:
multiplier = 0
string_representation: str = str(num)
if string_representation[0] == '-':
string_representation = string_representation[1:]
if isinstance(num, int):
return _IntegerAndDecimalParts(
integer_part_str=string_representation,
integer_part_int=num,
decimal_part_str='',
decimal_part_float=0.0,
sign=sign,
multiplier=multiplier,
)
if 'e-' in string_representation: # this means |num| is small
if math.fabs(num) > 1:
raise _InternalError(f'`num` ({num}) is more than 1')
mantissa_str, exponent_str = string_representation.split('e')
mantissa_str_parts = mantissa_str.split('.')
decimal_part_str = mantissa_str_parts[0].zfill(
abs(int(exponent_str))
)
if len(mantissa_str_parts) > 1:
decimal_part_str += mantissa_str_parts[1]
return _IntegerAndDecimalParts(
integer_part_str='0',
integer_part_int=0,
decimal_part_str=decimal_part_str,
decimal_part_float=float(neg_sign + '0.' + decimal_part_str),
sign=sign,
multiplier=multiplier,
)
if 'e+' in string_representation: # this means |num| is big
integer_val: int = int(num)
decimal_val: float = num % 1
decimal_str = (
'' if decimal_val == 0 else str(decimal_val).split('.')[1]
)
return _IntegerAndDecimalParts(
integer_part_str=str(integer_val),
integer_part_int=integer_val,
decimal_part_str=decimal_str,
decimal_part_float=decimal_val,
sign=sign,
multiplier=multiplier,
)
if 'e' in string_representation:
raise _InternalError(
f'"e" in `string_representation`. {MSG_CONTACT_US}'
)
integer_part_str, decimal_part_str = string_representation.split('.')
return _IntegerAndDecimalParts(
integer_part_str=integer_part_str,
integer_part_int=int(integer_part_str),
decimal_part_str=decimal_part_str,
decimal_part_float=float('0.' + decimal_part_str),
sign=sign,
multiplier=multiplier,
)
@classmethod
def _decompose_float(cls, num: Union[float, int]) -> Tuple[float, int]:
dec = Decimal(math.fabs(num))
exponent = cls._get_base_10_exp(dec)
mantissa = float(dec.scaleb(-exponent).normalize())
if mantissa == 10.0:
mantissa = 1.0
exponent += 1
return mantissa, exponent
@classmethod
def _get_base_10_exp(cls, decimal_: Decimal) -> int:
sign, digits, exponent = decimal_.as_tuple()
return len(digits) + exponent - 1 # type: ignore[operator]
@classmethod
def _round_digits(cls, digits: str, precision: Optional[int]) -> str:
"""For example:
* digits = '00013245', precision = 5 --> output: '00013'
* digits = '00013745', precision = 5 --> output: '00014'
* digits = '00019745', precision = 5 --> output: '00020'
* digits = '00019745', precision = 10 --> output: '0001974500'
"""
if precision == len(digits) or digits == '':
return digits
if precision is None: # "natural" way to round: strip 0
return digits.rstrip('0')
if precision < 0:
raise _InternalError(f'`precision` should >= 0. {MSG_CONTACT_US}')
if precision == 0:
return ''
if precision > len(digits):
how_many_0s_to_add: int = precision - len(digits)
return digits + '0' * how_many_0s_to_add
digits_to_keep: str = digits[:precision]
digits_to_throw_away: str = digits[precision:]
should_carry: bool = digits_to_throw_away[0] >= '5'
if should_carry:
digits_to_keep = cls._carry(digits_to_keep)
return digits_to_keep
@classmethod
def _carry(cls, digits: str) -> str:
if digits == '':
return '1'
last_digit: str = digits[-1]
if last_digit < '9':
return digits[:-1] + chr(ord(last_digit) + 1)
return cls._carry(digits[:-1]) + '0'