From bbd8fc6c0d30608a749374d950f56bfae6f69dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=A9nainn=20Woodsend?= Date: Fri, 17 Jun 2022 23:25:01 +0100 Subject: [PATCH] Add humanize.metric() for converting big/small numbers to SI units. --- src/humanize/__init__.py | 1 + src/humanize/number.py | 52 ++++++++++++++++++++++++++++++++++++++++ tests/test_number.py | 32 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 0953c6d..fe112e8 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -10,6 +10,7 @@ intword, ordinal, scientific, + metric, ) from humanize.time import ( naturaldate, diff --git a/src/humanize/number.py b/src/humanize/number.py index 79de25f..4f74c3f 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -436,3 +436,55 @@ def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_toke "Invalid format. Must be either a valid formatting string, or a function " "that accepts value and returns a string." ) + + +def metric(value, unit="", precision=3): + """Return a value with a metric SI unit-prefix appended. + + Examples: + ```pycon + >>> metric(1500, "V") + '1.50kV' + >>> metric(2e8, "W") + '200MW' + >>> metric(220e-6, "F") + '220μF' + >>> metric(1e-14, precision=4) + '10.00f' + + ``` + + The unit prefix is always chosen so that non-significant zero digits are required. + i.e. `123,000` will become `123k` instead of `0.123M` and `1,230,000` will become + `1.23M` instead of `1230K`. For numbers that are either too huge or too tiny to + represent without resorting to either leading or trailing zeroes, it falls back to + `scientific()`. + ```pycon + >>> metric(1e40) + '1.00 x 10⁴⁰' + + ``` + + Args: + value (int, float): Input number. + unit (str): Optional base unit. + precision (int): The number of digits the output should contain. + + Returns: + str: + """ + exponent = int(math.floor(math.log10(abs(value)))) + + if exponent >= 27 or exponent < -24: + return scientific(value, precision - 1) + unit + else: + value /= (10 ** (exponent // 3 * 3)) + if exponent >= 3: + ordinal = "kMGTPEZY"[exponent // 3 - 1] + elif exponent < 0: + ordinal = "mμnpfazy"[(-exponent - 1) // 3] + else: + ordinal = "" + value = format(value, ".%if" % (precision - (exponent % 3) - 1)) + + return f'{value}{ordinal}{unit}' diff --git a/tests/test_number.py b/tests/test_number.py index 6bee35c..c3cde5e 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -176,3 +176,35 @@ def test_scientific(test_args, expected): ) def test_clamp(test_args, expected): assert humanize.clamp(*test_args) == expected + + +@pytest.mark.parametrize( + "test_args, expected", + [ + ([1, "Hz"], "1.00Hz"), + ([1.0, "W"], "1.00W"), + ([3, "C"], "3.00C"), + ([3, "W", 5], "3.0000W"), + ([1.23456], "1.23"), + ([12.3456], "12.3"), + ([123.456], "123"), + ([1234.56], "1.23k"), + ([12345, "", 6], "12.3450k"), + ([200_000], "200k"), + ([1e25, "m"], "10.0Ym"), + ([1e26, "m"], "100Ym"), + ([1e27, "A"], "1.00 x 10²⁷A"), + ([1.234e28, "A"], "1.23 x 10²⁸A"), + ([-1500, "V"], "-1.50kV"), + ([.12], "120m"), + ([.012], "12.0m"), + ([.0012], "1.20m"), + ([.00012], "120μ"), + ([1e-23], "10.0y"), + ([1e-24], "1.00y"), + ([1e-25], "1.00 x 10⁻²⁵"), + ([1e-26], "1.00 x 10⁻²⁶"), + ], ids=str +) +def test_metric(test_args, expected): + assert humanize.metric(*test_args) == expected