From 6d89dfa96779eeca96654c48a3d6106942cf1a6c Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Sat, 1 Nov 2025 16:11:07 +0100 Subject: [PATCH 1/2] naturaldelta rounds the value to the nearest unit that makes sense naturaldelta used to always round down. We want instead to round to the nearest unit that makes sense. fix #174 --- src/humanize/time.py | 43 ++++++++++++++------- tests/test_time.py | 89 ++++++++++++++++++++++++++++++-------------- 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 0ea316d..3e7c2dd 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -101,6 +101,8 @@ def naturaldelta( This is similar to `naturaltime`, but does not add tense to the result. + The timedelta will be rounded to the nearest unit that makes sense. + Args: value (datetime.timedelta, int or float): A timedelta or a number of seconds. months (bool): If `True`, then a number of months (based on 30.5 days) will be @@ -155,9 +157,9 @@ def naturaldelta( delta = abs(delta) years = delta.days // 365 days = delta.days % 365 - num_months = int(days // 30.5) + num_months = round(days / 30.5) - if not years and days < 1: + if years == 0 and days < 1: if delta.seconds == 0: if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000: return ( @@ -181,18 +183,24 @@ def naturaldelta( if delta.seconds < 60: return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds - if 60 <= delta.seconds < 120: - return _("a minute") + if 60 <= delta.seconds < 3600: + minutes = round(delta.seconds / 60) + if minutes == 1: + return _("a minute") + + if minutes == 60: + return _("an hour") - if 120 <= delta.seconds < 3600: - minutes = delta.seconds // 60 return _ngettext("%d minute", "%d minutes", minutes) % minutes - if 3600 <= delta.seconds < 3600 * 2: - return _("an hour") + if 3600 <= delta.seconds: + hours = round(delta.seconds / 3600) + if hours == 1: + return _("an hour") + + if hours == 24: + return _("a day") - if 3600 < delta.seconds: - hours = delta.seconds // 3600 return _ngettext("%d hour", "%d hours", hours) % hours elif years == 0: @@ -202,25 +210,32 @@ def naturaldelta( if not use_months: return _ngettext("%d day", "%d days", days) % days - if not num_months: + if num_months == 0: return _ngettext("%d day", "%d days", days) % days if num_months == 1: return _("a month") + if num_months == 12: + return _("a year") + return _ngettext("%d month", "%d months", num_months) % num_months elif years == 1: - if not num_months and not days: + if num_months == 0 and days == 0: return _("a year") - if not num_months: + if num_months == 0: return _ngettext("1 year, %d day", "1 year, %d days", days) % days if use_months: if num_months == 1: return _("1 year, 1 month") + if num_months == 12: + years += 1 + return _ngettext("%d year", "%d years", years) % years + return ( _ngettext("1 year, %d month", "1 year, %d months", num_months) % num_months @@ -242,6 +257,8 @@ def naturaltime( This is more or less compatible with Django's `naturaltime` filter. + The time will be rounded to the nearest unit that makes sense. + Args: value (datetime.datetime, datetime.timedelta, int or float): A `datetime`, a `timedelta`, or a number of seconds. diff --git a/tests/test_time.py b/tests/test_time.py index 70c2bd5..ae77b68 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -97,15 +97,26 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None: (23.5, "23 seconds"), (30, "30 seconds"), (dt.timedelta(microseconds=13), "a moment"), - (dt.timedelta(minutes=1, seconds=30), "a minute"), + (dt.timedelta(minutes=1, seconds=29), "a minute"), + (dt.timedelta(minutes=1, seconds=30), "2 minutes"), + (dt.timedelta(minutes=1, seconds=59), "2 minutes"), (dt.timedelta(minutes=2), "2 minutes"), - (dt.timedelta(hours=1, minutes=30, seconds=30), "an hour"), - (dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours"), + (dt.timedelta(minutes=59), "59 minutes"), + (dt.timedelta(minutes=59, seconds=30), "an hour"), + (dt.timedelta(hours=1, minutes=29), "an hour"), + # Round to nearest, ties away from zero. + # See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules + (dt.timedelta(hours=1, minutes=30), "2 hours"), + (dt.timedelta(hours=2, minutes=30), "2 hours"), + (dt.timedelta(hours=3, minutes=30), "4 hours"), + (dt.timedelta(hours=23, minutes=50, seconds=50), "a day"), (dt.timedelta(days=1), "a day"), (dt.timedelta(days=500), "1 year, 4 months"), (dt.timedelta(days=365 * 2 + 35), "2 years"), (dt.timedelta(seconds=1), "a second"), (dt.timedelta(seconds=30), "30 seconds"), + (dt.timedelta(days=364), "a year"), + (dt.timedelta(days=365 + 364), "2 years"), # regression tests for bugs in post-release humanize (dt.timedelta(days=10000), "27 years"), (dt.timedelta(days=365 + 35), "1 year, 1 month"), @@ -134,19 +145,25 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None: (NOW, "now"), (NOW - dt.timedelta(seconds=1), "a second ago"), (NOW - dt.timedelta(seconds=30), "30 seconds ago"), - (NOW - dt.timedelta(minutes=1, seconds=30), "a minute ago"), + (NOW - dt.timedelta(minutes=1, seconds=29), "a minute ago"), + (NOW - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"), (NOW - dt.timedelta(minutes=2), "2 minutes ago"), - (NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"), - (NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"), + (NOW - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"), + (NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"), + (NOW - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"), + (NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"), (NOW - dt.timedelta(days=1), "a day ago"), (NOW - dt.timedelta(days=500), "1 year, 4 months ago"), (NOW - dt.timedelta(days=365 * 2 + 35), "2 years ago"), (NOW + dt.timedelta(seconds=1), "a second from now"), (NOW + dt.timedelta(seconds=30), "30 seconds from now"), - (NOW + dt.timedelta(minutes=1, seconds=30), "a minute from now"), + (NOW + dt.timedelta(minutes=1, seconds=29), "a minute from now"), + (NOW + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"), (NOW + dt.timedelta(minutes=2), "2 minutes from now"), - (NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"), - (NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"), + (NOW + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"), + (NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"), + (NOW + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"), + (NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"), (NOW + dt.timedelta(days=1), "a day from now"), (NOW + dt.timedelta(days=500), "1 year, 4 months from now"), (NOW + dt.timedelta(days=365 * 2 + 35), "2 years from now"), @@ -156,6 +173,7 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None: (dt.timedelta(days=-10000), "27 years from now"), (dt.timedelta(days=365 + 35), "1 year, 1 month ago"), (23.5, "23 seconds ago"), + # (23.9, "24 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), @@ -175,10 +193,12 @@ def test_naturaltime( (NOW, "now"), (NOW - dt.timedelta(seconds=1), "a second ago"), (NOW - dt.timedelta(seconds=30), "30 seconds ago"), - (NOW - dt.timedelta(minutes=1, seconds=30), "a minute ago"), + (NOW - dt.timedelta(minutes=1, seconds=29), "a minute ago"), + (NOW - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"), (NOW - dt.timedelta(minutes=2), "2 minutes ago"), - (NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"), - (NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"), + (NOW - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"), + (NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"), + (NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"), (NOW - dt.timedelta(days=1), "a day ago"), (NOW - dt.timedelta(days=17), "17 days ago"), (NOW - dt.timedelta(days=47), "47 days ago"), @@ -186,10 +206,13 @@ def test_naturaltime( (NOW - dt.timedelta(days=365 * 2 + 35), "2 years ago"), (NOW + dt.timedelta(seconds=1), "a second from now"), (NOW + dt.timedelta(seconds=30), "30 seconds from now"), - (NOW + dt.timedelta(minutes=1, seconds=30), "a minute from now"), + (NOW + dt.timedelta(minutes=1, seconds=29), "a minute from now"), + (NOW + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"), (NOW + dt.timedelta(minutes=2), "2 minutes from now"), - (NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"), - (NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"), + (NOW + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"), + (NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"), + (NOW + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"), + (NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"), (NOW + dt.timedelta(days=1), "a day from now"), (NOW + dt.timedelta(days=500), "1 year, 135 days from now"), (NOW + dt.timedelta(days=365 * 2 + 35), "2 years from now"), @@ -419,19 +442,25 @@ def test_naturaltime_minimum_unit_explicit( (NOW_UTC, "now"), (NOW_UTC - dt.timedelta(seconds=1), "a second ago"), (NOW_UTC - dt.timedelta(seconds=30), "30 seconds ago"), - (NOW_UTC - dt.timedelta(minutes=1, seconds=30), "a minute ago"), + (NOW_UTC - dt.timedelta(minutes=1, seconds=29), "a minute ago"), + (NOW_UTC - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"), (NOW_UTC - dt.timedelta(minutes=2), "2 minutes ago"), - (NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"), - (NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"), + (NOW_UTC - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"), + (NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"), + (NOW_UTC - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"), + (NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"), (NOW_UTC - dt.timedelta(days=1), "a day ago"), (NOW_UTC - dt.timedelta(days=500), "1 year, 4 months ago"), (NOW_UTC - dt.timedelta(days=365 * 2 + 35), "2 years ago"), (NOW_UTC + dt.timedelta(seconds=1), "a second from now"), (NOW_UTC + dt.timedelta(seconds=30), "30 seconds from now"), - (NOW_UTC + dt.timedelta(minutes=1, seconds=30), "a minute from now"), + (NOW_UTC + dt.timedelta(minutes=1, seconds=29), "a minute from now"), + (NOW_UTC + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"), (NOW_UTC + dt.timedelta(minutes=2), "2 minutes from now"), - (NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"), - (NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"), + (NOW_UTC + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"), + (NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"), + (NOW_UTC + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"), + (NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"), (NOW_UTC + dt.timedelta(days=1), "a day from now"), (NOW_UTC + dt.timedelta(days=500), "1 year, 4 months from now"), (NOW_UTC + dt.timedelta(days=365 * 2 + 35), "2 years from now"), @@ -453,19 +482,25 @@ def test_naturaltime_timezone(test_input: dt.datetime, expected: str) -> None: (NOW_UTC, "now"), (NOW_UTC - dt.timedelta(seconds=1), "a second ago"), (NOW_UTC - dt.timedelta(seconds=30), "30 seconds ago"), - (NOW_UTC - dt.timedelta(minutes=1, seconds=30), "a minute ago"), + (NOW_UTC - dt.timedelta(minutes=1, seconds=29), "a minute ago"), + (NOW_UTC - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"), (NOW_UTC - dt.timedelta(minutes=2), "2 minutes ago"), - (NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"), - (NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"), + (NOW_UTC - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"), + (NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"), + (NOW_UTC - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"), + (NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"), (NOW_UTC - dt.timedelta(days=1), "a day ago"), (NOW_UTC - dt.timedelta(days=500), "1 year, 4 months ago"), (NOW_UTC - dt.timedelta(days=365 * 2 + 35), "2 years ago"), (NOW_UTC + dt.timedelta(seconds=1), "a second from now"), (NOW_UTC + dt.timedelta(seconds=30), "30 seconds from now"), - (NOW_UTC + dt.timedelta(minutes=1, seconds=30), "a minute from now"), + (NOW_UTC + dt.timedelta(minutes=1, seconds=29), "a minute from now"), + (NOW_UTC + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"), (NOW_UTC + dt.timedelta(minutes=2), "2 minutes from now"), - (NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"), - (NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"), + (NOW_UTC + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"), + (NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"), + (NOW_UTC + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"), + (NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"), (NOW_UTC + dt.timedelta(days=1), "a day from now"), (NOW_UTC + dt.timedelta(days=500), "1 year, 4 months from now"), (NOW_UTC + dt.timedelta(days=365 * 2 + 35), "2 years from now"), From 906a38cb957e37ada2cb3574102b56a3dbc5e4e9 Mon Sep 17 00:00:00 2001 From: Daniel Gillet Date: Thu, 13 Nov 2025 20:38:37 +0100 Subject: [PATCH 2/2] naturaltime rounds to nearest when provided with a float value. We used to round down. Now we round to the nearest. --- src/humanize/time.py | 2 +- tests/test_time.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 3e7c2dd..f0b24fa 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -84,7 +84,7 @@ def _date_and_delta( delta = value else: try: - value = value if precise else int(value) + value = value if precise else round(value) delta = dt.timedelta(seconds=value) date = now - delta except (ValueError, TypeError): diff --git a/tests/test_time.py b/tests/test_time.py index ae77b68..ff76200 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -172,8 +172,9 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None: (NOW - dt.timedelta(days=365 + 35), "1 year, 1 month ago"), (dt.timedelta(days=-10000), "27 years from now"), (dt.timedelta(days=365 + 35), "1 year, 1 month ago"), - (23.5, "23 seconds ago"), - # (23.9, "24 seconds ago"), + (22.5, "22 seconds ago"), + (23.5, "24 seconds ago"), + (23.9, "24 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), @@ -221,7 +222,8 @@ def test_naturaltime( (NOW - dt.timedelta(days=365 + 35), "1 year, 35 days ago"), (dt.timedelta(days=-10000), "27 years from now"), (dt.timedelta(days=365 + 35), "1 year, 35 days ago"), - (23.5, "23 seconds ago"), + (22.5, "22 seconds ago"), + (23.5, "24 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"),