Skip to content

Commit 0f5d294

Browse files
dangilletnuztalgiaeldipaDaniel Gillet
authored
Precisedelta rounding (#254)
Co-authored-by: Nuz / Lovegood <[email protected]> Co-authored-by: Martin Di Paola <[email protected]> Co-authored-by: Daniel Gillet <[email protected]>
1 parent 58d10b4 commit 0f5d294

File tree

2 files changed

+181
-80
lines changed

2 files changed

+181
-80
lines changed

src/humanize/time.py

Lines changed: 94 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta:
6565
return delta
6666

6767

68-
def _date_and_delta(value: Any, *, now: dt.datetime | None = None) -> tuple[Any, Any]:
68+
def _date_and_delta(
69+
value: Any, *, now: dt.datetime | None = None, precise: bool = False
70+
) -> tuple[Any, Any]:
6971
"""Turn a value into a date and a timedelta which represents how long ago it was.
7072
7173
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,
8284
delta = value
8385
else:
8486
try:
85-
value = int(value)
87+
value = value if precise else int(value)
8688
delta = dt.timedelta(seconds=value)
8789
date = now - delta
8890
except (ValueError, TypeError):
@@ -345,77 +347,43 @@ def _quotient_and_remainder(
345347
unit: Unit,
346348
minimum_unit: Unit,
347349
suppress: Iterable[Unit],
350+
format: str,
348351
) -> tuple[float, float]:
349-
"""Divide `value` by `divisor` returning the quotient and remainder.
352+
"""Divide `value` by `divisor`, returning the quotient and remainder.
350353
351-
If `unit` is `minimum_unit`, makes the quotient a float number and the remainder
352-
will be zero. The rational is that if `unit` is the unit of the quotient, we cannot
353-
represent the remainder because it would require a unit smaller than the
354-
`minimum_unit`.
354+
If `unit` is `minimum_unit`, the quotient will be the rounding of `value / divisor`
355+
according to the `format` string and the remainder will be zero. The rationale is
356+
that if `unit` is the unit of the quotient, we cannot represent the remainder
357+
because it would require a unit smaller than the `minimum_unit`.
355358
356359
>>> from humanize.time import _quotient_and_remainder, Unit
357-
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, [])
360+
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, [], "%0.2f")
358361
(1.5, 0)
359362
360-
If unit is in `suppress`, the quotient will be zero and the remainder will be the
363+
If `unit` is in `suppress`, the quotient will be zero and the remainder will be the
361364
initial value. The idea is that if we cannot use `unit`, we are forced to use a
362-
lower unit so we cannot do the division.
365+
lower unit, so we cannot do the division.
363366
364-
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS])
367+
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS], "%0.2f")
365368
(0, 36)
366369
367-
In other case return quotient and remainder as `divmod` would do it.
370+
In other cases, return the quotient and remainder as `divmod` would do it.
368371
369-
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [])
372+
>>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [], "%0.2f")
370373
(1, 12)
371374
372375
"""
373376
if unit == minimum_unit:
374-
return value / divisor, 0
377+
return _rounding_by_fmt(format, value / divisor), 0
375378

376379
if unit in suppress:
377380
return 0, value
378381

379-
return divmod(value, divisor)
380-
381-
382-
def _carry(
383-
value1: float,
384-
value2: float,
385-
ratio: float,
386-
unit: Unit,
387-
min_unit: Unit,
388-
suppress: Iterable[Unit],
389-
) -> tuple[float, float]:
390-
"""Return a tuple with two values.
391-
392-
If the unit is in `suppress`, multiply `value1` by `ratio` and add it to `value2`
393-
(carry to right). The idea is that if we cannot represent `value1` we need to
394-
represent it in a lower unit.
395-
396-
>>> from humanize.time import _carry, Unit
397-
>>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, [Unit.DAYS])
398-
(0, 54)
399-
400-
If the unit is the minimum unit, `value2` is divided by `ratio` and added to
401-
`value1` (carry to left). We assume that `value2` has a lower unit so we need to
402-
carry it to `value1`.
403-
404-
>>> _carry(2, 6, 24, Unit.DAYS, Unit.DAYS, [])
405-
(2.25, 0)
406-
407-
Otherwise, just return the same input:
408-
409-
>>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, [])
410-
(2, 6)
411-
"""
412-
if unit == min_unit:
413-
return value1 + value2 / ratio, 0
414-
415-
if unit in suppress:
416-
return 0, value2 + value1 * ratio
417-
418-
return value1, value2
382+
# Convert the remainder back to integer is necessary for months. 1 month is 30.5
383+
# days on average, but if we have 31 days, we want to count is as a whole month,
384+
# and not as 1 month plus a remainder of 0.5 days.
385+
q, r = divmod(value, divisor)
386+
return q, int(r)
419387

420388

421389
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]
464432

465433

466434
def precisedelta(
467-
value: dt.timedelta | int | None,
435+
value: dt.timedelta | float | None,
468436
minimum_unit: str = "seconds",
469437
suppress: Iterable[str] = (),
470438
format: str = "%0.2f",
471439
) -> str:
472-
"""Return a precise representation of a timedelta.
440+
"""Return a precise representation of a timedelta or number of seconds.
473441
474442
```pycon
475443
>>> import datetime as dt
@@ -535,14 +503,14 @@ def precisedelta(
535503
536504
```
537505
"""
538-
date, delta = _date_and_delta(value)
506+
date, delta = _date_and_delta(value, precise=True)
539507
if date is None:
540508
return str(value)
541509

542510
suppress_set = {Unit[s.upper()] for s in suppress}
543511

544-
# Find a suitable minimum unit (it can be greater the one that the
545-
# user gave us if it is suppressed).
512+
# Find a suitable minimum unit (it can be greater than the one that the
513+
# user gave us, if that one is suppressed).
546514
min_unit = Unit[minimum_unit.upper()]
547515
min_unit = _suitable_minimum_unit(min_unit, suppress_set)
548516
del minimum_unit
@@ -572,27 +540,57 @@ def precisedelta(
572540
# years, days = divmod(years, days)
573541
#
574542
# The same applies for months, hours, minutes and milliseconds below
575-
years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress_set)
576-
months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress_set)
543+
years, days = _quotient_and_remainder(
544+
days, 365, YEARS, min_unit, suppress_set, format
545+
)
546+
months, days = _quotient_and_remainder(
547+
days, 30.5, MONTHS, min_unit, suppress_set, format
548+
)
577549

578-
# If DAYS is not in suppress, we can represent the days but
579-
# if it is a suppressed unit, we need to carry it to a lower unit,
580-
# seconds in this case.
581-
#
582-
# The same applies for secs and usecs below
583-
days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress_set)
550+
secs = days * 24 * 3600 + secs
551+
days, secs = _quotient_and_remainder(
552+
secs, 24 * 3600, DAYS, min_unit, suppress_set, format
553+
)
584554

585-
hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress_set)
586-
minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress_set)
555+
hours, secs = _quotient_and_remainder(
556+
secs, 3600, HOURS, min_unit, suppress_set, format
557+
)
558+
minutes, secs = _quotient_and_remainder(
559+
secs, 60, MINUTES, min_unit, suppress_set, format
560+
)
587561

588-
secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress_set)
562+
usecs = secs * 1e6 + usecs
563+
secs, usecs = _quotient_and_remainder(
564+
usecs, 1e6, SECONDS, min_unit, suppress_set, format
565+
)
589566

590567
msecs, usecs = _quotient_and_remainder(
591-
usecs, 1000, MILLISECONDS, min_unit, suppress_set
568+
usecs, 1000, MILLISECONDS, min_unit, suppress_set, format
592569
)
593570

594-
# if _unused != 0 we had lost some precision
595-
usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress_set)
571+
# Due to rounding, it could be that a unit is high enough to be promoted to a higher
572+
# unit. Example: 59.9 minutes was rounded to 60 minutes, and thus it should become 0
573+
# minutes and one hour more.
574+
if msecs >= 1_000 and SECONDS not in suppress_set:
575+
msecs -= 1_000
576+
secs += 1
577+
if secs >= 60 and MINUTES not in suppress_set:
578+
secs -= 60
579+
minutes += 1
580+
if minutes >= 60 and HOURS not in suppress_set:
581+
minutes -= 60
582+
hours += 1
583+
if hours >= 24 and DAYS not in suppress_set:
584+
hours -= 24
585+
days += 1
586+
# When adjusting we should not deal anymore with fractional days as all rounding has
587+
# been already made. We promote 31 days to an extra month.
588+
if days >= 31 and MONTHS not in suppress_set:
589+
days -= 31
590+
months += 1
591+
if months >= 12 and YEARS not in suppress_set:
592+
months -= 12
593+
years += 1
596594

597595
fmts = [
598596
("%d year", "%d years", years),
@@ -616,6 +614,8 @@ def precisedelta(
616614
if unit == min_unit and math.modf(fmt_value)[0] > 0:
617615
fmt_txt = fmt_txt.replace("%d", format)
618616
elif unit == YEARS:
617+
if math.modf(fmt_value)[0] == 0:
618+
fmt_value = int(fmt_value)
619619
fmt_txt = fmt_txt.replace("%d", "%s")
620620
texts.append(fmt_txt % intcomma(fmt_value))
621621
continue
@@ -632,3 +632,24 @@ def precisedelta(
632632
tail = texts[-1]
633633

634634
return _("%s and %s") % (head, tail)
635+
636+
637+
def _rounding_by_fmt(format: str, value: float) -> float | int:
638+
"""Round a number according to the string format provided.
639+
640+
The string format is the old printf-style string formatting.
641+
642+
If we are using a format which truncates the value, such as "%d" or "%i", the
643+
returned value will be of type `int`.
644+
645+
If we are using a format which rounds the value, such as "%.2f" or even "%.0f",
646+
we will return a float.
647+
"""
648+
result = format % value
649+
650+
try:
651+
value = int(result)
652+
except ValueError:
653+
value = float(result)
654+
655+
return value

0 commit comments

Comments
 (0)