From 746aafb678b5fa2cd9736d5a255b023540a98c5c 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 | 2 ++ src/humanize/number.py | 52 ++++++++++++++++++++++++++++++++++++++++ tests/test_number.py | 33 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index dda543d..25c0ba1 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -8,6 +8,7 @@ fractional, intcomma, intword, + metric, ordinal, scientific, ) @@ -38,6 +39,7 @@ "fractional", "intcomma", "intword", + "metric", "naturaldate", "naturalday", "naturaldelta", diff --git a/src/humanize/number.py b/src/humanize/number.py index 511060a..69b4c3c 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -458,3 +458,55 @@ def clamp( "Invalid format. Must be either a valid formatting string, or a function " "that accepts value and returns a string." ) + + +def metric(value: float, unit: str = "", precision: int = 3) -> str: + """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 + + 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 b69e9ce..7086d40 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -181,3 +181,36 @@ def test_scientific(test_args: list[typing.Any], expected: str) -> None: ) def test_clamp(test_args: list[typing.Any], expected: str) -> None: 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"), + ([0.12], "120m"), + ([0.012], "12.0m"), + ([0.0012], "1.20m"), + ([0.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