diff --git a/arrow/arrow.py b/arrow/arrow.py index fef66c10..0b0db27b 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1125,6 +1125,7 @@ def humanize( locale: str = DEFAULT_LOCALE, only_distance: bool = False, granularity: Union[_GRANULARITY, List[_GRANULARITY]] = "auto", + dynamic: bool = False, ) -> str: """Returns a localized, humanized representation of a relative difference in time. @@ -1276,7 +1277,11 @@ def gather_timeframes(_delta: float, _frame: TimeFrameLiteral) -> float: if _frame in granularity: value = sign * _delta / self._SECS_MAP[_frame] _delta %= self._SECS_MAP[_frame] - if trunc(abs(value)) != 1: + + # If user chooses dynamic and the display value is 0 don't subtract + if dynamic and trunc(abs(value)) == 0: + pass + elif trunc(abs(value)) != 1: timeframes.append( (cast(TimeFrameLiteral, _frame + "s"), value) ) @@ -1285,6 +1290,7 @@ def gather_timeframes(_delta: float, _frame: TimeFrameLiteral) -> float: return _delta delta = float(delta_second) + frames: Tuple[TimeFrameLiteral, ...] = ( "year", "quarter", @@ -1298,12 +1304,25 @@ def gather_timeframes(_delta: float, _frame: TimeFrameLiteral) -> float: for frame in frames: delta = gather_timeframes(delta, frame) - if len(timeframes) < len(granularity): + if len(timeframes) < len(granularity) and not dynamic: raise ValueError( "Invalid level of granularity. " "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter' or 'year'." ) + # Needed to see if there are no units output an error + if not timeframes and dynamic: + raise ValueError( + "All provided granularity values produced an output of zero. " + "Consider using smaller granularities, or set the dynamic flag to False. " + ) + + # Needed for the case of dynamic usage (could end up with only one frame unit) + if len(timeframes) == 1: + return locale.describe( + timeframes[0][0], delta, only_distance=only_distance + ) + return locale.describe_multi(timeframes, only_distance=only_distance) except KeyError as e: diff --git a/arrow/locales.py b/arrow/locales.py index 1a7a635f..618ed4d6 100644 --- a/arrow/locales.py +++ b/arrow/locales.py @@ -172,7 +172,17 @@ def describe_multi( humanized = " ".join(parts) if not only_distance: - humanized = self._format_relative(humanized, *timeframes[-1]) + + # Needed to determine the correct relative string to use + timeframe_value = 0 + + for _unit_name, unit_value in timeframes: + if trunc(unit_value) != 0: + timeframe_value = trunc(unit_value) + break + + # Note it doesn't matter the timeframe unit we use on the call, only the value + humanized = self._format_relative(humanized, "seconds", timeframe_value) return humanized @@ -3460,8 +3470,15 @@ def describe_multi( """ humanized = "" + relative_delta = 0 + for index, (timeframe, delta) in enumerate(timeframes): last_humanized = self._format_timeframe(timeframe, trunc(delta)) + + # A check for the relative timeframe unit + if trunc(delta) != 0: + relative_delta = trunc(delta) + if index == 0: humanized = last_humanized elif index == len(timeframes) - 1: # Must have at least 2 items @@ -3473,7 +3490,7 @@ def describe_multi( humanized += ", " + last_humanized if not only_distance: - humanized = self._format_relative(humanized, timeframe, trunc(delta)) + humanized = self._format_relative(humanized, timeframe, relative_delta) return humanized diff --git a/tests/test_arrow.py b/tests/test_arrow.py index 02b42a5a..8d9d986a 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -2312,6 +2312,41 @@ def test_no_floats_multi_gran(self): ) assert humanize_string == "916 минути 40 няколко секунди назад" + # Dynamic Humanize Tests + def test_dynamic_on(self): + arw = arrow.Arrow(2013, 1, 1, 0, 0, 0) + later = arw.shift(seconds=3630) + humanize_string = arw.humanize( + later, granularity=["second", "hour", "day", "month", "year"], dynamic=True + ) + + assert humanize_string == "an hour and 30 seconds ago" + + def test_dynamic_on_one_granularity(self): + arw = arrow.Arrow(2013, 1, 1, 0, 0, 0) + later = arw.shift(seconds=3600) + humanize_string = arw.humanize( + later, granularity=["hour", "second"], dynamic=True + ) + + assert humanize_string == "in an hour" + + def test_dynamic_on_zero_output(self): + arw = arrow.Arrow(2013, 1, 1, 0, 0, 0) + later = arw.shift(seconds=0) + + with pytest.raises(ValueError): + arw.humanize(later, granularity=["hour", "second"], dynamic=True) + + def test_dynamic_off(self): + arw = arrow.Arrow(2013, 1, 1, 0, 0, 0) + later = arw.shift(seconds=3600) + humanize_string = arw.humanize( + later, + granularity=["second", "hour", "day", "month", "year"], + ) + assert humanize_string == "0 years 0 months 0 days an hour and 0 seconds ago" + @pytest.mark.usefixtures("time_2013_01_01") class TestArrowHumanizeTestsWithLocale: diff --git a/tests/test_locales.py b/tests/test_locales.py index 133b76e3..11bb4cc2 100644 --- a/tests/test_locales.py +++ b/tests/test_locales.py @@ -850,6 +850,9 @@ def test_describe_multi(self): assert describe(seconds60) == "בעוד דקה" assert describe(seconds60, only_distance=True) == "דקה" + fulltestend0 = [("years", 5), ("weeks", 1), ("hours", 1), ("minutes", 0)] + assert describe(fulltestend0) == "בעוד 5 שנים, שבוע, שעה ו־0 דקות" + @pytest.mark.usefixtures("lang_locale") class TestAzerbaijaniLocale: