diff --git a/src/humanize/time.py b/src/humanize/time.py index 3559546..0ea316d 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -65,7 +65,9 @@ def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta: return delta -def _date_and_delta(value: Any, *, now: dt.datetime | None = None) -> tuple[Any, Any]: +def _date_and_delta( + value: Any, *, now: dt.datetime | None = None, precise: bool = False +) -> tuple[Any, Any]: """Turn a value into a date and a timedelta which represents how long ago it was. If that's not possible, return `(None, value)`. @@ -82,7 +84,7 @@ def _date_and_delta(value: Any, *, now: dt.datetime | None = None) -> tuple[Any, delta = value else: try: - value = int(value) + value = value if precise else int(value) delta = dt.timedelta(seconds=value) date = now - delta except (ValueError, TypeError): @@ -345,77 +347,43 @@ def _quotient_and_remainder( unit: Unit, minimum_unit: Unit, suppress: Iterable[Unit], + format: str, ) -> tuple[float, float]: - """Divide `value` by `divisor` returning the quotient and remainder. + """Divide `value` by `divisor`, returning the quotient and remainder. - If `unit` is `minimum_unit`, makes the quotient a float number and the remainder - will be zero. The rational is that if `unit` is the unit of the quotient, we cannot - represent the remainder because it would require a unit smaller than the - `minimum_unit`. + If `unit` is `minimum_unit`, the quotient will be the rounding of `value / divisor` + according to the `format` string and the remainder will be zero. The rationale is + that if `unit` is the unit of the quotient, we cannot represent the remainder + because it would require a unit smaller than the `minimum_unit`. >>> from humanize.time import _quotient_and_remainder, Unit - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, []) + >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, [], "%0.2f") (1.5, 0) - If unit is in `suppress`, the quotient will be zero and the remainder will be the + If `unit` is in `suppress`, the quotient will be zero and the remainder will be the initial value. The idea is that if we cannot use `unit`, we are forced to use a - lower unit so we cannot do the division. + lower unit, so we cannot do the division. - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS]) + >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS], "%0.2f") (0, 36) - In other case return quotient and remainder as `divmod` would do it. + In other cases, return the quotient and remainder as `divmod` would do it. - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, []) + >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [], "%0.2f") (1, 12) """ if unit == minimum_unit: - return value / divisor, 0 + return _rounding_by_fmt(format, value / divisor), 0 if unit in suppress: return 0, value - return divmod(value, divisor) - - -def _carry( - value1: float, - value2: float, - ratio: float, - unit: Unit, - min_unit: Unit, - suppress: Iterable[Unit], -) -> tuple[float, float]: - """Return a tuple with two values. - - If the unit is in `suppress`, multiply `value1` by `ratio` and add it to `value2` - (carry to right). The idea is that if we cannot represent `value1` we need to - represent it in a lower unit. - - >>> from humanize.time import _carry, Unit - >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, [Unit.DAYS]) - (0, 54) - - If the unit is the minimum unit, `value2` is divided by `ratio` and added to - `value1` (carry to left). We assume that `value2` has a lower unit so we need to - carry it to `value1`. - - >>> _carry(2, 6, 24, Unit.DAYS, Unit.DAYS, []) - (2.25, 0) - - Otherwise, just return the same input: - - >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, []) - (2, 6) - """ - if unit == min_unit: - return value1 + value2 / ratio, 0 - - if unit in suppress: - return 0, value2 + value1 * ratio - - return value1, value2 + # Convert the remainder back to integer is necessary for months. 1 month is 30.5 + # days on average, but if we have 31 days, we want to count is as a whole month, + # and not as 1 month plus a remainder of 0.5 days. + q, r = divmod(value, divisor) + return q, int(r) def _suitable_minimum_unit(min_unit: Unit, suppress: Iterable[Unit]) -> Unit: @@ -464,12 +432,12 @@ def _suppress_lower_units(min_unit: Unit, suppress: Iterable[Unit]) -> set[Unit] def precisedelta( - value: dt.timedelta | int | None, + value: dt.timedelta | float | None, minimum_unit: str = "seconds", suppress: Iterable[str] = (), format: str = "%0.2f", ) -> str: - """Return a precise representation of a timedelta. + """Return a precise representation of a timedelta or number of seconds. ```pycon >>> import datetime as dt @@ -535,14 +503,14 @@ def precisedelta( ``` """ - date, delta = _date_and_delta(value) + date, delta = _date_and_delta(value, precise=True) if date is None: return str(value) suppress_set = {Unit[s.upper()] for s in suppress} - # Find a suitable minimum unit (it can be greater the one that the - # user gave us if it is suppressed). + # Find a suitable minimum unit (it can be greater than the one that the + # user gave us, if that one is suppressed). min_unit = Unit[minimum_unit.upper()] min_unit = _suitable_minimum_unit(min_unit, suppress_set) del minimum_unit @@ -572,27 +540,57 @@ def precisedelta( # years, days = divmod(years, days) # # The same applies for months, hours, minutes and milliseconds below - years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress_set) - months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress_set) + years, days = _quotient_and_remainder( + days, 365, YEARS, min_unit, suppress_set, format + ) + months, days = _quotient_and_remainder( + days, 30.5, MONTHS, min_unit, suppress_set, format + ) - # If DAYS is not in suppress, we can represent the days but - # if it is a suppressed unit, we need to carry it to a lower unit, - # seconds in this case. - # - # The same applies for secs and usecs below - days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress_set) + secs = days * 24 * 3600 + secs + days, secs = _quotient_and_remainder( + secs, 24 * 3600, DAYS, min_unit, suppress_set, format + ) - hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress_set) - minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress_set) + hours, secs = _quotient_and_remainder( + secs, 3600, HOURS, min_unit, suppress_set, format + ) + minutes, secs = _quotient_and_remainder( + secs, 60, MINUTES, min_unit, suppress_set, format + ) - secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress_set) + usecs = secs * 1e6 + usecs + secs, usecs = _quotient_and_remainder( + usecs, 1e6, SECONDS, min_unit, suppress_set, format + ) msecs, usecs = _quotient_and_remainder( - usecs, 1000, MILLISECONDS, min_unit, suppress_set + usecs, 1000, MILLISECONDS, min_unit, suppress_set, format ) - # if _unused != 0 we had lost some precision - usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress_set) + # Due to rounding, it could be that a unit is high enough to be promoted to a higher + # unit. Example: 59.9 minutes was rounded to 60 minutes, and thus it should become 0 + # minutes and one hour more. + if msecs >= 1_000 and SECONDS not in suppress_set: + msecs -= 1_000 + secs += 1 + if secs >= 60 and MINUTES not in suppress_set: + secs -= 60 + minutes += 1 + if minutes >= 60 and HOURS not in suppress_set: + minutes -= 60 + hours += 1 + if hours >= 24 and DAYS not in suppress_set: + hours -= 24 + days += 1 + # When adjusting we should not deal anymore with fractional days as all rounding has + # been already made. We promote 31 days to an extra month. + if days >= 31 and MONTHS not in suppress_set: + days -= 31 + months += 1 + if months >= 12 and YEARS not in suppress_set: + months -= 12 + years += 1 fmts = [ ("%d year", "%d years", years), @@ -616,6 +614,8 @@ def precisedelta( if unit == min_unit and math.modf(fmt_value)[0] > 0: fmt_txt = fmt_txt.replace("%d", format) elif unit == YEARS: + if math.modf(fmt_value)[0] == 0: + fmt_value = int(fmt_value) fmt_txt = fmt_txt.replace("%d", "%s") texts.append(fmt_txt % intcomma(fmt_value)) continue @@ -632,3 +632,24 @@ def precisedelta( tail = texts[-1] return _("%s and %s") % (head, tail) + + +def _rounding_by_fmt(format: str, value: float) -> float | int: + """Round a number according to the string format provided. + + The string format is the old printf-style string formatting. + + If we are using a format which truncates the value, such as "%d" or "%i", the + returned value will be of type `int`. + + If we are using a format which rounds the value, such as "%.2f" or even "%.0f", + we will return a float. + """ + result = format % value + + try: + value = int(result) + except ValueError: + value = float(result) + + return value diff --git a/tests/test_time.py b/tests/test_time.py index a490d1c..70ea10c 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -170,7 +170,9 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None: ("NaN", "NaN"), ], ) -def test_naturaltime(test_input: dt.datetime, expected: str) -> None: +def test_naturaltime( + test_input: dt.datetime | dt.timedelta | float, expected: str +) -> None: assert humanize.naturaltime(test_input) == expected @@ -211,7 +213,9 @@ def test_naturaltime(test_input: dt.datetime, expected: str) -> None: ("NaN", "NaN"), ], ) -def test_naturaltime_nomonths(test_input: dt.datetime, expected: str) -> None: +def test_naturaltime_nomonths( + test_input: dt.datetime | dt.timedelta | float, expected: str +) -> None: assert humanize.naturaltime(test_input, months=False) == expected @@ -506,7 +510,7 @@ def test_naturaltime_timezone_when(test_input: dt.datetime, expected: str) -> No ], ) def test_precisedelta_one_unit_enough( - val: int | dt.timedelta, min_unit: str, expected: str + val: dt.timedelta | float, min_unit: str, expected: str ) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -559,10 +563,18 @@ def test_precisedelta_one_unit_enough( "minutes", "0 minutes", ), + (dt.timedelta(days=31), "seconds", "1 month"), + (dt.timedelta(days=32), "seconds", "1 month and 1 day"), + (dt.timedelta(days=62), "seconds", "2 months and 1 day"), + (dt.timedelta(days=92), "seconds", "3 months"), + (dt.timedelta(days=31), "days", "1 month"), + (dt.timedelta(days=32), "days", "1 month and 1 day"), + (dt.timedelta(days=62), "days", "2 months and 1 day"), + (dt.timedelta(days=92), "days", "3 months"), ], ) def test_precisedelta_multiple_units( - val: dt.timedelta, min_unit: str, expected: str + val: dt.timedelta | float, min_unit: str, expected: str ) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -582,7 +594,7 @@ def test_precisedelta_multiple_units( "%0.4f", "2.0020 milliseconds", ), - (dt.timedelta(microseconds=2002), "milliseconds", "%0.2f", "2.00 milliseconds"), + (dt.timedelta(microseconds=2002), "milliseconds", "%0.2f", "2 milliseconds"), ( dt.timedelta(seconds=1, microseconds=230000), "seconds", @@ -608,12 +620,63 @@ def test_precisedelta_multiple_units( "5 days and 4.50 hours", ), (dt.timedelta(days=5, hours=4, seconds=30 * 60), "days", "%0.2f", "5.19 days"), + # 1 month is 30.5 days but remainder is always rounded down. + (dt.timedelta(days=31), "days", "%d", "1 month"), + (dt.timedelta(days=31), "days", "%.0f", "1 month"), + (dt.timedelta(days=32), "days", "%d", "1 month and 1 day"), + (dt.timedelta(days=32), "days", "%.0f", "1 month and 1 day"), + (dt.timedelta(days=62), "days", "%d", "2 months and 1 day"), + (dt.timedelta(days=92), "days", "%d", "3 months"), (dt.timedelta(days=120), "months", "%0.2f", "3.93 months"), (dt.timedelta(days=183), "years", "%0.1f", "0.5 years"), + (0.01, "seconds", "%0.3f", "0.010 seconds"), + # 31 seconds will be truncated to 0 with %d and rounded to the nearest + # number with %.0f, ie. 1 + (31, "minutes", "%d", "0 minutes"), + (31, "minutes", "%0.0f", "1 minute"), + (60 + 29.99, "minutes", "%d", "1 minute"), + (60 + 29.99, "minutes", "%.0f", "1 minute"), + (60 + 30, "minutes", "%d", "1 minute"), + # 30 sec is 0.5 minutes. Round to nearest, ties away from zero. + # See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules + (60 + 30, "minutes", "%.0f", "2 minutes"), + (60 * 60 + 30.99, "minutes", "%.0f", "1 hour"), + (60 * 60 + 31, "minutes", "%.0f", "1 hour and 1 minute"), + ( + ONE_DAY - MILLISECONDS_1_337, + "seconds", + "%.1f", + "23 hours, 59 minutes and 58.7 seconds", + ), + ( + ONE_DAY - ONE_MILLISECOND, + "seconds", + "%.4f", + "23 hours, 59 minutes and 59.9990 seconds", + ), + (91500, "hours", "%0.0f", "1 day and 1 hour"), + # Because we use a format to round, we will end up with 9 hours. + (9 * 60 * 60 - 1, "minutes", "%0.0f", "9 hours"), + (dt.timedelta(days=30.99999), "minutes", "%0.0f", "1 month"), + # We round at the hour. We end up with 12.5 hours. It's a tie, so round to the + # nearest even number which is 12, thus we round down. + ( + dt.timedelta(days=30.5 * 3, minutes=30), + "hours", + "%0.0f", + "2 months, 30 days and 12 hours", + ), + (dt.timedelta(days=10, hours=6), "days", "%0.2f", "10.25 days"), + (dt.timedelta(days=30.55), "days", "%0.1f", "30.6 days"), + (dt.timedelta(microseconds=999.5), "microseconds", "%0.0f", "1 millisecond"), + (dt.timedelta(milliseconds=999.5), "milliseconds", "%0.0f", "1 second"), + (dt.timedelta(seconds=59.5), "seconds", "%0.0f", "1 minute"), + (dt.timedelta(minutes=59.5), "minutes", "%0.0f", "1 hour"), + (dt.timedelta(days=364), "months", "%0.0f", "1 year"), ], ) def test_precisedelta_custom_format( - val: dt.timedelta, min_unit: str, fmt: str, expected: str + val: dt.timedelta | float, min_unit: str, fmt: str, expected: str ) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit, format=fmt) == expected @@ -690,7 +753,7 @@ def test_precisedelta_custom_format( ], ) def test_precisedelta_suppress_units( - val: dt.timedelta, min_unit: str, suppress: list[str], expected: str + val: dt.timedelta | float, min_unit: str, suppress: list[str], expected: str ) -> None: assert ( humanize.precisedelta(val, minimum_unit=min_unit, suppress=suppress) == expected @@ -714,3 +777,20 @@ def test_time_unit() -> None: with pytest.raises(TypeError): _ = years < "foo" + + +@pytest.mark.parametrize( + "fmt, value, expected", + [ + ("%.2f", 1.011, 1.01), + ("%.0f", 1.01, 1.0), + ("%.0f", 1.5, 2.0), + ("%10.0f", 1.01, 1.0), + ("%i", 1.01, 1), + # Surprising rounding with %d. It does not truncate for all values... + ("%d", 1.999999999999999, 1), + ("%d", 1.9999999999999999, 2), + ], +) +def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None: + assert time._rounding_by_fmt(fmt, value) == pytest.approx(expected)