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
2 changes: 1 addition & 1 deletion packages/generaltranslation/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from datetime import timedelta
from datetime import datetime, timedelta, timezone

from babel.dates import format_timedelta

Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading