From 4a58ec4d7e079205094a5eee124c7f0eb2cecefe Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:47:57 +0800 Subject: [PATCH 1/2] fix(time): raise ValueError instead of KeyError for unsupported minimum_unit --- src/humanize/time.py | 38 +++++++++++++++++++++++++++++++++----- tests/test_time.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 4a07d528..981f17fd 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 76997704..2e1ce9aa 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -827,6 +827,36 @@ def test_precisedelta_bogus_call() -> None: with pytest.raises(ValueError, match="Minimum unit 'years' not supported"): 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"] From a5757b5f6dfba10675a56dd2a137f7f1ac39610b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:48:13 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_time.py b/tests/test_time.py index 2e1ce9aa..ed279c81 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -827,6 +827,7 @@ def test_precisedelta_bogus_call() -> None: with pytest.raises(ValueError, match="Minimum unit 'years' not supported"): humanize.naturaldelta(1, minimum_unit="years") + @pytest.mark.parametrize( "func", [humanize.precisedelta, humanize.naturaldelta, humanize.naturaltime], @@ -857,7 +858,6 @@ def test_suppress_unknown_unit_raises_valueerror(suppress_unit: str) -> None: ) - def test_time_unit() -> None: years, minutes = time.Unit["YEARS"], time.Unit["MINUTES"] assert minutes < years