Skip to content

Commit

Permalink
Fix handling of negative time deltas (#173)
Browse files Browse the repository at this point in the history
Fixes #18. 
Fixes #171.

The [Python docs
say](https://docs.python.org/3/library/datetime.html#datetime.timedelta):

> String representations of
[timedelta](https://docs.python.org/3/library/datetime.html#datetime.timedelta)
objects are normalized similarly to their internal representation. This
leads to somewhat unusual results for negative timedeltas. For example:

```pycon
>>> timedelta(hours=-5)
datetime.timedelta(days=-1, seconds=68400)
>>> print(_)
-1 day, 19:00:00
```

However, we assumed all components have the same sign, and used absolute
values of some of those, leading to inconsistent behaviour (on Linux and
macOS; it still worked on Windows).

Instead, we should convert the whole `timedelta` into its absolute
value, and then use its components directly.

Also test all the `timedelta`s with positive and negative values.
  • Loading branch information
hugovk committed Feb 25, 2024
2 parents 1399e04 + ec99f29 commit 218a86e
Show file tree
Hide file tree
Showing 3 changed files with 18 additions and 15 deletions.
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Expand Up @@ -28,7 +28,8 @@ repos:
hooks:
- id: mypy
additional_dependencies: [pytest, types-freezegun, types-setuptools]
args: [--strict, --pretty, --show-error-codes]
args: [--strict, --pretty, --show-error-codes, .]
pass_filenames: false

- repo: https://github.com/tox-dev/pyproject-fmt
rev: 1.7.0
Expand Down
27 changes: 13 additions & 14 deletions src/humanize/time.py
Expand Up @@ -141,14 +141,13 @@ def naturaldelta(

use_months = months

seconds = abs(delta.seconds)
days = abs(delta.days)
years = days // 365
days = days % 365
delta = abs(delta)
years = delta.days // 365
days = delta.days % 365
num_months = int(days // 30.5)

if not years and days < 1:
if seconds == 0:
if delta.seconds == 0:
if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000:
return (
_ngettext("%d microsecond", "%d microseconds", delta.microseconds)
Expand All @@ -165,24 +164,24 @@ def naturaldelta(
)
return _("a moment")

if seconds == 1:
if delta.seconds == 1:
return _("a second")

if seconds < 60:
return _ngettext("%d second", "%d seconds", seconds) % seconds
if delta.seconds < 60:
return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds

if 60 <= seconds < 120:
if 60 <= delta.seconds < 120:
return _("a minute")

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

if 3600 <= seconds < 3600 * 2:
if 3600 <= delta.seconds < 3600 * 2:
return _("an hour")

if 3600 < seconds:
hours = seconds // 3600
if 3600 < delta.seconds:
hours = delta.seconds // 3600
return _ngettext("%d hour", "%d hours", hours) % hours

elif years == 0:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_time.py
Expand Up @@ -95,6 +95,7 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None:
(1, "a second"),
(23.5, "23 seconds"),
(30, "30 seconds"),
(dt.timedelta(microseconds=13), "a moment"),
(dt.timedelta(minutes=1, seconds=30), "a minute"),
(dt.timedelta(minutes=2), "2 minutes"),
(dt.timedelta(hours=1, minutes=30, seconds=30), "an hour"),
Expand Down Expand Up @@ -129,6 +130,8 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None:
)
def test_naturaldelta(test_input: float | dt.timedelta, expected: str) -> None:
assert humanize.naturaldelta(test_input) == expected
if not isinstance(test_input, str):
assert humanize.naturaldelta(-test_input) == expected


@freeze_time("2020-02-02")
Expand Down

0 comments on commit 218a86e

Please sign in to comment.