Skip to content
Draft
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
22 changes: 11 additions & 11 deletions src/humanize/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,13 @@ def naturaltime(
"""
import datetime as dt

value = _convert_aware_datetime(value)
when = _convert_aware_datetime(when)

now = when or _now()
aware_value = isinstance(value, dt.datetime) and value.tzinfo is not None
now = when or (dt.datetime.now(dt.timezone.utc) if aware_value else _now())
if aware_value or now.tzinfo is not None:
# Compare instants in UTC so a local daylight-saving transition cannot
# erase a repeated hour or count a skipped hour twice.
value = _convert_datetime_to_utc(value)
now = _convert_datetime_to_utc(now)

date, delta = _date_and_delta(value, now=now)
if date is None:
Expand All @@ -311,17 +314,14 @@ def naturaltime(
return str(ago % delta)


def _convert_aware_datetime(
def _convert_datetime_to_utc(
value: dt.datetime | dt.timedelta | float | None,
) -> Any:
"""Convert aware datetime to naive datetime and pass through any other type."""
if value is None:
return None

"""Convert a datetime to naive UTC and pass through any other type."""
import datetime as dt

if isinstance(value, dt.datetime) and value.tzinfo is not None:
value = dt.datetime.fromtimestamp(value.timestamp())
if isinstance(value, dt.datetime):
value = value.astimezone(dt.timezone.utc).replace(tzinfo=None)
return value


Expand Down
97 changes: 97 additions & 0 deletions tests/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import datetime as dt
import typing
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import pytest
from freezegun import freeze_time
Expand Down Expand Up @@ -567,6 +568,102 @@ def test_naturaltime_timezone_when(test_input: dt.datetime, expected: str) -> No
assert humanize.naturaltime(test_input, when=NOW_UTC) == expected


@freeze_time("2026-11-01 06:30:00")
@pytest.mark.parametrize(
"value, when, expected",
[
(
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))),
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-5))),
"an hour ago",
),
(
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-5))),
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))),
"an hour from now",
),
(
dt.datetime(2026, 3, 8, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-5))),
dt.datetime(2026, 3, 8, 3, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))),
"an hour ago",
),
(
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))),
dt.datetime(2026, 11, 1, 1, 30, fold=1),
"an hour ago",
),
(
dt.datetime(2026, 11, 1, 1, 30),
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-5))),
"an hour ago",
),
(
dt.datetime(2026, 11, 1, 1, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))),
None,
"an hour ago",
),
],
)
def test_naturaltime_daylight_saving(
value: dt.datetime,
when: dt.datetime | None,
expected: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import time as system_time

if not hasattr(system_time, "tzset"):
pytest.skip("Changing the local timezone requires time.tzset")
try:
with monkeypatch.context() as timezone:
timezone.setenv("TZ", "EST5EDT,M3.2.0,M11.1.0")
system_time.tzset()
assert humanize.naturaltime(value, when=when) == expected
finally:
system_time.tzset()


@pytest.mark.parametrize(
"value, when",
[
(
dt.datetime(2026, 11, 1, 1, 30, fold=0),
dt.datetime(2026, 11, 1, 1, 30, fold=1),
),
(
dt.datetime(2026, 3, 8, 1, 30),
dt.datetime(2026, 3, 8, 3, 30),
),
],
)
def test_naturaltime_daylight_saving_same_zone(
value: dt.datetime, when: dt.datetime, monkeypatch: pytest.MonkeyPatch
) -> None:
import time as system_time

if not hasattr(system_time, "tzset"):
pytest.skip("Changing the local timezone requires time.tzset")

try:
eastern = ZoneInfo("America/New_York")
except ZoneInfoNotFoundError:
pytest.skip("IANA timezone database is unavailable")

value = value.replace(tzinfo=eastern)
when = when.replace(tzinfo=eastern)

try:
with monkeypatch.context() as timezone:
timezone.setenv("TZ", "EST5EDT,M3.2.0,M11.1.0")
system_time.tzset()
assert value.tzinfo is when.tzinfo
assert abs(value.timestamp() - when.timestamp()) == ONE_HOUR
assert humanize.naturaltime(value, when=when) == "an hour ago"
assert humanize.naturaltime(when, when=value) == "an hour from now"
finally:
system_time.tzset()


@pytest.mark.parametrize(
"val, min_unit, expected",
[
Expand Down