From bff81bfc7dafa188ee908c60a8bca4fdb4ea0b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Mon, 31 Aug 2026 20:03:54 +0100 Subject: [PATCH] Reject negative numbers in dehumanize instead of silently dropping the sign The number pattern used to extract digits from a matched time unit is unsigned (\d+), so a literal minus sign right before a number, as in "in -1 hours" or "-3 minutes ago", is simply invisible to it. The direction of the shift already comes from matching the whole string against the locale's "ago"/"in" templates, so the minus sign gets silently thrown away and the result ends up in the opposite direction from what was written, with no error at all. Since direction is already conveyed by that phrasing, a signed number on top of it is contradictory input rather than something with a sensible meaning to fall back to, so this now raises instead. --- arrow/arrow.py | 13 +++++++++++++ tests/test_arrow.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..a98f121e2 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1428,6 +1428,19 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": 1 if not time_delta.isnumeric() else abs(int(time_delta)) ) else: + # num_pattern only matches unsigned digits, so a literal minus + # sign right before the number (e.g. "-1 hours") is invisible + # to it and would otherwise be silently dropped. Direction is + # already conveyed by the "ago"/"in" phrasing, so treat a sign + # here as invalid input rather than letting it disappear. + number_start = match.start() + num_match.start() + if number_start > 0 and input_string[number_start - 1] == "-": + raise ValueError( + "Invalid input String. Negative numbers are not " + "supported by dehumanize(), as direction is already " + "conveyed by phrases such as 'ago' or 'in'. Found a " + f"negative value near: {match_string!r}" + ) change_value = int(num_match.group()) # No time to update if now is the unit diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e21..7bef556fb 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2960,6 +2960,25 @@ def test_slavic_locales(self, slavic_locales: List[str]): assert arw.dehumanize(past_string, locale=lang) == past assert arw.dehumanize(future_string, locale=lang) == future + def test_negative_numbers_rejected(self): + arw = arrow.Arrow(2000, 6, 18, 5, 55, 0) + + # A literal minus sign is invisible to the unsigned number regex, so + # without a check these used to silently return the same result as + # their unsigned counterpart instead of raising. + with pytest.raises(ValueError): + arw.dehumanize("in -1 hours") + + with pytest.raises(ValueError): + arw.dehumanize("-3 minutes ago") + + with pytest.raises(ValueError): + arw.dehumanize("in -2 days") + + # unsigned input must still work exactly as before + assert arw.dehumanize("in 1 hours") == arw.shift(hours=1) + assert arw.dehumanize("3 minutes ago") == arw.shift(minutes=-3) + def test_czech_slovak(self): # Relevant units for Slavic locale plural logic units = [