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 3f6070f..08f0cd8 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -337,7 +337,7 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: >>> scientific(int(500)) '5.00 x 10²' >>> scientific(-1000) - '1.00 x 10⁻³' + '-1.00 x 10³' >>> scientific(1000, 1) '1.0 x 10³' >>> scientific(1000, 3) @@ -369,35 +369,19 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: "7": "⁷", "8": "⁸", "9": "⁹", - "+": "⁺", "-": "⁻", } - negative = False try: - if "-" in str(value): - value = str(value).replace("-", "") - negative = True - - if isinstance(value, str): - value = float(value) - - fmt = "{:.%se}" % str(int(precision)) - n = fmt.format(value) - + value = float(value) except (ValueError, TypeError): return str(value) - + fmt = "{:.%se}" % str(int(precision)) + n = fmt.format(value) part1, part2 = n.split("e") - if "-0" in part2: - part2 = part2.replace("-0", "-") - - if "+0" in part2: - part2 = part2.replace("+0", "") + # Remove redundant leading '+' or '0's (preserving the last '0' for 10⁰). + part2 = re.sub(r"^\+?(\-?)0*(.+)$", r"\1\2", part2) new_part2 = [] - if negative: - new_part2.append(exponents["-"]) - for char in part2: new_part2.append(exponents[char]) @@ -474,3 +458,59 @@ 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.50 kV' + >>> metric(2e8, "W") + '200 MW' + >>> metric(220e-6, "F") + '220 μF' + >>> metric(1e-14, precision=4) + '10.00 f' + + ``` + + 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)) + if not (unit or ordinal) or unit in ("°", "′", "″"): + space = "" + else: + space = " " + + return f"{value_}{space}{ordinal}{unit}" diff --git a/tests/test_number.py b/tests/test_number.py index 9b08d4d..4ffa706 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -144,7 +144,7 @@ def test_fractional(test_input: float | str, expected: str) -> None: "test_args, expected", [ ([1000], "1.00 x 10³"), - ([-1000], "1.00 x 10⁻³"), + ([-1000], "-1.00 x 10³"), ([5.5], "5.50 x 10⁰"), ([5781651000], "5.78 x 10⁹"), (["1000"], "1.00 x 10³"), @@ -156,6 +156,10 @@ def test_fractional(test_input: float | str, expected: str) -> None: ([float(0.3), 1], "3.0 x 10⁻¹"), ([1000, 0], "1 x 10³"), ([float(0.3), 0], "3 x 10⁻¹"), + ([float(1e20)], "1.00 x 10²⁰"), + ([float(2e-20)], "2.00 x 10⁻²⁰"), + ([float(-3e20)], "-3.00 x 10²⁰"), + ([float(-4e-20)], "-4.00 x 10⁻²⁰"), ], ) def test_scientific(test_args: list[typing.Any], expected: str) -> None: @@ -177,3 +181,40 @@ 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.00 Hz"), + ([1.0, "W"], "1.00 W"), + ([3, "C"], "3.00 C"), + ([3, "W", 5], "3.0000 W"), + ([1.23456], "1.23"), + ([12.3456], "12.3"), + ([123.456], "123"), + ([1234.56], "1.23 k"), + ([12345, "", 6], "12.3450 k"), + ([200_000], "200 k"), + ([1e25, "m"], "10.0 Ym"), + ([1e26, "m"], "100 Ym"), + ([1e27, "A"], "1.00 x 10²⁷A"), + ([1.234e28, "A"], "1.23 x 10²⁸A"), + ([-1500, "V"], "-1.50 kV"), + ([0.12], "120 m"), + ([0.012], "12.0 m"), + ([0.0012], "1.20 m"), + ([0.00012], "120 μ"), + ([1e-23], "10.0 y"), + ([1e-24], "1.00 y"), + ([1e-25], "1.00 x 10⁻²⁵"), + ([1e-26], "1.00 x 10⁻²⁶"), + ([1, "°"], "1.00°"), + ([0.1, "°"], "100m°"), + ([100], "100"), + ([0.1], "100 m"), + ], + ids=str, +) +def test_metric(test_args: list[typing.Any], expected: str) -> None: + assert humanize.metric(*test_args) == expected