Skip to content

Commit 29d37fb

Browse files
authored
Merge pull request #22 from bwoodsend/metric
2 parents 7688f20 + 19726a0 commit 29d37fb

File tree

3 files changed

+106
-23
lines changed

3 files changed

+106
-23
lines changed

src/humanize/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
fractional,
99
intcomma,
1010
intword,
11+
metric,
1112
ordinal,
1213
scientific,
1314
)
@@ -38,6 +39,7 @@
3839
"fractional",
3940
"intcomma",
4041
"intword",
42+
"metric",
4143
"naturaldate",
4244
"naturalday",
4345
"naturaldelta",

src/humanize/number.py

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ def scientific(value: NumberOrString, precision: int = 2) -> str:
337337
>>> scientific(int(500))
338338
'5.00 x 10²'
339339
>>> scientific(-1000)
340-
'1.00 x 10³'
340+
'-1.00 x 10³'
341341
>>> scientific(1000, 1)
342342
'1.0 x 10³'
343343
>>> scientific(1000, 3)
@@ -369,35 +369,19 @@ def scientific(value: NumberOrString, precision: int = 2) -> str:
369369
"7": "⁷",
370370
"8": "⁸",
371371
"9": "⁹",
372-
"+": "⁺",
373372
"-": "⁻",
374373
}
375-
negative = False
376374
try:
377-
if "-" in str(value):
378-
value = str(value).replace("-", "")
379-
negative = True
380-
381-
if isinstance(value, str):
382-
value = float(value)
383-
384-
fmt = "{:.%se}" % str(int(precision))
385-
n = fmt.format(value)
386-
375+
value = float(value)
387376
except (ValueError, TypeError):
388377
return str(value)
389-
378+
fmt = "{:.%se}" % str(int(precision))
379+
n = fmt.format(value)
390380
part1, part2 = n.split("e")
391-
if "-0" in part2:
392-
part2 = part2.replace("-0", "-")
393-
394-
if "+0" in part2:
395-
part2 = part2.replace("+0", "")
381+
# Remove redundant leading '+' or '0's (preserving the last '0' for 10⁰).
382+
part2 = re.sub(r"^\+?(\-?)0*(.+)$", r"\1\2", part2)
396383

397384
new_part2 = []
398-
if negative:
399-
new_part2.append(exponents["-"])
400-
401385
for char in part2:
402386
new_part2.append(exponents[char])
403387

@@ -474,3 +458,59 @@ def clamp(
474458
"Invalid format. Must be either a valid formatting string, or a function "
475459
"that accepts value and returns a string."
476460
)
461+
462+
463+
def metric(value: float, unit: str = "", precision: int = 3) -> str:
464+
"""Return a value with a metric SI unit-prefix appended.
465+
466+
Examples:
467+
```pycon
468+
>>> metric(1500, "V")
469+
'1.50 kV'
470+
>>> metric(2e8, "W")
471+
'200 MW'
472+
>>> metric(220e-6, "F")
473+
'220 μF'
474+
>>> metric(1e-14, precision=4)
475+
'10.00 f'
476+
477+
```
478+
479+
The unit prefix is always chosen so that non-significant zero digits are required.
480+
i.e. `123,000` will become `123k` instead of `0.123M` and `1,230,000` will become
481+
`1.23M` instead of `1230K`. For numbers that are either too huge or too tiny to
482+
represent without resorting to either leading or trailing zeroes, it falls back to
483+
`scientific()`.
484+
```pycon
485+
>>> metric(1e40)
486+
'1.00 x 10⁴⁰'
487+
488+
```
489+
490+
Args:
491+
value (int, float): Input number.
492+
unit (str): Optional base unit.
493+
precision (int): The number of digits the output should contain.
494+
495+
Returns:
496+
str:
497+
"""
498+
exponent = int(math.floor(math.log10(abs(value))))
499+
500+
if exponent >= 27 or exponent < -24:
501+
return scientific(value, precision - 1) + unit
502+
503+
value /= 10 ** (exponent // 3 * 3)
504+
if exponent >= 3:
505+
ordinal = "kMGTPEZY"[exponent // 3 - 1]
506+
elif exponent < 0:
507+
ordinal = "mμnpfazy"[(-exponent - 1) // 3]
508+
else:
509+
ordinal = ""
510+
value_ = format(value, ".%if" % (precision - (exponent % 3) - 1))
511+
if not (unit or ordinal) or unit in ("°", "′", "″"):
512+
space = ""
513+
else:
514+
space = " "
515+
516+
return f"{value_}{space}{ordinal}{unit}"

tests/test_number.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def test_fractional(test_input: float | str, expected: str) -> None:
144144
"test_args, expected",
145145
[
146146
([1000], "1.00 x 10³"),
147-
([-1000], "1.00 x 10³"),
147+
([-1000], "-1.00 x 10³"),
148148
([5.5], "5.50 x 10⁰"),
149149
([5781651000], "5.78 x 10⁹"),
150150
(["1000"], "1.00 x 10³"),
@@ -156,6 +156,10 @@ def test_fractional(test_input: float | str, expected: str) -> None:
156156
([float(0.3), 1], "3.0 x 10⁻¹"),
157157
([1000, 0], "1 x 10³"),
158158
([float(0.3), 0], "3 x 10⁻¹"),
159+
([float(1e20)], "1.00 x 10²⁰"),
160+
([float(2e-20)], "2.00 x 10⁻²⁰"),
161+
([float(-3e20)], "-3.00 x 10²⁰"),
162+
([float(-4e-20)], "-4.00 x 10⁻²⁰"),
159163
],
160164
)
161165
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:
177181
)
178182
def test_clamp(test_args: list[typing.Any], expected: str) -> None:
179183
assert humanize.clamp(*test_args) == expected
184+
185+
186+
@pytest.mark.parametrize(
187+
"test_args, expected",
188+
[
189+
([1, "Hz"], "1.00 Hz"),
190+
([1.0, "W"], "1.00 W"),
191+
([3, "C"], "3.00 C"),
192+
([3, "W", 5], "3.0000 W"),
193+
([1.23456], "1.23"),
194+
([12.3456], "12.3"),
195+
([123.456], "123"),
196+
([1234.56], "1.23 k"),
197+
([12345, "", 6], "12.3450 k"),
198+
([200_000], "200 k"),
199+
([1e25, "m"], "10.0 Ym"),
200+
([1e26, "m"], "100 Ym"),
201+
([1e27, "A"], "1.00 x 10²⁷A"),
202+
([1.234e28, "A"], "1.23 x 10²⁸A"),
203+
([-1500, "V"], "-1.50 kV"),
204+
([0.12], "120 m"),
205+
([0.012], "12.0 m"),
206+
([0.0012], "1.20 m"),
207+
([0.00012], "120 μ"),
208+
([1e-23], "10.0 y"),
209+
([1e-24], "1.00 y"),
210+
([1e-25], "1.00 x 10⁻²⁵"),
211+
([1e-26], "1.00 x 10⁻²⁶"),
212+
([1, "°"], "1.00°"),
213+
([0.1, "°"], "100m°"),
214+
([100], "100"),
215+
([0.1], "100 m"),
216+
],
217+
ids=str,
218+
)
219+
def test_metric(test_args: list[typing.Any], expected: str) -> None:
220+
assert humanize.metric(*test_args) == expected

0 commit comments

Comments
 (0)