diff --git a/packages/generaltranslation/pyproject.toml b/packages/generaltranslation/pyproject.toml index 7ebd0e8..a8d31a9 100644 --- a/packages/generaltranslation/pyproject.toml +++ b/packages/generaltranslation/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "generaltranslation" -version = "0.1.0" +version = "0.2.0" description = "Core Python language toolkit for General Translation" readme = "README.md" authors = [ diff --git a/packages/generaltranslation/src/generaltranslation/formatting/__init__.py b/packages/generaltranslation/src/generaltranslation/formatting/__init__.py index d2fca63..18d6e9e 100644 --- a/packages/generaltranslation/src/generaltranslation/formatting/__init__.py +++ b/packages/generaltranslation/src/generaltranslation/formatting/__init__.py @@ -6,7 +6,11 @@ from generaltranslation.formatting._format_list import format_list, format_list_to_parts from generaltranslation.formatting._format_message import format_message from generaltranslation.formatting._format_num import format_num -from generaltranslation.formatting._format_relative_time import format_relative_time +from generaltranslation.formatting._format_relative_time import ( + format_relative_time, + format_relative_time_from_date, + select_relative_time_unit, +) __all__ = [ "format_num", @@ -15,6 +19,8 @@ "format_list", "format_list_to_parts", "format_relative_time", + "format_relative_time_from_date", + "select_relative_time_unit", "format_message", "format_cutoff", "CutoffFormat", diff --git a/packages/generaltranslation/src/generaltranslation/formatting/_format_relative_time.py b/packages/generaltranslation/src/generaltranslation/formatting/_format_relative_time.py index aff5c40..f06ac19 100644 --- a/packages/generaltranslation/src/generaltranslation/formatting/_format_relative_time.py +++ b/packages/generaltranslation/src/generaltranslation/formatting/_format_relative_time.py @@ -5,7 +5,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta, timezone from babel.dates import format_timedelta @@ -94,6 +94,70 @@ def format_relative_time( ) +def select_relative_time_unit(date: "datetime") -> tuple[int, str]: + """Select the best unit and compute the value for relative time formatting. + + Mirrors ``_selectRelativeTimeUnit`` from the JS core library. + + Args: + date: A :class:`~datetime.datetime` (timezone-aware or naive). + + Returns: + A ``(value, unit)`` tuple where *value* is signed (negative = past) + and *unit* is one of ``"second"``, ``"minute"``, ``"hour"``, + ``"day"``, ``"week"``, ``"month"``, ``"year"``. + """ + now = datetime.now(timezone.utc) + if date.tzinfo is None: + date = date.replace(tzinfo=timezone.utc) + diff_ms = (date - now).total_seconds() * 1000 + abs_diff_ms = abs(diff_ms) + sign = -1 if diff_ms < 0 else 1 + + seconds = int(abs_diff_ms // 1000) + minutes = int(abs_diff_ms // (1000 * 60)) + hours = int(abs_diff_ms // (1000 * 60 * 60)) + days = int(abs_diff_ms // (1000 * 60 * 60 * 24)) + weeks = int(abs_diff_ms // (1000 * 60 * 60 * 24 * 7)) + months = int(abs_diff_ms // (1000 * 60 * 60 * 24 * 30)) + years = int(abs_diff_ms // (1000 * 60 * 60 * 24 * 365)) + + if seconds < 60: + return (sign * seconds, "second") + if minutes < 60: + return (sign * minutes, "minute") + if hours < 24: + return (sign * hours, "hour") + if days < 7: + return (sign * days, "day") + if days < 28: + return (sign * weeks, "week") + if months < 12: + return (sign * months, "month") + return (sign * years, "year") + + +def format_relative_time_from_date( + date: "datetime", + locales: str | list[str] | None = None, + options: dict | None = None, +) -> str: + """Format a relative time string from a datetime, auto-selecting the best unit. + + Mirrors ``_formatRelativeTimeFromDate`` from the JS core library. + + Args: + date: A :class:`~datetime.datetime` (timezone-aware or naive). + locales: BCP 47 locale tag(s). Defaults to ``"en"``. + options: Formatting options passed to :func:`format_relative_time`. + + Returns: + The formatted relative time string (e.g. ``"3 days ago"``). + """ + value, unit = select_relative_time_unit(date) + return format_relative_time(value, unit, locales=locales, options=options) + + def _singular_unit(unit: str) -> str: """Normalize unit to singular form for Babel granularity param.""" singular_map = { diff --git a/packages/generaltranslation/tests/formatting/test_format_relative_time_from_date.py b/packages/generaltranslation/tests/formatting/test_format_relative_time_from_date.py new file mode 100644 index 0000000..213b879 --- /dev/null +++ b/packages/generaltranslation/tests/formatting/test_format_relative_time_from_date.py @@ -0,0 +1,94 @@ +"""Tests for select_relative_time_unit and format_relative_time_from_date.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +from generaltranslation.formatting import ( + format_relative_time_from_date, + select_relative_time_unit, +) + +FROZEN_NOW = datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc) + + +def _make_date(delta: timedelta) -> datetime: + return FROZEN_NOW + delta + + +@pytest.fixture(autouse=True) +def _freeze_now(): + """Patch datetime.now in the module under test to return FROZEN_NOW.""" + target = "generaltranslation.formatting._format_relative_time.datetime" + with patch(target, wraps=datetime) as mock_dt: + mock_dt.now.return_value = FROZEN_NOW + yield + + +# --- select_relative_time_unit --- + +@pytest.mark.parametrize( + "delta, expected_unit, expected_value", + [ + (timedelta(seconds=-30), "second", -30), + (timedelta(seconds=45), "second", 45), + (timedelta(minutes=-5), "minute", -5), + (timedelta(minutes=30), "minute", 30), + (timedelta(hours=-3), "hour", -3), + (timedelta(hours=10), "hour", 10), + (timedelta(days=-3), "day", -3), + (timedelta(days=5), "day", 5), + (timedelta(days=-14), "week", -2), + (timedelta(days=21), "week", 3), + (timedelta(days=-90), "month", -3), + (timedelta(days=180), "month", 6), + (timedelta(days=-400), "year", -1), + (timedelta(days=730), "year", 2), + ], +) +def test_select_relative_time_unit(delta, expected_unit, expected_value): + value, unit = select_relative_time_unit(_make_date(delta)) + assert unit == expected_unit + assert value == expected_value + + +def test_naive_datetime_treated_as_utc(): + naive_date = FROZEN_NOW.replace(tzinfo=None) - timedelta(hours=2) + value, unit = select_relative_time_unit(naive_date) + assert unit == "hour" + assert value == -2 + + +# --- format_relative_time_from_date --- + +def test_format_english_past(): + result = format_relative_time_from_date( + _make_date(timedelta(hours=-3)), locales="en" + ) + assert "3 hours ago" in result + + +def test_format_english_future(): + result = format_relative_time_from_date( + _make_date(timedelta(days=2)), locales="en" + ) + assert "2 days" in result + + +def test_format_spanish(): + result = format_relative_time_from_date( + _make_date(timedelta(minutes=-10)), locales="es" + ) + assert "10" in result + + +def test_format_short_style(): + result = format_relative_time_from_date( + _make_date(timedelta(days=-3)), + locales="en", + options={"style": "short"}, + ) + assert result # doesn't crash diff --git a/uv.lock b/uv.lock index 0b33b93..d45e2f3 100644 --- a/uv.lock +++ b/uv.lock @@ -226,7 +226,7 @@ requires-dist = [ [[package]] name = "generaltranslation" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/generaltranslation" } dependencies = [ { name = "babel" },