diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 96b2655..0f9026c 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -3,7 +3,15 @@ from humanize.filesize import naturalsize from humanize.i18n import activate, deactivate, thousands_separator -from humanize.number import apnumber, fractional, intcomma, intword, ordinal, scientific +from humanize.number import ( + apnumber, + clamp, + fractional, + intcomma, + intword, + ordinal, + scientific, +) from humanize.time import ( naturaldate, naturalday, @@ -19,6 +27,7 @@ "__version__", "activate", "apnumber", + "clamp", "deactivate", "fractional", "intcomma", diff --git a/src/humanize/number.py b/src/humanize/number.py index f425395..d391c2e 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -363,3 +363,66 @@ def scientific(value, precision=2): final_str = part1 + " x 10" + "".join(new_part2) return final_str + + +def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_token=">"): + """Returns number with the specified format, clamped between floor and ceil. + + If the number is larger than ceil or smaller than floor, then the respective limit + will be returned, formatted and prepended with a token specifying as such. + + Examples: + ```pycon + >>> clamp(123.456) + '123.456' + >>> clamp(0.0001, floor=0.01) + '<0.01' + >>> clamp(0.99, format="{:.0%}", ceil=0.99) + '99%' + >>> clamp(0.999, format="{:.0%}", ceil=0.99) + '>99%' + >>> clamp(1, format=intword, floor=1e6, floor_token="under ") + 'under 1.0 million' + >>> clamp(None) is None + True + + ``` + + Args: + value (int, float): Input number. + format (str OR callable): Can either be a formatting string, or a callable + function than receives value and returns a string. + floor (int, float): Smallest value before clamping. + ceil (int, float): Largest value before clamping. + floor_token (str): If value is smaller than floor, token will be prepended + to output. + ceil_token (str): If value is larger than ceil, token will be prepended + to output. + + Returns: + str: Formatted number. The output is clamped between the indicated floor and + ceil. If the number if larger than ceil or smaller than floor, the output will + be prepended with a token indicating as such. + + """ + if value is None: + return None + + if floor is not None and value < floor: + value = floor + token = floor_token + elif ceil is not None and value > ceil: + value = ceil + token = ceil_token + else: + token = "" + + if isinstance(format, str): + return token + format.format(value) + elif callable(format): + return token + format(value) + else: + raise ValueError( + "Invalid format. Must be either a valid formatting string, or a function " + "that accepts value and returns a string." + ) diff --git a/tests/test_number.py b/tests/test_number.py index 25f1d72..03427fd 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -154,3 +154,20 @@ def test_fractional(test_input, expected): ) def test_scientific(test_args, expected): assert humanize.scientific(*test_args) == expected + + +@pytest.mark.parametrize( + "test_args, expected", + [ + ([1], "1"), + ([None], None), + ([0.0001, "{:.0%}"], "0%"), + ([0.0001, "{:.0%}", 0.01], "<1%"), + ([0.9999, "{:.0%}", None, 0.99], ">99%"), + ([0.0001, "{:.0%}", 0.01, None, "under ", None], "under 1%"), + ([0.9999, "{:.0%}", None, 0.99, None, "above "], "above 99%"), + ([1, humanize.intword, 1e6, None, "under "], "under 1.0 million"), + ], +) +def test_clamp(test_args, expected): + assert humanize.clamp(*test_args) == expected