diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf2326..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( @@ -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 @@ -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 @@ -1392,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(): @@ -1408,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}") @@ -1428,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/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_arrow.py b/tests/test_arrow.py index b595e4e2..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 ) ) @@ -2606,6 +2607,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) @@ -2832,6 +2839,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) 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