From b65e6f6f334b90f7ce0ab571cdc964890bf101d0 Mon Sep 17 00:00:00 2001 From: jermaine-hicks <297244852+jermaine-hicks@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:24:43 -0400 Subject: [PATCH] fix: handle negative values in dehumanize() dehumanize() silently dropped a leading minus sign on numeric values, so "in -1 hours" was parsed identically to "in 1 hours" and shifted forward instead of producing a correct or clearly-invalid result. The number is extracted with a digit-only regex (\d+) that never captures a sign, and direction is derived solely from the past/future templates ("... ago" / "in ..."). A negative magnitude is therefore malformed input rather than a second, conflicting direction signal -- humanize() never emits negative numbers. To match the existing dehumanize() convention of raising ValueError on malformed input (scrambled input, missing relative unit, etc.), detect a leading minus immediately before the matched digits and raise a clear ValueError instead of silently dropping it. Adds a regression test covering negative values in both the future ("in -1 hours") and past ("-3 hours ago") forms across several units. Fixes #1278 --- arrow/arrow.py | 20 ++++++++++++++++++++ tests/test_arrow.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..b3632bd3a 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1430,6 +1430,26 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": else: change_value = int(num_match.group()) + # Reject explicitly-negative values such as "in -1 hours". + # The digit-only num_pattern above never captures a sign, + # so the minus was previously dropped silently and + # "in -1 hours" produced the same (wrong) result as + # "in 1 hours". ``humanize`` always expresses direction + # through the past/future templates ("... ago" / "in ...") + # with a positive magnitude, so a negative number here is + # malformed input rather than a second, conflicting + # direction signal. Detect the sign by inspecting the + # character that immediately precedes the matched digits in + # the original input string. + num_start = match.start() + num_match.start() + if num_start > 0 and input_string[num_start - 1] == "-": + raise ValueError( + f"Invalid input string. Negative time value " + f"'-{num_match.group()}' is not supported; express " + "direction using the past ('... ago') or future " + "('in ...') form with a positive value instead." + ) + # No time to update if now is the unit if unit == "now": unit_visited[unit] = True diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e21..bb774eb3e 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2888,6 +2888,29 @@ def test_require_relative_unit(self, locale_list_no_weeks: List[str]): with pytest.raises(ValueError): arw.dehumanize(second_future_string, locale=lang) + def test_negative_values(self): + # Negative numeric values are malformed input: humanize only ever + # emits positive magnitudes and expresses direction through the + # past/future templates. They were previously silently dropped, so + # "in -1 hours" returned the same (wrong) result as "in 1 hours". + arw = arrow.Arrow(2000, 6, 18, 5, 55, 0) + + # Sanity check: the corresponding positive strings still work and the + # buggy result must not equal the positive result. + assert arw.dehumanize("in 1 hours") == arw.shift(hours=1) + assert arw.dehumanize("1 hours ago") == arw.shift(hours=-1) + + negative_strings = [ + "in -1 hours", + "in -5 days", + "-3 hours ago", + "in -2 years", + "-10 seconds ago", + ] + for negative_string in negative_strings: + with pytest.raises(ValueError): + arw.dehumanize(negative_string) + # Test for scrambled input def test_scrambled_input(self, locale_list_no_weeks: List[str]): for lang in locale_list_no_weeks: