To me your code looks very WET. Everything is using the same core but with minor tweeks here and there. And so we can convert your code to a for loop.
TRANSFORMS = [
( 12, "$", 10**12, 3, ".3f", " trillion", ".000", ""),
( 9, "$", 10** 9, 2, ".2f", " billion", ".00", ""),
( 6, "$", 10** 6, 1, ".1f", " million", ".0", ""),
( 0, "$", 10** 0, 2, ".2f", "", ".00", ""),
(float("-inf"), "", 10**-2, None, "", " cents", "1 cents", "1 cent"),
]
def convert_float_to_value(number):
if number == 0.0:
return "0 cents"
digits = 1 + math.floor(math.log10(number))
for d, *t in TRANSFORMS:
if digits > d:
return f"{t[0]}{round(number / t[1], t[2]):,{t[3]}}{t[4]}".replace(t[5], t[6])
From here we can start to see patterns and other improvements.
Excluding cents; t[2]
, t[3]
and t[5]
all have the same size.
This means we can build them all from one value.
v = ...
t[2] = v
t[3] = f".{v}f"
t[4] = "." + "0"*v
It makes little sense to use .2f
for billions. I've never, until this day, seen "$1.10 billion". It's almost like you've used the wrong word and meant to say "dollars".
To fix this I'd stop using .2f
. However this will break how .replace
works, and can add some hard to fix bugs.
(Converting 10.01 to 101)
To fix this I'd convert integers to int
s and keeps floats as floats.
We haven't built t[3]
and t[5]
from t[2]
for plain dollars as it still requires .2f
to make sense.
To deal with this we can make a function pretty_round
that takes a number, a number of digets to round to, an option for the output to be a fixed amount of decimal places and any additional formatting. The only thing is that the output has return a string and a float.
def pretty_round(number, ndigits=None, fixed=False, format_spec=""):
number = round(number, ndigits)
if number % 1 == 0:
return number, format(int(number), format_spec)
return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
With the above changes we can see t[5]
and t[6]
are only there to convert cents from plural to singular.
And so why not just check if the output is singular or plural and append the correct term?
By changing the method of finding digits
to use len
we can replace float('-inf')
with -2
and build t[1]
from d
.
I am going to reorder the table so that they're grouped better. t[0]
should be near t[5]
and t[6]
.
And we can now give them easy to understand names.
TRANSFORMS = [
(12, 3, False, " trillion", "$", "", ""),
( 9, 2, False, " billion", "$", "", ""),
( 6, 1, False, " million", "$", "", ""),
( 0, 2, True, "", "$", "", ""),
(-2, None, False, "", "", " cents", " cent"),
]
def pretty_round(number, ndigits=None, fixed=False, format_spec=""):
number = round(number, ndigits)
if number % 1 == 0:
return number, format(int(number), format_spec)
return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
def convert_float_to_value(number):
digits = len(str(int(number)).lstrip('0-'))
for exp, dp, fixed, magnitude, prefix, plural, singular in TRANSFORMS:
if digits > exp:
number_f, number_s = pretty_round(number / 10**exp, dp, fixed, ",")
name = singular if number_f == 1 and exp <= 0 else plural
return f"{prefix}{number_s}{magnitude}{name}"
We can see that TRANSFORMS
is not normalized.
All of prefix
, plural
and singular
are refering to dollers and cents.
This can be extracted out into it's own table.
Not only does it reduce how WET your code is it allows us to replace dollars with other monetary notations.
Additionally it allows making changes easier if you want to change from using "$" to "dollars"
The magnitude can change even in English. This is as 1 trillion can equal 1 billion, depending on which scale you use.
Having this baked into the TRANSFORMS
table can be problematic in the future.
With your values fixed
is the same as not exp
.
SHORT_SCALE = [
"",
" thousand",
" million",
" billion",
" trillion",
]
USD = [
("$", "", ""),
("", " cents", " cent"),
]
TRANSFORMS = [
(12, 3),
( 9, 2),
( 6, 1),
( 0, 2),
(-2, 0),
]
def pretty_round(number, ndigits=None, fixed=False, format_spec=""):
number = round(number, ndigits)
if number % 1 == 0:
return number, format(int(number), format_spec)
return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
def convert_float_to_value(number, *, scale=SHORT_SCALE, currency=USD):
digits = len(str(int(number)).lstrip('0-'))
for exp, dp in TRANSFORMS:
if digits > exp:
number_f, number_s = pretty_round(number / 10**exp, dp, not exp, ",")
prefix, plural, singular = currency[exp < 0]
name = singular if number_f == 1 and exp <= 0 else plural
return f"{prefix}{number_s}{scale[int(exp / 3)]}{name}"
convert_float_to_value(.51)
returns'51 cent'
\$\endgroup\$return re.sub(r"^1 cents$", "1 cent", f"{round(number * 100):,} cents")
or perhaps some logic that checks the output string is exactly1 cents
and then returns1 cent
. \$\endgroup\$