Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve precisedelta to better support rounding and float values. #39

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions src/humanize/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import collections.abc
import datetime as dt
import math
import re
import typing
from enum import Enum
from functools import total_ordering
Expand Down Expand Up @@ -63,7 +64,7 @@ def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta:


def _date_and_delta(
value: typing.Any, *, now: dt.datetime | None = None
value: typing.Any, *, now: dt.datetime | None = None, precise: bool = False
) -> tuple[typing.Any, typing.Any]:
"""Turn a value into a date and a timedelta which represents how long ago it was.

Expand All @@ -79,7 +80,7 @@ def _date_and_delta(
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):
Expand Down Expand Up @@ -297,25 +298,25 @@ def _quotient_and_remainder(
minimum_unit: Unit,
suppress: collections.abc.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`.

>>> from humanize.time import _quotient_and_remainder, Unit
>>> _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)
Expand All @@ -339,16 +340,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, [])
Expand All @@ -368,7 +369,7 @@ def _carry(


def _suitable_minimum_unit(min_unit: Unit, suppress: typing.Iterable[Unit]) -> Unit:
"""Return a minimum unit suitable that is not suppressed.
"""Return a suitable minimum unit that is not suppressed.

If not suppressed, return the same unit:

Expand Down Expand Up @@ -414,12 +415,12 @@ def _suppress_lower_units(min_unit: Unit, suppress: typing.Iterable[Unit]) -> se


def precisedelta(
value: dt.timedelta | int,
value: dt.timedelta | float,
minimum_unit: str = "seconds",
suppress: typing.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
Expand Down Expand Up @@ -485,14 +486,14 @@ def precisedelta(

```
"""
date, delta = _date_and_delta(value)
date, delta = _date_and_delta(value, precise=True)
if date is None:
return str(value)

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
Expand Down Expand Up @@ -525,12 +526,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)
Expand All @@ -541,7 +538,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 = [
Expand All @@ -555,9 +552,13 @@ def precisedelta(
("%d microsecond", "%d microseconds", usecs),
]

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_txt = _ngettext(singular_txt, plural_txt, fmt_value)
if unit == min_unit and math.modf(fmt_value)[0] > 0:
Expand Down
52 changes: 44 additions & 8 deletions tests/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None:
(dt.timedelta(days=999_999_999), "2,739,726 years"),
],
)
def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None:
def test_naturaldelta(test_input: dt.timedelta | float, expected: str) -> None:
assert humanize.naturaldelta(test_input) == expected


Expand Down Expand Up @@ -166,11 +166,13 @@ def test_naturaldelta(test_input: int | 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


def nt_nomonths(d: dt.datetime) -> str:
def nt_nomonths(d: dt.datetime | dt.timedelta | float) -> str:
return humanize.naturaltime(d, months=False)


Expand Down Expand Up @@ -211,7 +213,9 @@ def nt_nomonths(d: dt.datetime) -> str:
("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 nt_nomonths(test_input) == expected


Expand Down Expand Up @@ -437,7 +441,7 @@ def test_naturaltime_minimum_unit_explicit(
],
)
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

Expand Down Expand Up @@ -490,10 +494,18 @@ 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(
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

Expand Down Expand Up @@ -539,12 +551,36 @@ 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=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"),
(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(
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

Expand Down Expand Up @@ -621,7 +657,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
Expand Down