diff --git a/README.md b/README.md index e14dec69..bd42ed67 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ If seconds are too large, set `minimum_unit` to milliseconds or microseconds: ```pycon >>> import humanize ->>> humanize.fractional(1/3) +>>> humanize.fractional(1 / 3) '1/3' >>> humanize.fractional(1.5) '1 1/2' @@ -199,6 +199,18 @@ If seconds are too large, set `minimum_unit` to milliseconds or microseconds: '1 x 10⁰' ``` +### Percentages and ratios + +```pycon +>>> import humanize +>>> humanize.percentage(50) +'50%' +>>> humanize.percentage(0.125, is_ratio=True, precision=1) +'12.5%' +>>> humanize.percentage(12.5, precision=2) +'12.50%' +``` + ## Localization How to change locale at runtime: diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 4f54bc46..d496e763 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -22,6 +22,7 @@ intword, metric, ordinal, + percentage, scientific, ) from humanize.time import ( @@ -52,6 +53,7 @@ "naturalsize", "naturaltime", "ordinal", + "percentage", "precisedelta", "scientific", "thousands_separator", diff --git a/src/humanize/number.py b/src/humanize/number.py index 52a5356a..0aa4a39c 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -601,3 +601,53 @@ def metric(value: float, unit: str = "", precision: int = 3) -> str: space = " " return f"{value_}{space}{ordinal_}{unit}" + + +def percentage( + value: NumberOrString, *, is_ratio: bool = False, precision: int = 0 +) -> str: + """Return a human-readable percentage representation of a number. + + Examples: + ```pycon + >>> percentage(50) + '50%' + >>> percentage(12.5, precision=1) + '12.5%' + >>> percentage(0.125, is_ratio=True, precision=1) + '12.5%' + >>> percentage(1) + '1%' + >>> percentage(1, is_ratio=True) + '100%' + >>> percentage(-1.25, is_ratio=True, precision=1) + '-125.0%' + >>> percentage("foo") + 'foo' + >>> percentage(None) + 'None' + + ``` + + Args: + value (int, float, str): Number or string to format as percentage. + is_ratio (bool): If True, value is treated as a ratio between 0 and 1 + and multiplied by 100. Defaults to False. + precision (int): Number of decimal places. Defaults to 0. + + Returns: + str: Formatted percentage string. + """ + import math + + # checking whether value is a number + try: + value = float(value) + if not math.isfinite(value): + return _format_not_finite(value) + except (ValueError, TypeError): + return str(value) + + if is_ratio: + value *= 100 + return f"{value:.{precision}f}%" diff --git a/tests/test_number.py b/tests/test_number.py index 5fb12fa6..e0abb278 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -377,3 +377,28 @@ def test_clamp(test_args: list[typing.Any], expected: str) -> None: ) def test_metric(test_args: list[typing.Any], expected: str) -> None: assert humanize.metric(*test_args) == expected + + +@pytest.mark.parametrize( + "value, kwargs, expected", + [ + (50, {}, "50%"), + (0, {}, "0%"), + (100, {}, "100%"), + (0.5, {"is_ratio": True}, "50%"), + (1, {"is_ratio": True}, "100%"), + (12.5, {"precision": 1}, "12.5%"), + (12.5, {"precision": 0}, "12%"), + (0.125, {"is_ratio": True, "precision": 1}, "12.5%"), + (-10, {}, "-10%"), + (-0.125, {"is_ratio": True, "precision": 1}, "-12.5%"), + ("75", {}, "75%"), + ("foo", {}, "foo"), + (None, {}, "None"), + (math.nan, {}, "NaN"), + (math.inf, {}, "+Inf"), + (-math.inf, {}, "-Inf"), + ], +) +def test_percentage(value, kwargs, expected): + assert humanize.percentage(value, **kwargs) == expected