fix: normalize datetimes to aware UTC in dedup

Dedup with precision=datetime crashed with TypeError: redminelib returns
naive created_on/updated_on while the cutoff from _compute_dedup_cutoff
is aware UTC. Normalize at a single point: _parse_datetime now always
returns an aware datetime (naive treated as UTC), matching its docstring.
The dedup cutoff is normalized the same way, and any residual comparison
TypeError is wrapped in RedmineAPIError instead of leaking raw.

Also fix --commit saving last_used.to as naive local time; it now stores
aware UTC (datetime.now(timezone.utc)) so the next run computes a correct
aware cutoff.

Closes #58
This commit is contained in:
Кокос Артем Николаевич
2026-07-17 12:11:35 +07:00
parent c4ec23048a
commit 46674ba926
4 changed files with 164 additions and 22 deletions

View File

@@ -528,9 +528,9 @@ def main(argv: Optional[List[str]] = None) -> int:
dynamic = Config._app.period_dynamic if Config._app else False dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime": if precision == "datetime":
from datetime import datetime as dt_mod # Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = dt_mod.now().isoformat(timespec="seconds") now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now from_str = now
to_str = now to_str = now
else: else:

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
@@ -150,20 +150,28 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
Accepts datetime objects, ISO strings (with or without timezone), Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None. or None. Returns a timezone-aware datetime or None.
Naive datetimes are treated as UTC (#58).
""" """
if value is None: if value is None:
return None return None
if isinstance(value, datetime): if isinstance(value, datetime):
return value return _ensure_aware_utc(value)
if isinstance(value, str): if isinstance(value, str):
try: try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00")) dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt return _ensure_aware_utc(dt)
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
return None return None
def _ensure_aware_utc(dt: datetime) -> datetime:
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: List[Issue] = []
@@ -277,20 +285,29 @@ def fetch_issues_with_spent_time(
# Запись исключается, если BOTH created_on AND updated_on < dedup_before. # Запись исключается, если BOTH created_on AND updated_on < dedup_before.
# Записи без метаданных (created_on/updated_on == None) не фильтруются. # Записи без метаданных (created_on/updated_on == None) не фильтруются.
if dedup_before is not None: if dedup_before is not None:
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
dedup_before = _ensure_aware_utc(dedup_before)
filtered: list = [] filtered: list = []
for entry in time_entries: try:
created = _parse_datetime(getattr(entry, "created_on", None)) for entry in time_entries:
updated = _parse_datetime(getattr(entry, "updated_on", None)) created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
if created is None and updated is None: if created is None and updated is None:
filtered.append(entry)
elif created is not None and updated is not None:
if created >= dedup_before and updated >= dedup_before:
filtered.append(entry) filtered.append(entry)
elif created is not None and created >= dedup_before: elif created is not None and updated is not None:
filtered.append(entry) if created >= dedup_before and updated >= dedup_before:
elif updated is not None and updated >= dedup_before: filtered.append(entry)
filtered.append(entry) elif created is not None and created >= dedup_before:
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
except TypeError as exc:
raise RedmineAPIError(
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
original=exc,
) from exc
time_entries = filtered time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется) # Агрегируем часы по issue.id (и активности, если требуется)

View File

@@ -656,6 +656,49 @@ class TestCommitFlag:
assert "T" in saved_from assert "T" in saved_from
assert "T" in saved_to assert "T" in saved_to
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_saves_aware_utc(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
from datetime import datetime, timezone
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to):
parsed = datetime.fromisoformat(saved)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path): def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):

View File

@@ -745,11 +745,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
# -- #58: дедупликация при precision=datetime с naive created_on -- # -- #58: дедупликация при precision=datetime с naive created_on --
@pytest.mark.xfail(
strict=True,
reason="bug #58: naive created_on vs aware dedup cutoff raises TypeError; "
"fix must normalize entry datetimes to aware (UTC)",
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class): def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
@@ -801,3 +796,90 @@ def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
assert result is not None assert result is not None
assert [r[0].id for r in result] == [2] assert [r[0].id for r in result] == [2]
# -- #58: нормализация datetime к aware UTC --
class TestParseDatetimeNormalization:
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
def test_naive_datetime_treated_as_utc(self):
from datetime import datetime, timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_aware_datetime_kept(self):
from datetime import datetime, timedelta, timezone
from redmine_reporter.client import _parse_datetime
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
assert _parse_datetime(aware) is aware
def test_naive_iso_string_treated_as_utc(self):
from datetime import timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00")
assert result is not None
assert result.tzinfo == timezone.utc
def test_aware_iso_string_keeps_offset(self):
from datetime import timedelta
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00+03:00")
assert result is not None
assert result.utcoffset() == timedelta(hours=3)
def test_none_and_garbage_return_none(self):
from redmine_reporter.client import _parse_datetime
assert _parse_datetime(None) is None
assert _parse_datetime("not a date") is None
assert _parse_datetime(123) is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
from datetime import datetime
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]