From 4cd57c1c17f764f3dcc3cf7f5c125ef370e3f008 Mon Sep 17 00:00:00 2001 From: 72004 Date: Wed, 21 Oct 2026 01:14:33 +0500 Subject: [PATCH] Use calendar months for humanize month and quarter granularity granularity="month" and "quarter" divided the elapsed seconds by a fixed 30.5-day month, so whole calendar months were under-counted: Jan 1 -> Mar 1 was "in a month" and exactly 3 months was "in 0 quarters". Count calendar months with relativedelta, as the auto granularity does. --- arrow/arrow.py | 23 +++++++++++++++++++---- tests/test_arrow.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..2067e47c4 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1276,10 +1276,25 @@ def humanize( delta = sign * delta_second / self._SECS_PER_DAY elif granularity == "week": delta = sign * delta_second / self._SECS_PER_WEEK - elif granularity == "month": - delta = sign * delta_second / self._SECS_PER_MONTH - elif granularity == "quarter": - delta = sign * delta_second / self._SECS_PER_QUARTER + elif granularity in ("month", "quarter"): + # Count calendar months, as the "auto" granularity does, so + # that e.g. Jan 1 to Mar 1 is 2 months rather than 59 / 30.5. + # The partial month is a fraction of the next calendar month, + # which keeps it below 1 and keeps the sign of a 0 delta. + start, end = sorted((self._datetime, dt)) + calendar_diff = relativedelta(end, start) + calendar_months = ( + calendar_diff.years * self._MONTHS_PER_YEAR + + calendar_diff.months + ) + month_start = start + relativedelta(months=calendar_months) + month_end = start + relativedelta(months=calendar_months + 1) + elapsed_months = calendar_months + (end - month_start) / ( + month_end - month_start + ) + if granularity == "quarter": + elapsed_months /= self._MONTHS_PER_QUARTER + delta = sign * elapsed_months elif granularity == "year": delta = sign * delta_second / self._SECS_PER_YEAR else: diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e21..686c95622 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -1991,8 +1991,9 @@ def test_granularity(self): later506 = self.now.shift(seconds=50 * 10**6) assert self.now.humanize(later506, granularity="week") == "82 weeks ago" assert later506.humanize(self.now, granularity="week") == "in 82 weeks" - assert self.now.humanize(later506, granularity="month") == "18 months ago" - assert later506.humanize(self.now, granularity="month") == "in 18 months" + # 2013-01-01 to 2014-08-02 is 19 calendar months + assert self.now.humanize(later506, granularity="month") == "19 months ago" + assert later506.humanize(self.now, granularity="month") == "in 19 months" assert self.now.humanize(later506, granularity="quarter") == "6 quarters ago" assert later506.humanize(self.now, granularity="quarter") == "in 6 quarters" assert self.now.humanize(later506, granularity="year") == "a year ago" @@ -2037,6 +2038,32 @@ def test_granularity(self): with pytest.raises(ValueError): self.now.humanize(later108, granularity="years") + def test_granularity_calendar_months(self): + start = arrow.Arrow(2023, 1, 31) + + for months, expected in [(1, "a month"), (2, "2 months"), (6, "6 months")]: + end = start.shift(months=months) + assert end.humanize(start, granularity="month") == f"in {expected}" + assert start.humanize(end, granularity="month") == f"{expected} ago" + + for months, expected in [ + (3, "a quarter"), + (6, "2 quarters"), + (18, "6 quarters"), + ]: + end = start.shift(months=months) + assert end.humanize(start, granularity="quarter") == f"in {expected}" + assert start.humanize(end, granularity="quarter") == f"{expected} ago" + + # Less than a calendar month, even though it is more than 30.5 days + end = arrow.Arrow(2023, 1, 31, 23, 59) + assert end.humanize(arrow.Arrow(2023, 1, 1), granularity="month") == ( + "in 0 months" + ) + assert arrow.Arrow(2023, 1, 1).humanize(end, granularity="month") == ( + "0 months ago" + ) + def test_multiple_granularity(self): assert self.now.humanize(granularity="second") == "just now" assert self.now.humanize(granularity=["second"]) == "just now"