From 3a5ff46b784e484fd41c68ce078253ee1bfa59dc Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Mon, 11 Jul 2022 10:47:32 -0700 Subject: [PATCH 01/10] Add tests defining the desired behavior for precisedelta. --- tests/test_time.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_time.py b/tests/test_time.py index a490d1c..0b91399 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -608,8 +608,29 @@ 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"), + (dt.timedelta(days=31), "days", "%d", "1 month"), + (dt.timedelta(days=31.01), "days", "%d", "1 month 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, "minutes", "%d", "1 minute"), + (60 + 29.99, "minutes", "%d", "1 minute"), + (60 + 30, "minutes", "%d", "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", + ), ], ) def test_precisedelta_custom_format( From e9df3f7e28bd82a00b4500c233480bccdcbf3c5a Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Mon, 11 Jul 2022 10:49:30 -0700 Subject: [PATCH 02/10] Support `float` in precisedelta and fix bugs to make new tests pass. --- src/humanize/time.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 3559546..b67524d 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): @@ -464,7 +466,7 @@ 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", @@ -535,7 +537,7 @@ def precisedelta( ``` """ - date, delta = _date_and_delta(value) + date, delta = _date_and_delta(value, precise=True) if date is None: return str(value) @@ -605,9 +607,14 @@ def precisedelta( ("%d microsecond", "%d microseconds", usecs), ] + import re + round_fmt_value = re.fullmatch(r"%\d*(d|(\.0*f))", format) + texts: list[str] = [] for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, fmt_value = fmt + if round_fmt_value: + fmt_value = round(fmt_value) if fmt_value > 0 or (not texts and unit == min_unit): _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value) fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value) From 1e9783465dcc8f280501d3319e0fdf0d153ea0f2 Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Mon, 11 Jul 2022 10:50:19 -0700 Subject: [PATCH 03/10] Improve grammar and consistency in some internal docstrings/comments. --- src/humanize/time.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index b67524d..bb7726b 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -348,10 +348,10 @@ def _quotient_and_remainder( minimum_unit: Unit, suppress: Iterable[Unit], ) -> 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 + If `unit` is `minimum_unit`, the quotient will be a float number 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`. @@ -359,14 +359,14 @@ def _quotient_and_remainder( >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, []) (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]) (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, []) (1, 12) @@ -391,16 +391,16 @@ def _carry( ) -> 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 + If `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 + If `unit` is the minimum unit, divide `value2` by `ratio` and add it 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, []) @@ -471,7 +471,7 @@ def precisedelta( 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 @@ -543,8 +543,8 @@ def precisedelta( 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 @@ -593,7 +593,7 @@ def precisedelta( usecs, 1000, MILLISECONDS, min_unit, suppress_set ) - # if _unused != 0 we had lost some precision + # if _unused != 0 we have lost some precision usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress_set) fmts = [ From 29b26a478751e2e6e00580a827c79b7b71d19228 Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Mon, 11 Jul 2022 10:50:58 -0700 Subject: [PATCH 04/10] Clean up some type annotations in `time` tests. --- tests/test_time.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_time.py b/tests/test_time.py index 0b91399..d417111 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 @@ -562,7 +566,7 @@ def test_precisedelta_one_unit_enough( ], ) 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 @@ -634,7 +638,7 @@ def test_precisedelta_multiple_units( ], ) 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 @@ -711,7 +715,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 From 9cd65dc834d7fe9beb65eeb923570cde5831dd22 Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Mon, 11 Jul 2022 11:14:32 -0700 Subject: [PATCH 05/10] Add more tests to catch the "odd months" bug described in #14. --- tests/test_time.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_time.py b/tests/test_time.py index d417111..9fbeee1 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -563,6 +563,14 @@ def test_precisedelta_one_unit_enough( "minutes", "0 minutes", ), + (dt.timedelta(days=31), "seconds", "1 month and 12 hours"), + (dt.timedelta(days=32), "seconds", "1 month, 1 day and 12 hours"), + (dt.timedelta(days=62), "seconds", "2 months and 1 day"), + (dt.timedelta(days=92), "seconds", "3 months and 12 hours"), + (dt.timedelta(days=31), "days", "1 month and 0.50 days"), + (dt.timedelta(days=32), "days", "1 month and 1.50 days"), + (dt.timedelta(days=62), "days", "2 months and 1 day"), + (dt.timedelta(days=92), "days", "3 months and 0.50 days"), ], ) def test_precisedelta_multiple_units( @@ -614,6 +622,9 @@ def test_precisedelta_multiple_units( (dt.timedelta(days=5, hours=4, seconds=30 * 60), "days", "%0.2f", "5.19 days"), (dt.timedelta(days=31), "days", "%d", "1 month"), (dt.timedelta(days=31.01), "days", "%d", "1 month and 1 day"), + (dt.timedelta(days=31.99), "days", "%d", "1 month and 1 day"), + (dt.timedelta(days=32), "days", "%d", "1 month and 2 days"), + (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"), From f220b672b56f3ee39f04550bbe9e99adba6d17a7 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sun, 15 May 2022 22:35:19 -0300 Subject: [PATCH 06/10] Propagate days to seconds and from there compute hours and minutes --- src/humanize/time.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index bb7726b..ab559b5 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -577,12 +577,8 @@ def precisedelta( 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) - # 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) hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress_set) minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress_set) From 191f09310cddb24bbd1dfc319d58641ccf6c7a71 Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Mon, 26 May 2025 17:21:39 +0200 Subject: [PATCH 07/10] Use rounding according to the string format in precisedelta %d and %0.0f do not produce the same values. So we will first apply the required formatting and turn the formatted string back into a float or int. YEARS needs to be treated slightly differently as it needs to be formatted with `intcomma`. We first check if the resulting value does not have any fractional part and if not, we turn it into an int, so that it string output is what a human would expect. Added several unittests to highlight some of the differences between using %d and %.0f as a format in precisedelta. --- src/humanize/time.py | 30 +++++++++++++++++++++++++----- tests/test_time.py | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index ab559b5..449b04e 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -603,14 +603,11 @@ def precisedelta( ("%d microsecond", "%d microseconds", usecs), ] - import re - round_fmt_value = re.fullmatch(r"%\d*(d|(\.0*f))", format) - texts: list[str] = [] for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, fmt_value = fmt - if round_fmt_value: - fmt_value = round(fmt_value) + + fmt_value = _rounding_by_fmt(format, fmt_value) if fmt_value > 0 or (not texts and unit == min_unit): _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value) fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value) @@ -619,6 +616,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 @@ -635,3 +634,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 9fbeee1..2925875 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -594,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", @@ -620,18 +620,34 @@ 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. Remaining 0.5 days is rounded down for both formats (dt.timedelta(days=31), "days", "%d", "1 month"), - (dt.timedelta(days=31.01), "days", "%d", "1 month and 1 day"), + (dt.timedelta(days=31), "days", "%.0f", "1 month"), + # But adding a tiny amount will reveal a difference between %d and %.0f + # %d will truncate while %.0f will round to the nearest number. + (dt.timedelta(days=31.01), "days", "%d", "1 month"), + (dt.timedelta(days=31.01), "days", "%.0f", "1 month and 1 day"), (dt.timedelta(days=31.99), "days", "%d", "1 month and 1 day"), - (dt.timedelta(days=32), "days", "%d", "1 month and 2 days"), + # 1 month is 30.5 days. Remaining 1.5 days is truncated for %d. + # For format %.0f, there is a tie, so it's rounded to the nearest even number, + # which is 2. See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules + (dt.timedelta(days=32), "days", "%d", "1 month and 1 day"), + (dt.timedelta(days=32), "days", "%.0f", "1 month and 2 days"), (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, "minutes", "%d", "1 minute"), + # 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 + 30, "minutes", "%d", "2 minutes"), + (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"), ( @@ -750,3 +766,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) From 4d04c0d37fdc54770eb65f87fbb07d58412f2f96 Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Mon, 26 May 2025 18:14:04 +0200 Subject: [PATCH 08/10] Small performance improvement to precisedetla We only try to round the value based on the format provided if we are dealing with the minimum_unit. --- src/humanize/time.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 449b04e..049db7b 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -607,7 +607,9 @@ def precisedelta( for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, fmt_value = fmt - fmt_value = _rounding_by_fmt(format, fmt_value) + if unit == min_unit: + fmt_value = _rounding_by_fmt(format, fmt_value) + if fmt_value > 0 or (not texts and unit == min_unit): _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value) fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value) From efa5e600878302a96a42f5618278a4c87ed1cfba Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Mon, 26 May 2025 22:53:29 +0200 Subject: [PATCH 09/10] Move rounding logic to _quotient_and_remainder The logic about rounding due to formatting has been moved to _quotient_and_remainder because this is where we have all the logic regarding the minimum unit and suppress units. Also removed _carry function. Instead use the same logic than was used for calculating the `secs` based on the remaining amount of days. It is done now also for `usecs` based on the `secs` remaining. Add another block of logic to check after rounding if any units should be promoted to a higher unit, in case of a rounding up. --- src/humanize/time.py | 112 ++++++++++++++++++++----------------------- tests/test_time.py | 19 ++++++++ 2 files changed, 71 insertions(+), 60 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 049db7b..43fcc1c 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -347,33 +347,34 @@ 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. - If `unit` is `minimum_unit`, the quotient will be a float number 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`. + 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 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. - >>> _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 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 @@ -381,45 +382,6 @@ def _quotient_and_remainder( 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 `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 `unit` is the minimum unit, divide `value2` by `ratio` and add it 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 - - def _suitable_minimum_unit(min_unit: Unit, suppress: Iterable[Unit]) -> Unit: """Return a minimum unit suitable that is not suppressed. @@ -574,23 +536,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 + ) secs = days * 24 * 3600 + secs - days, secs = _quotient_and_remainder(secs, 24 * 3600, DAYS, min_unit, suppress_set) + 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 have 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), @@ -606,10 +602,6 @@ def precisedelta( texts: list[str] = [] for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, fmt_value = fmt - - if unit == min_unit: - fmt_value = _rounding_by_fmt(format, fmt_value) - if fmt_value > 0 or (not texts and unit == min_unit): _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value) fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value) diff --git a/tests/test_time.py b/tests/test_time.py index 2925875..74d8560 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -662,6 +662,25 @@ def test_precisedelta_multiple_units( "%.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( From 1687b7d54c7c24f8c104ed8945e142c0fbea4976 Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Sat, 5 Jul 2025 18:04:23 +0200 Subject: [PATCH 10/10] Do not keep fractional remainders for months in precisedelta Although 1 month is 30.5 days on average, we want 31 days to be one month, and not remainder of 0.5 days which would not be intuitive. --- src/humanize/time.py | 8 ++++++-- tests/test_time.py | 24 ++++++++---------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 43fcc1c..0ea316d 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -379,7 +379,11 @@ def _quotient_and_remainder( if unit in suppress: return 0, value - return divmod(value, divisor) + # 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: @@ -633,7 +637,7 @@ def precisedelta( 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. + 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`. diff --git a/tests/test_time.py b/tests/test_time.py index 74d8560..70ea10c 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -563,14 +563,14 @@ def test_precisedelta_one_unit_enough( "minutes", "0 minutes", ), - (dt.timedelta(days=31), "seconds", "1 month and 12 hours"), - (dt.timedelta(days=32), "seconds", "1 month, 1 day and 12 hours"), + (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 and 12 hours"), - (dt.timedelta(days=31), "days", "1 month and 0.50 days"), - (dt.timedelta(days=32), "days", "1 month and 1.50 days"), + (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 and 0.50 days"), + (dt.timedelta(days=92), "days", "3 months"), ], ) def test_precisedelta_multiple_units( @@ -620,19 +620,11 @@ 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. Remaining 0.5 days is rounded down for both formats + # 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"), - # But adding a tiny amount will reveal a difference between %d and %.0f - # %d will truncate while %.0f will round to the nearest number. - (dt.timedelta(days=31.01), "days", "%d", "1 month"), - (dt.timedelta(days=31.01), "days", "%.0f", "1 month and 1 day"), - (dt.timedelta(days=31.99), "days", "%d", "1 month and 1 day"), - # 1 month is 30.5 days. Remaining 1.5 days is truncated for %d. - # For format %.0f, there is a tie, so it's rounded to the nearest even number, - # which is 2. See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules (dt.timedelta(days=32), "days", "%d", "1 month and 1 day"), - (dt.timedelta(days=32), "days", "%.0f", "1 month and 2 days"), + (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"),