diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..2892eb1ec 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -17,6 +17,7 @@ from typing import ( Any, ClassVar, + Dict, Final, Generator, Iterable, @@ -74,6 +75,9 @@ "year", ] +# An unsigned integer or decimal, as accepted by dehumanize() +_NUMBER_PATTERN = r"\d+(?:[.,]\d+)?" + class Arrow: """An :class:`Arrow ` object. @@ -1381,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 ) @@ -1391,8 +1396,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 +1416,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}") @@ -1423,12 +1433,18 @@ 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)) ) 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 = [