Skip to content

Commit 3dca143

Browse files
authored
naturaldelta: round the value to nearest unit that makes sense (#272)
2 parents 6ab21b6 + bac6f26 commit 3dca143

File tree

2 files changed

+97
-43
lines changed

2 files changed

+97
-43
lines changed

src/humanize/time.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def _date_and_delta(
8484
delta = value
8585
else:
8686
try:
87-
value = value if precise else int(value)
87+
value = value if precise else round(value)
8888
delta = dt.timedelta(seconds=value)
8989
date = now - delta
9090
except (ValueError, TypeError):
@@ -101,6 +101,8 @@ def naturaldelta(
101101
102102
This is similar to `naturaltime`, but does not add tense to the result.
103103
104+
The timedelta will be rounded to the nearest unit that makes sense.
105+
104106
Args:
105107
value (datetime.timedelta, int or float): A timedelta or a number of seconds.
106108
months (bool): If `True`, then a number of months (based on 30.5 days) will be
@@ -155,9 +157,9 @@ def naturaldelta(
155157
delta = abs(delta)
156158
years = delta.days // 365
157159
days = delta.days % 365
158-
num_months = int(days // 30.5)
160+
num_months = round(days / 30.5)
159161

160-
if not years and days < 1:
162+
if years == 0 and days < 1:
161163
if delta.seconds == 0:
162164
if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000:
163165
return (
@@ -181,18 +183,24 @@ def naturaldelta(
181183
if delta.seconds < 60:
182184
return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds
183185

184-
if 60 <= delta.seconds < 120:
185-
return _("a minute")
186+
if 60 <= delta.seconds < 3600:
187+
minutes = round(delta.seconds / 60)
188+
if minutes == 1:
189+
return _("a minute")
190+
191+
if minutes == 60:
192+
return _("an hour")
186193

187-
if 120 <= delta.seconds < 3600:
188-
minutes = delta.seconds // 60
189194
return _ngettext("%d minute", "%d minutes", minutes) % minutes
190195

191-
if 3600 <= delta.seconds < 3600 * 2:
192-
return _("an hour")
196+
if 3600 <= delta.seconds:
197+
hours = round(delta.seconds / 3600)
198+
if hours == 1:
199+
return _("an hour")
200+
201+
if hours == 24:
202+
return _("a day")
193203

194-
if 3600 < delta.seconds:
195-
hours = delta.seconds // 3600
196204
return _ngettext("%d hour", "%d hours", hours) % hours
197205

198206
elif years == 0:
@@ -202,25 +210,32 @@ def naturaldelta(
202210
if not use_months:
203211
return _ngettext("%d day", "%d days", days) % days
204212

205-
if not num_months:
213+
if num_months == 0:
206214
return _ngettext("%d day", "%d days", days) % days
207215

208216
if num_months == 1:
209217
return _("a month")
210218

219+
if num_months == 12:
220+
return _("a year")
221+
211222
return _ngettext("%d month", "%d months", num_months) % num_months
212223

213224
elif years == 1:
214-
if not num_months and not days:
225+
if num_months == 0 and days == 0:
215226
return _("a year")
216227

217-
if not num_months:
228+
if num_months == 0:
218229
return _ngettext("1 year, %d day", "1 year, %d days", days) % days
219230

220231
if use_months:
221232
if num_months == 1:
222233
return _("1 year, 1 month")
223234

235+
if num_months == 12:
236+
years += 1
237+
return _ngettext("%d year", "%d years", years) % years
238+
224239
return (
225240
_ngettext("1 year, %d month", "1 year, %d months", num_months)
226241
% num_months
@@ -242,6 +257,8 @@ def naturaltime(
242257
243258
This is more or less compatible with Django's `naturaltime` filter.
244259
260+
The time will be rounded to the nearest unit that makes sense.
261+
245262
Args:
246263
value (datetime.datetime, datetime.timedelta, int or float): A `datetime`, a
247264
`timedelta`, or a number of seconds.

tests/test_time.py

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,26 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None:
9797
(23.5, "23 seconds"),
9898
(30, "30 seconds"),
9999
(dt.timedelta(microseconds=13), "a moment"),
100-
(dt.timedelta(minutes=1, seconds=30), "a minute"),
100+
(dt.timedelta(minutes=1, seconds=29), "a minute"),
101+
(dt.timedelta(minutes=1, seconds=30), "2 minutes"),
102+
(dt.timedelta(minutes=1, seconds=59), "2 minutes"),
101103
(dt.timedelta(minutes=2), "2 minutes"),
102-
(dt.timedelta(hours=1, minutes=30, seconds=30), "an hour"),
103-
(dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours"),
104+
(dt.timedelta(minutes=59), "59 minutes"),
105+
(dt.timedelta(minutes=59, seconds=30), "an hour"),
106+
(dt.timedelta(hours=1, minutes=29), "an hour"),
107+
# Round to nearest, ties to even.
108+
# See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules
109+
(dt.timedelta(hours=1, minutes=30), "2 hours"),
110+
(dt.timedelta(hours=2, minutes=30), "2 hours"),
111+
(dt.timedelta(hours=3, minutes=30), "4 hours"),
112+
(dt.timedelta(hours=23, minutes=50, seconds=50), "a day"),
104113
(dt.timedelta(days=1), "a day"),
105114
(dt.timedelta(days=500), "1 year, 4 months"),
106115
(dt.timedelta(days=365 * 2 + 35), "2 years"),
107116
(dt.timedelta(seconds=1), "a second"),
108117
(dt.timedelta(seconds=30), "30 seconds"),
118+
(dt.timedelta(days=364), "a year"),
119+
(dt.timedelta(days=365 + 364), "2 years"),
109120
# regression tests for bugs in post-release humanize
110121
(dt.timedelta(days=10000), "27 years"),
111122
(dt.timedelta(days=365 + 35), "1 year, 1 month"),
@@ -134,19 +145,25 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None:
134145
(NOW, "now"),
135146
(NOW - dt.timedelta(seconds=1), "a second ago"),
136147
(NOW - dt.timedelta(seconds=30), "30 seconds ago"),
137-
(NOW - dt.timedelta(minutes=1, seconds=30), "a minute ago"),
148+
(NOW - dt.timedelta(minutes=1, seconds=29), "a minute ago"),
149+
(NOW - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"),
138150
(NOW - dt.timedelta(minutes=2), "2 minutes ago"),
139-
(NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"),
140-
(NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"),
151+
(NOW - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"),
152+
(NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"),
153+
(NOW - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"),
154+
(NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"),
141155
(NOW - dt.timedelta(days=1), "a day ago"),
142156
(NOW - dt.timedelta(days=500), "1 year, 4 months ago"),
143157
(NOW - dt.timedelta(days=365 * 2 + 35), "2 years ago"),
144158
(NOW + dt.timedelta(seconds=1), "a second from now"),
145159
(NOW + dt.timedelta(seconds=30), "30 seconds from now"),
146-
(NOW + dt.timedelta(minutes=1, seconds=30), "a minute from now"),
160+
(NOW + dt.timedelta(minutes=1, seconds=29), "a minute from now"),
161+
(NOW + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"),
147162
(NOW + dt.timedelta(minutes=2), "2 minutes from now"),
148-
(NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"),
149-
(NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"),
163+
(NOW + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"),
164+
(NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"),
165+
(NOW + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"),
166+
(NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"),
150167
(NOW + dt.timedelta(days=1), "a day from now"),
151168
(NOW + dt.timedelta(days=500), "1 year, 4 months from now"),
152169
(NOW + dt.timedelta(days=365 * 2 + 35), "2 years from now"),
@@ -155,7 +172,9 @@ def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None:
155172
(NOW - dt.timedelta(days=365 + 35), "1 year, 1 month ago"),
156173
(dt.timedelta(days=-10000), "27 years from now"),
157174
(dt.timedelta(days=365 + 35), "1 year, 1 month ago"),
158-
(23.5, "23 seconds ago"),
175+
(22.5, "22 seconds ago"),
176+
(23.5, "24 seconds ago"),
177+
(23.9, "24 seconds ago"),
159178
(30, "30 seconds ago"),
160179
(NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"),
161180
(NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"),
@@ -175,21 +194,26 @@ def test_naturaltime(
175194
(NOW, "now"),
176195
(NOW - dt.timedelta(seconds=1), "a second ago"),
177196
(NOW - dt.timedelta(seconds=30), "30 seconds ago"),
178-
(NOW - dt.timedelta(minutes=1, seconds=30), "a minute ago"),
197+
(NOW - dt.timedelta(minutes=1, seconds=29), "a minute ago"),
198+
(NOW - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"),
179199
(NOW - dt.timedelta(minutes=2), "2 minutes ago"),
180-
(NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"),
181-
(NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"),
200+
(NOW - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"),
201+
(NOW - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"),
202+
(NOW - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"),
182203
(NOW - dt.timedelta(days=1), "a day ago"),
183204
(NOW - dt.timedelta(days=17), "17 days ago"),
184205
(NOW - dt.timedelta(days=47), "47 days ago"),
185206
(NOW - dt.timedelta(days=500), "1 year, 135 days ago"),
186207
(NOW - dt.timedelta(days=365 * 2 + 35), "2 years ago"),
187208
(NOW + dt.timedelta(seconds=1), "a second from now"),
188209
(NOW + dt.timedelta(seconds=30), "30 seconds from now"),
189-
(NOW + dt.timedelta(minutes=1, seconds=30), "a minute from now"),
210+
(NOW + dt.timedelta(minutes=1, seconds=29), "a minute from now"),
211+
(NOW + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"),
190212
(NOW + dt.timedelta(minutes=2), "2 minutes from now"),
191-
(NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"),
192-
(NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"),
213+
(NOW + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"),
214+
(NOW + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"),
215+
(NOW + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"),
216+
(NOW + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"),
193217
(NOW + dt.timedelta(days=1), "a day from now"),
194218
(NOW + dt.timedelta(days=500), "1 year, 135 days from now"),
195219
(NOW + dt.timedelta(days=365 * 2 + 35), "2 years from now"),
@@ -198,7 +222,8 @@ def test_naturaltime(
198222
(NOW - dt.timedelta(days=365 + 35), "1 year, 35 days ago"),
199223
(dt.timedelta(days=-10000), "27 years from now"),
200224
(dt.timedelta(days=365 + 35), "1 year, 35 days ago"),
201-
(23.5, "23 seconds ago"),
225+
(22.5, "22 seconds ago"),
226+
(23.5, "24 seconds ago"),
202227
(30, "30 seconds ago"),
203228
(NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"),
204229
(NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"),
@@ -419,19 +444,25 @@ def test_naturaltime_minimum_unit_explicit(
419444
(NOW_UTC, "now"),
420445
(NOW_UTC - dt.timedelta(seconds=1), "a second ago"),
421446
(NOW_UTC - dt.timedelta(seconds=30), "30 seconds ago"),
422-
(NOW_UTC - dt.timedelta(minutes=1, seconds=30), "a minute ago"),
447+
(NOW_UTC - dt.timedelta(minutes=1, seconds=29), "a minute ago"),
448+
(NOW_UTC - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"),
423449
(NOW_UTC - dt.timedelta(minutes=2), "2 minutes ago"),
424-
(NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"),
425-
(NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"),
450+
(NOW_UTC - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"),
451+
(NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"),
452+
(NOW_UTC - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"),
453+
(NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"),
426454
(NOW_UTC - dt.timedelta(days=1), "a day ago"),
427455
(NOW_UTC - dt.timedelta(days=500), "1 year, 4 months ago"),
428456
(NOW_UTC - dt.timedelta(days=365 * 2 + 35), "2 years ago"),
429457
(NOW_UTC + dt.timedelta(seconds=1), "a second from now"),
430458
(NOW_UTC + dt.timedelta(seconds=30), "30 seconds from now"),
431-
(NOW_UTC + dt.timedelta(minutes=1, seconds=30), "a minute from now"),
459+
(NOW_UTC + dt.timedelta(minutes=1, seconds=29), "a minute from now"),
460+
(NOW_UTC + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"),
432461
(NOW_UTC + dt.timedelta(minutes=2), "2 minutes from now"),
433-
(NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"),
434-
(NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"),
462+
(NOW_UTC + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"),
463+
(NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"),
464+
(NOW_UTC + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"),
465+
(NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"),
435466
(NOW_UTC + dt.timedelta(days=1), "a day from now"),
436467
(NOW_UTC + dt.timedelta(days=500), "1 year, 4 months from now"),
437468
(NOW_UTC + dt.timedelta(days=365 * 2 + 35), "2 years from now"),
@@ -453,19 +484,25 @@ def test_naturaltime_timezone(test_input: dt.datetime, expected: str) -> None:
453484
(NOW_UTC, "now"),
454485
(NOW_UTC - dt.timedelta(seconds=1), "a second ago"),
455486
(NOW_UTC - dt.timedelta(seconds=30), "30 seconds ago"),
456-
(NOW_UTC - dt.timedelta(minutes=1, seconds=30), "a minute ago"),
487+
(NOW_UTC - dt.timedelta(minutes=1, seconds=29), "a minute ago"),
488+
(NOW_UTC - dt.timedelta(minutes=1, seconds=30), "2 minutes ago"),
457489
(NOW_UTC - dt.timedelta(minutes=2), "2 minutes ago"),
458-
(NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "an hour ago"),
459-
(NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours ago"),
490+
(NOW_UTC - dt.timedelta(hours=1, minutes=29, seconds=30), "an hour ago"),
491+
(NOW_UTC - dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours ago"),
492+
(NOW_UTC - dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours ago"),
493+
(NOW_UTC - dt.timedelta(hours=23, minutes=50, seconds=50), "a day ago"),
460494
(NOW_UTC - dt.timedelta(days=1), "a day ago"),
461495
(NOW_UTC - dt.timedelta(days=500), "1 year, 4 months ago"),
462496
(NOW_UTC - dt.timedelta(days=365 * 2 + 35), "2 years ago"),
463497
(NOW_UTC + dt.timedelta(seconds=1), "a second from now"),
464498
(NOW_UTC + dt.timedelta(seconds=30), "30 seconds from now"),
465-
(NOW_UTC + dt.timedelta(minutes=1, seconds=30), "a minute from now"),
499+
(NOW_UTC + dt.timedelta(minutes=1, seconds=29), "a minute from now"),
500+
(NOW_UTC + dt.timedelta(minutes=1, seconds=30), "2 minutes from now"),
466501
(NOW_UTC + dt.timedelta(minutes=2), "2 minutes from now"),
467-
(NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "an hour from now"),
468-
(NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "23 hours from now"),
502+
(NOW_UTC + dt.timedelta(hours=1, minutes=29, seconds=30), "an hour from now"),
503+
(NOW_UTC + dt.timedelta(hours=1, minutes=30, seconds=30), "2 hours from now"),
504+
(NOW_UTC + dt.timedelta(hours=23, minutes=29, seconds=50), "23 hours from now"),
505+
(NOW_UTC + dt.timedelta(hours=23, minutes=50, seconds=50), "a day from now"),
469506
(NOW_UTC + dt.timedelta(days=1), "a day from now"),
470507
(NOW_UTC + dt.timedelta(days=500), "1 year, 4 months from now"),
471508
(NOW_UTC + dt.timedelta(days=365 * 2 + 35), "2 years from now"),

0 commit comments

Comments
 (0)