Skip to content

Commit 191f093

Browse files
author
Daniel Gillet
committed
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.
1 parent f220b67 commit 191f093

File tree

2 files changed

+63
-10
lines changed

2 files changed

+63
-10
lines changed

src/humanize/time.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -603,14 +603,11 @@ def precisedelta(
603603
("%d microsecond", "%d microseconds", usecs),
604604
]
605605

606-
import re
607-
round_fmt_value = re.fullmatch(r"%\d*(d|(\.0*f))", format)
608-
609606
texts: list[str] = []
610607
for unit, fmt in zip(reversed(Unit), fmts):
611608
singular_txt, plural_txt, fmt_value = fmt
612-
if round_fmt_value:
613-
fmt_value = round(fmt_value)
609+
610+
fmt_value = _rounding_by_fmt(format, fmt_value)
614611
if fmt_value > 0 or (not texts and unit == min_unit):
615612
_fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value)
616613
fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value)
@@ -619,6 +616,8 @@ def precisedelta(
619616
if unit == min_unit and math.modf(fmt_value)[0] > 0:
620617
fmt_txt = fmt_txt.replace("%d", format)
621618
elif unit == YEARS:
619+
if math.modf(fmt_value)[0] == 0:
620+
fmt_value = int(fmt_value)
622621
fmt_txt = fmt_txt.replace("%d", "%s")
623622
texts.append(fmt_txt % intcomma(fmt_value))
624623
continue
@@ -635,3 +634,24 @@ def precisedelta(
635634
tail = texts[-1]
636635

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

tests/test_time.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ def test_precisedelta_multiple_units(
594594
"%0.4f",
595595
"2.0020 milliseconds",
596596
),
597-
(dt.timedelta(microseconds=2002), "milliseconds", "%0.2f", "2.00 milliseconds"),
597+
(dt.timedelta(microseconds=2002), "milliseconds", "%0.2f", "2 milliseconds"),
598598
(
599599
dt.timedelta(seconds=1, microseconds=230000),
600600
"seconds",
@@ -620,18 +620,34 @@ def test_precisedelta_multiple_units(
620620
"5 days and 4.50 hours",
621621
),
622622
(dt.timedelta(days=5, hours=4, seconds=30 * 60), "days", "%0.2f", "5.19 days"),
623+
# 1 month is 30.5 days. Remaining 0.5 days is rounded down for both formats
623624
(dt.timedelta(days=31), "days", "%d", "1 month"),
624-
(dt.timedelta(days=31.01), "days", "%d", "1 month and 1 day"),
625+
(dt.timedelta(days=31), "days", "%.0f", "1 month"),
626+
# But adding a tiny amount will reveal a difference between %d and %.0f
627+
# %d will truncate while %.0f will round to the nearest number.
628+
(dt.timedelta(days=31.01), "days", "%d", "1 month"),
629+
(dt.timedelta(days=31.01), "days", "%.0f", "1 month and 1 day"),
625630
(dt.timedelta(days=31.99), "days", "%d", "1 month and 1 day"),
626-
(dt.timedelta(days=32), "days", "%d", "1 month and 2 days"),
631+
# 1 month is 30.5 days. Remaining 1.5 days is truncated for %d.
632+
# For format %.0f, there is a tie, so it's rounded to the nearest even number,
633+
# which is 2. See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules
634+
(dt.timedelta(days=32), "days", "%d", "1 month and 1 day"),
635+
(dt.timedelta(days=32), "days", "%.0f", "1 month and 2 days"),
627636
(dt.timedelta(days=62), "days", "%d", "2 months and 1 day"),
628637
(dt.timedelta(days=92), "days", "%d", "3 months"),
629638
(dt.timedelta(days=120), "months", "%0.2f", "3.93 months"),
630639
(dt.timedelta(days=183), "years", "%0.1f", "0.5 years"),
631640
(0.01, "seconds", "%0.3f", "0.010 seconds"),
632-
(31, "minutes", "%d", "1 minute"),
641+
# 31 seconds will be truncated to 0 with %d and rounded to the nearest
642+
# number with %.0f, ie. 1
643+
(31, "minutes", "%d", "0 minutes"),
644+
(31, "minutes", "%0.0f", "1 minute"),
633645
(60 + 29.99, "minutes", "%d", "1 minute"),
634-
(60 + 30, "minutes", "%d", "2 minutes"),
646+
(60 + 29.99, "minutes", "%.0f", "1 minute"),
647+
(60 + 30, "minutes", "%d", "1 minute"),
648+
# 30 sec is 0.5 minutes. Round to nearest, ties away from zero.
649+
# See https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules
650+
(60 + 30, "minutes", "%.0f", "2 minutes"),
635651
(60 * 60 + 30.99, "minutes", "%.0f", "1 hour"),
636652
(60 * 60 + 31, "minutes", "%.0f", "1 hour and 1 minute"),
637653
(
@@ -750,3 +766,20 @@ def test_time_unit() -> None:
750766

751767
with pytest.raises(TypeError):
752768
_ = years < "foo"
769+
770+
771+
@pytest.mark.parametrize(
772+
"fmt, value, expected",
773+
[
774+
("%.2f", 1.011, 1.01),
775+
("%.0f", 1.01, 1.0),
776+
("%.0f", 1.5, 2.0),
777+
("%10.0f", 1.01, 1.0),
778+
("%i", 1.01, 1),
779+
# Surprising rounding with %d. It does not truncate for all values...
780+
("%d", 1.999999999999999, 1),
781+
("%d", 1.9999999999999999, 2),
782+
],
783+
)
784+
def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None:
785+
assert time._rounding_by_fmt(fmt, value) == pytest.approx(expected)

0 commit comments

Comments
 (0)