From 79ba499c4d7deec305dba3b0fae23de2a583a447 Mon Sep 17 00:00:00 2001 From: Krushi Vaghani Date: Thu, 22 Oct 2026 20:40:12 -0300 Subject: [PATCH 1/3] feat: add numeric singular option to humanize --- arrow/arrow.py | 5 +++ arrow/locales.py | 15 +++++++ tests/test_numeric_humanize.py | 76 ++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/test_numeric_humanize.py diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..c456c7475 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1136,6 +1136,7 @@ def humanize( locale: str = DEFAULT_LOCALE, only_distance: bool = False, granularity: Union[_GRANULARITY, List[_GRANULARITY]] = "auto", + numeric: bool = False, ) -> str: """Returns a localized, humanized representation of a relative difference in time. @@ -1145,6 +1146,8 @@ def humanize( :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part. :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year' or a list of any combination of these strings + :param numeric: (optional) use numeric singular time units for English locales, + for example, '1 hour' instead of 'an hour'. Defaults to False. Usage:: @@ -1160,6 +1163,8 @@ def humanize( locale_name = locale locale = locales.get_locale(locale) + if numeric and isinstance(locale, locales.EnglishLocale): + locale.numeric = True if other is None: utc = dt_datetime.now(timezone.utc).replace(tzinfo=timezone.utc) diff --git a/arrow/locales.py b/arrow/locales.py index 757df480d..7f266b6e7 100644 --- a/arrow/locales.py +++ b/arrow/locales.py @@ -303,6 +303,21 @@ class EnglishLocale(Locale): past = "{0} ago" future = "in {0}" and_word = "and" + numeric: bool = False + + def _format_timeframe(self, timeframe: TimeFrameLiteral, delta: int) -> str: + if self.numeric and timeframe in ( + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year", + ): + return f"1 {timeframe}" + return super()._format_timeframe(timeframe, delta) timeframes = { "now": "just now", diff --git a/tests/test_numeric_humanize.py b/tests/test_numeric_humanize.py new file mode 100644 index 000000000..40e66460f --- /dev/null +++ b/tests/test_numeric_humanize.py @@ -0,0 +1,76 @@ +"""Regression tests for numeric singular output in Arrow.humanize().""" + +import pytest + +import arrow + + +@pytest.fixture +def reference(): + return arrow.get(2024, 1, 10, 12, 0, 0) + + +def test_humanize_numeric_singular_past(reference): + past = reference.shift(hours=-1) + assert past.humanize(reference, numeric=True) == "1 hour ago" + assert past.humanize(reference) == "an hour ago" + + +def test_humanize_numeric_singular_future(reference): + future = reference.shift(minutes=1) + assert future.humanize(reference, numeric=True) == "in 1 minute" + assert future.humanize(reference, only_distance=True, numeric=True) == "1 minute" + + +@pytest.mark.parametrize( + "shift, granularity, expected", + [ + ({"seconds": 2}, "second", "in 2 seconds"), + ({"minutes": 1}, "minute", "in 1 minute"), + ({"hours": 1}, "hour", "in 1 hour"), + ({"days": 1}, "day", "in 1 day"), + ({"weeks": 1}, "week", "in 1 week"), + ({"months": 1}, "month", "in 1 month"), + ({"days": 92}, "quarter", "in 1 quarter"), + ({"years": 1}, "year", "in 1 year"), + ], +) +def test_humanize_numeric_explicit_granularity(reference, shift, granularity, expected): + future = reference.shift(**shift) + assert future.humanize(reference, granularity=granularity, numeric=True) == expected + + +def test_humanize_numeric_multi_granularity(reference): + future = reference.shift(hours=1, minutes=6) + assert ( + future.humanize(reference, granularity=["hour", "minute"], numeric=True) + == "in 1 hour and 6 minutes" + ) + assert ( + future.humanize(reference, granularity=["hour", "minute"]) + == "in an hour and 6 minutes" + ) + + +def test_humanize_numeric_multi_with_two_singular_values(reference): + future = reference.shift(hours=1, minutes=1) + assert ( + future.humanize(reference, granularity=["hour", "minute"], numeric=True) + == "in 1 hour and 1 minute" + ) + + +def test_humanize_numeric_english_variant(reference): + future = reference.shift(hours=1) + assert future.humanize(reference, locale="en-gb", numeric=True) == "in 1 hour" + + +def test_humanize_numeric_other_locales_unchanged(reference): + future = reference.shift(hours=1) + assert future.humanize(reference, locale="fr", numeric=True) == future.humanize( + reference, locale="fr" + ) + + +def test_humanize_numeric_now_unchanged(reference): + assert reference.humanize(reference, numeric=True) == "just now" From 38e282b2f5899d0cfa9d6ea87b86c165b5cb1be5 Mon Sep 17 00:00:00 2001 From: Krushi Vaghani Date: Thu, 22 Oct 2026 21:16:58 -0300 Subject: [PATCH 2/3] ci: use Ubuntu 24.04 for Python compatibility --- .github/workflows/continuous_integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 3b9c38048..5808a81bd 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -16,9 +16,9 @@ jobs: fail-fast: false matrix: python-version: ["pypy-3.11", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-24.04, macos-latest, windows-latest] include: - - os: ubuntu-latest + - os: ubuntu-24.04 path: ~/.cache/pip - os: macos-latest path: ~/Library/Caches/pip From 11a8c82786d0ef49608c24e02c45dfccf342dc8e Mon Sep 17 00:00:00 2001 From: Krushi Vaghani Date: Thu, 22 Oct 2026 21:26:04 -0300 Subject: [PATCH 3/3] ci: preserve required check names for legacy Python --- .github/workflows/continuous_integration.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 5808a81bd..84e990c17 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -11,14 +11,14 @@ on: jobs: unit-tests: name: ${{ matrix.os }} (${{ matrix.python-version }}) - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.os == 'ubuntu-latest' && (matrix.python-version == '3.8' || matrix.python-version == '3.9') && 'ubuntu-24.04' || matrix.os }} strategy: fail-fast: false matrix: python-version: ["pypy-3.11", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - os: [ubuntu-24.04, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest] include: - - os: ubuntu-24.04 + - os: ubuntu-latest path: ~/.cache/pip - os: macos-latest path: ~/Library/Caches/pip