From 8b403f32d90bfde795c57ef7e33b08df173eb1f9 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Thu, 23 Jul 2026 13:28:50 +0000 Subject: [PATCH 1/6] validate dehumanize input before locale matching --- arrow/arrow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf2326..f526419c 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1380,6 +1380,14 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": current_time = self.fromdatetime(self._datetime) + # Reject non-string input before looking up locale-specific patterns. + # This keeps the public error stable even when a locale stores timeframes + # in a mapping that would otherwise be searched with regex operations. + if not isinstance(input_string, str): + raise TypeError( + f"input_string must be str, not {type(input_string).__name__}." + ) + # Create an object containing the relative time info time_object_info = dict.fromkeys( ["seconds", "minutes", "hours", "days", "weeks", "months", "years"], 0 From 64c7b3f7fbe7a5929068e169fe1eb6ee4a2223c2 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Tue, 28 Jul 2026 19:37:28 +0000 Subject: [PATCH 2/6] reject non-string values in dehumanize --- tests/test_arrow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e2..801299c9 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2606,6 +2606,12 @@ def slavic_locales() -> List[str]: class TestArrowDehumanize: + def test_non_string_input_raises_type_error(self): + arw = arrow.Arrow.utcnow() + + with pytest.raises(TypeError, match="input_string must be str"): + arw.dehumanize(None) + def test_now(self, locale_list_no_weeks: List[str]): for lang in locale_list_no_weeks: arw = arrow.Arrow(2000, 6, 18, 5, 55, 0) From c247f10508a70e0d5d0edb28fdce68baf2b0f529 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Thu, 30 Jul 2026 13:27:56 +0000 Subject: [PATCH 3/6] preserve format names for generator inputs --- arrow/parser.py | 1 + tests/test_parser.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/arrow/parser.py b/arrow/parser.py index fc3774b0..ec48fbc4 100644 --- a/arrow/parser.py +++ b/arrow/parser.py @@ -844,6 +844,7 @@ def _parse_multiformat(self, string: str, formats: Iterable[str]) -> datetime: :raises ParserError: If no format matches the input string. """ _datetime: Optional[datetime] = None + formats = list(formats) for fmt in formats: try: diff --git a/tests/test_parser.py b/tests/test_parser.py index 7038d880..23b4aeb6 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -61,6 +61,15 @@ def test_parse_multiformat_all_fail(self, mocker): with pytest.raises(parser.ParserError): self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"]) + def test_parse_multiformat_reports_generator_formats(self, mocker): + mocker.patch( + "arrow.parser.DateTimeParser.parse", + side_effect=parser.ParserMatchError, + ) + + with pytest.raises(parser.ParserError, match="fmt_a, fmt_b"): + self.parser._parse_multiformat("str", (fmt for fmt in ("fmt_a", "fmt_b"))) + def test_parse_multiformat_unself_expected_fail(self, mocker): class UnselfExpectedError(Exception): pass From 6a9f28547874d9d6404c8d878559269354e958bd Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Fri, 31 Jul 2026 01:31:04 +0000 Subject: [PATCH 4/6] preserve fractional quantities in dehumanized times --- arrow/arrow.py | 6 +++--- tests/test_arrow.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index f526419c..a94f1425 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1400,7 +1400,7 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": ) # Create a regex pattern object for numbers - num_pattern = re.compile(r"\d+") + num_pattern = re.compile(r"\d+(?:\.\d+)?") # Search input string for each time unit within locale for unit, unit_object in locale_obj.timeframes.items(): @@ -1416,7 +1416,7 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": for time_delta, time_string in strings_to_search.items(): # Replace {0} with regex \d representing digits search_string = str(time_string) - search_string = search_string.format(r"\d+") + search_string = search_string.format(r"\d+(?:\.\d+)?") # Create search pattern and find within string pattern = re.compile(rf"(^|\b|\d){search_string}") @@ -1436,7 +1436,7 @@ 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()) + change_value = float(num_match.group()) # 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 801299c9..8e1f6b66 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2838,6 +2838,12 @@ def test_mixed_granularity_day_hour(self, locale_list_no_weeks: List[str]): assert arw.dehumanize(past_string, locale=lang) == past assert arw.dehumanize(future_string, locale=lang) == future + def test_decimal_unit_quantity_preserves_fraction(self): + arw = arrow.Arrow(2000, 6, 18, 5, 55, 0) + + assert arw.dehumanize("in 1.5 hours") == arw.shift(hours=1.5) + assert arw.dehumanize("1.5 hours ago") == arw.shift(hours=-1.5) + # Test to make sure unsupported locales error out def test_unsupported_locale(self): arw = arrow.Arrow(2000, 6, 18, 5, 55, 0) From 3b50fbe9bb6c1786350834c5eb502b1bede909d5 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Fri, 31 Jul 2026 05:38:04 +0000 Subject: [PATCH 5/6] clarify locale setup in relative time parsing --- arrow/arrow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index a94f1425..3778134a 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1367,7 +1367,7 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": """ - # Create a locale object based off given local + # Create a locale object based off given localee locale_obj = locales.get_locale(locale) # Check to see if locale is supported From 09b71606bf19a0285601b8fe07901b0e1d7a3df9 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Fri, 31 Jul 2026 15:29:26 +0000 Subject: [PATCH 6/6] reject non-integer interval widths --- arrow/arrow.py | 2 +- tests/test_arrow.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index 3778134a..ada8efcd 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -773,7 +773,7 @@ def interval( (, ) (, ) """ - if interval < 1: + if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1: raise ValueError("interval has to be a positive integer") spanRange = iter( diff --git a/tests/test_arrow.py b/tests/test_arrow.py index 8e1f6b66..b52c4814 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -1557,11 +1557,12 @@ def test_small_interval_exact_open_bounds(self): class TestArrowInterval: - def test_incorrect_input(self): - with pytest.raises(ValueError): + @pytest.mark.parametrize("interval", [0, -1, 1.5, True]) + def test_incorrect_input(self, interval): + with pytest.raises(ValueError, match="positive integer"): list( arrow.Arrow.interval( - "month", datetime(2013, 1, 2), datetime(2013, 4, 15), 0 + "month", datetime(2013, 1, 2), datetime(2013, 4, 15), interval ) )