From 3e312bde4a43fef6a1547353c9b9e4ffc6a671c8 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 05:03:27 +0000 Subject: [PATCH 1/2] Fix naturalday/naturaldate giving wrong answer for tz-aware datetimes When a tz-aware datetime is passed, naturalday() and naturaldate() extract the date in the value's timezone but compare it with date.today() which uses system local time. This produces wrong results when the value's timezone differs from the system timezone. Fix: capture the value's tzinfo before converting to a plain date, then derive "today" via datetime.now(tzinfo).date() so both dates are in the same timezone. Fixes #152 Co-Authored-By: Claude Opus 4.6 --- src/humanize/time.py | 24 ++++++++++++++++++++--- tests/test_time.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index f0b24fa..2660c87 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -318,6 +318,10 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: """ import datetime as dt + # Capture timezone before converting to a plain date so we can + # derive "today" in the same timezone as the input value. + tzinfo = getattr(value, "tzinfo", None) + try: value = dt.date(value.year, value.month, value.day) except AttributeError: @@ -326,7 +330,12 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: except (OverflowError, ValueError): # Date arguments out of range return str(value) - delta = value - dt.date.today() + + if tzinfo is not None: + today = dt.datetime.now(tzinfo).date() + else: + today = dt.date.today() + delta = value - today if delta.days == 0: return _("today") @@ -344,16 +353,25 @@ def naturaldate(value: dt.date | dt.datetime) -> str: """Like `naturalday`, but append a year for dates more than ~five months away.""" import datetime as dt + # Capture timezone before converting so we derive "today" correctly. + tzinfo = getattr(value, "tzinfo", None) + try: - value = dt.date(value.year, value.month, value.day) + date_value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish return str(value) except (OverflowError, ValueError): # Date arguments out of range return str(value) - delta = _abs_timedelta(value - dt.date.today()) + + if tzinfo is not None: + today = dt.datetime.now(tzinfo).date() + else: + today = dt.date.today() + delta = _abs_timedelta(date_value - today) if delta.days >= 5 * 365 / 12: + # Pass original value so naturalday() can extract timezone info. return naturalday(value, "%b %d %Y") return naturalday(value) diff --git a/tests/test_time.py b/tests/test_time.py index 63e171a..d0945a5 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -300,6 +300,51 @@ def test_naturaldate(test_input: dt.date, expected: str) -> None: assert humanize.naturaldate(test_input) == expected +@freeze_time("2023-10-15 23:00:00", tz_offset=0) +def test_naturalday_tz_aware() -> None: + """Test that naturalday compares dates in the value's timezone, not system local.""" + # https://github.com/python-humanize/humanize/issues/152 + utc = dt.timezone.utc + aedt = dt.timezone(dt.timedelta(hours=11)) + edt = dt.timezone(dt.timedelta(hours=-4)) + pdt = dt.timezone(dt.timedelta(hours=-7)) + + # A moment 7 hours in the future (UTC). + future = dt.datetime(2023, 10, 16, hour=6, tzinfo=utc) + + # In UTC: now is Oct 15, future is Oct 16 → tomorrow + assert humanize.naturalday(future) == "tomorrow" + + # In AEDT (+11): now is Oct 16 10:00, future is Oct 16 17:00 → today + assert humanize.naturalday(future.astimezone(aedt)) == "today" + + # In EDT (-4): now is Oct 15 19:00, future is Oct 16 02:00 → tomorrow + assert humanize.naturalday(future.astimezone(edt)) == "tomorrow" + + # In PDT (-7): now is Oct 15 16:00, future is Oct 15 23:00 → today + assert humanize.naturalday(future.astimezone(pdt)) == "today" + + +@freeze_time("2023-10-15 23:00:00", tz_offset=0) +def test_naturaldate_tz_aware() -> None: + """Test that naturaldate compares dates in the value's timezone, not system local.""" + # https://github.com/python-humanize/humanize/issues/152 + utc = dt.timezone.utc + aedt = dt.timezone(dt.timedelta(hours=11)) + edt = dt.timezone(dt.timedelta(hours=-4)) + + future = dt.datetime(2023, 10, 16, hour=6, tzinfo=utc) + + # In UTC: tomorrow + assert humanize.naturaldate(future) == "tomorrow" + + # In AEDT (+11): today + assert humanize.naturaldate(future.astimezone(aedt)) == "today" + + # In EDT (-4): tomorrow + assert humanize.naturaldate(future.astimezone(edt)) == "tomorrow" + + @pytest.mark.parametrize( "seconds, expected", [ From f3893839d580af482178bfb6cfa5003aa8707c20 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 05:26:01 +0000 Subject: [PATCH 2/2] Fix line length in test docstring --- tests/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_time.py b/tests/test_time.py index d0945a5..f15334b 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -327,7 +327,7 @@ def test_naturalday_tz_aware() -> None: @freeze_time("2023-10-15 23:00:00", tz_offset=0) def test_naturaldate_tz_aware() -> None: - """Test that naturaldate compares dates in the value's timezone, not system local.""" + """Test naturaldate compares dates in value's timezone.""" # https://github.com/python-humanize/humanize/issues/152 utc = dt.timezone.utc aedt = dt.timezone(dt.timedelta(hours=11))