From 48ef7d56156fe64283ccf9fa0e129c682347d51a Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:00:21 +0530 Subject: [PATCH 1/2] Support decimal values in dehumanize `dehumanize("2 days 3.5 hours ago")` shifted by 2 days and 5 hours: the number pattern was `\d+`, so it matched the digits after the separator and ignored the ones before it. Match an optional decimal fraction and keep the value as a float when one is present. Integers still parse as int, so nothing changes for the strings humanize() produces. Both `.` and `,` are accepted as the separator: dehumanize input is written by hand, and the locale objects carry no separator information. A separator only counts when digits follow it, so this cannot fire on a locale's punctuation. Fractional months and years still raise, from relativedelta, which cannot represent them unambiguously. Fixes #1237 --- arrow/arrow.py | 23 ++++++++++++++++++----- tests/test_arrow.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..971c256fe 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -74,6 +74,9 @@ "year", ] +# An unsigned integer or decimal, as accepted by dehumanize() +_NUMBER_PATTERN = r"\d+(?:[.,]\d+)?" + class Arrow: """An :class:`Arrow ` object. @@ -1391,8 +1394,13 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": False, ) - # Create a regex pattern object for numbers - num_pattern = re.compile(r"\d+") + # Create a regex pattern object for numbers. + # A value may carry a decimal fraction ("3.5 hours"). Both separators + # are accepted because the input is written by hand rather than + # produced by humanize(), and the locale objects carry no separator + # information. A separator is only read as a decimal point when digits + # follow it, so digit grouping ("1,500 hours") is not supported. + num_pattern = re.compile(_NUMBER_PATTERN) # Search input string for each time unit within locale for unit, unit_object in locale_obj.timeframes.items(): @@ -1406,9 +1414,9 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": # Needs to cycle all through strings as some locales have strings that # could overlap in a regex match, since input validation isn't being performed. for time_delta, time_string in strings_to_search.items(): - # Replace {0} with regex \d representing digits + # Replace {0} with the regex matching a numeric value search_string = str(time_string) - search_string = search_string.format(r"\d+") + search_string = search_string.format(_NUMBER_PATTERN) # Create search pattern and find within string pattern = re.compile(rf"(^|\b|\d){search_string}") @@ -1428,7 +1436,12 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": 1 if not time_delta.isnumeric() else abs(int(time_delta)) ) else: - change_value = int(num_match.group()) + matched_number = num_match.group().replace(",", ".") + change_value = ( + float(matched_number) + if "." in matched_number + else int(matched_number) + ) # No time to update if now is the unit if unit == "now": diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e21..e9894d10e 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2930,6 +2930,41 @@ def test_no_units_modified(self, locale_list_no_weeks: List[str]): with pytest.raises(ValueError): arw.dehumanize(empty_future_string, locale=lang) + def test_fractional_value(self): + arw = arrow.Arrow(2025, 12, 10, 9, 0, 0) + + assert arw.dehumanize("1.5 hours ago") == arrow.Arrow(2025, 12, 10, 7, 30, 0) + assert arw.dehumanize("in 0.5 hours") == arrow.Arrow(2025, 12, 10, 9, 30, 0) + assert arw.dehumanize("2.25 minutes ago") == arrow.Arrow( + 2025, 12, 10, 8, 57, 45 + ) + assert arw.dehumanize("in 1.5 days") == arrow.Arrow(2025, 12, 11, 21, 0, 0) + + def test_fractional_value_comma_separator(self): + arw = arrow.Arrow(2025, 12, 10, 9, 0, 0) + + assert arw.dehumanize("1,5 hours ago") == arw.dehumanize("1.5 hours ago") + + def test_fractional_value_with_multiple_units(self): + arw = arrow.Arrow(2025, 12, 10, 9, 0, 0) + + assert arw.dehumanize("2 days 3.5 hours ago") == arrow.Arrow( + 2025, 12, 8, 5, 30, 0 + ) + assert arw.dehumanize("in 2 days 3.5 hours") == arrow.Arrow( + 2025, 12, 12, 12, 30, 0 + ) + + def test_fractional_months_and_years_are_rejected(self): + arw = arrow.Arrow(2025, 12, 10, 9, 0, 0) + + # relativedelta cannot represent these unambiguously + with pytest.raises(ValueError): + arw.dehumanize("1.5 months ago") + + with pytest.raises(ValueError): + arw.dehumanize("in 1.5 years") + def test_slavic_locales(self, slavic_locales: List[str]): # Relevant units for Slavic locale plural logic units = [ From b30820a87a99303b5d054c6d579823633de2d858 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:55:26 +0530 Subject: [PATCH 2/2] Type change_value and time_object_info as int | float A decimal value makes change_value a float, but its type was inferred from the int-only branch above and time_object_info was built with dict.fromkeys(..., 0), so mypy rejected both assignments. Co-Authored-By: Claude Opus 5 --- arrow/arrow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index 971c256fe..2892eb1ec 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -17,6 +17,7 @@ from typing import ( Any, ClassVar, + Dict, Final, Generator, Iterable, @@ -1384,7 +1385,8 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": current_time = self.fromdatetime(self._datetime) # Create an object containing the relative time info - time_object_info = dict.fromkeys( + # float as well as int: a value may carry a decimal fraction + time_object_info: Dict[str, Union[int, float]] = dict.fromkeys( ["seconds", "minutes", "hours", "days", "weeks", "months", "years"], 0 ) @@ -1431,6 +1433,7 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": # If no number matches # Need for absolute value as some locales have signs included in their objects + change_value: Union[int, float] if not num_match: change_value = ( 1 if not time_delta.isnumeric() else abs(int(time_delta))