diff --git a/src/humanize/time.py b/src/humanize/time.py index 4a07d52..981f17f 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -94,6 +94,20 @@ def _date_and_delta( return date, _abs_timedelta(delta) +def _minimum_unit_or_raise(name: str) -> Unit: + """Resolve *name* to a ``Unit`` or raise a clear ``ValueError``. + + A bare ``Unit[name.upper()]`` lookup raises an opaque ``KeyError`` for an + unknown unit name; this helper raises a consistent, helpful ``ValueError`` + instead. + """ + try: + return Unit[name.upper()] + except KeyError: + msg = f"Minimum unit '{name}' not supported" + raise ValueError(msg) from None + + def naturaldelta( value: dt.timedelta | float, months: bool = True, @@ -119,6 +133,7 @@ def naturaldelta( Raises: OverflowError: If `value` is too large to convert to datetime.timedelta. + ValueError: If `minimum_unit` is not a supported unit. Examples: Compare two timestamps in a custom local timezone:: @@ -138,11 +153,10 @@ def naturaldelta( """ import datetime as dt - tmp = Unit[minimum_unit.upper()] - if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): + min_unit = _minimum_unit_or_raise(minimum_unit) + if min_unit not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): msg = f"Minimum unit '{minimum_unit}' not supported" raise ValueError(msg) - min_unit = tmp if isinstance(value, dt.timedelta): delta = value @@ -275,6 +289,9 @@ def naturaltime( Returns: str: A natural representation of the input in a resolution that makes sense. + + Raises: + ValueError: If `minimum_unit` is not a supported unit. """ import datetime as dt @@ -534,17 +551,28 @@ def precisedelta( >>> precisedelta(delta, minimum_unit="minutes") '0 minutes' + ``` + + An unsupported ``minimum_unit`` raises a clear ``ValueError`` rather than + an opaque ``KeyError``: + + ```pycon + >>> precisedelta(dt.timedelta(seconds=1), minimum_unit="fortnights") + Traceback (most recent call last): + ... + ValueError: Minimum unit 'fortnights' not supported + ``` """ date, delta = _date_and_delta(value, precise=True) if date is None: return str(value) - suppress_set = {Unit[s.upper()] for s in suppress} + suppress_set = {_minimum_unit_or_raise(s) for s in suppress} # Find a suitable minimum unit (it can be greater than the one that the # user gave us, if that one is suppressed). - min_unit = Unit[minimum_unit.upper()] + min_unit = _minimum_unit_or_raise(minimum_unit) min_unit = _suitable_minimum_unit(min_unit, suppress_set) del minimum_unit diff --git a/tests/test_time.py b/tests/test_time.py index 7699770..ed279c8 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -828,6 +828,36 @@ def test_precisedelta_bogus_call() -> None: humanize.naturaldelta(1, minimum_unit="years") +@pytest.mark.parametrize( + "func", + [humanize.precisedelta, humanize.naturaldelta, humanize.naturaltime], +) +@pytest.mark.parametrize("minimum_unit", ["weeks", "fortnights", "nanoseconds"]) +def test_minimum_unit_unknown_raises_valueerror( + func: typing.Callable[..., str], minimum_unit: str +) -> None: + # Units that are not part of ``humanize.time.Unit`` used to raise a raw + # ``KeyError``. They must raise a clear ``ValueError`` instead, consistent + # with the message used for enum members that are not allowed as a minimum + # unit (e.g. ``years`` for ``naturaldelta``). + with pytest.raises( + ValueError, match=rf"^Minimum unit '{minimum_unit}' not supported$" + ): + func(dt.timedelta(seconds=1), minimum_unit=minimum_unit) + + +@pytest.mark.parametrize("suppress_unit", ["weeks", "fortnights"]) +def test_suppress_unknown_unit_raises_valueerror(suppress_unit: str) -> None: + # An unknown unit passed to ``suppress`` must also raise a clear + # ``ValueError`` rather than an opaque ``KeyError``. + with pytest.raises( + ValueError, match=rf"^Minimum unit '{suppress_unit}' not supported$" + ): + humanize.precisedelta( + dt.timedelta(seconds=1), minimum_unit="seconds", suppress=[suppress_unit] + ) + + def test_time_unit() -> None: years, minutes = time.Unit["YEARS"], time.Unit["MINUTES"] assert minutes < years