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:
@@ -528,9 +528,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
dynamic = Config._app.period_dynamic if Config._app else False
|
||||
|
||||
if precision == "datetime":
|
||||
from datetime import datetime as dt_mod
|
||||
|
||||
now = dt_mod.now().isoformat(timespec="seconds")
|
||||
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
|
||||
# метки aware cutoff для дедупликации.
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
from_str = now
|
||||
to_str = now
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
@@ -150,20 +150,28 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
|
||||
Accepts datetime objects, ISO strings (with or without timezone),
|
||||
or None. Returns a timezone-aware datetime or None.
|
||||
Naive datetimes are treated as UTC (#58).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return _ensure_aware_utc(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return dt
|
||||
return _ensure_aware_utc(dt)
|
||||
except (ValueError, TypeError):
|
||||
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]:
|
||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||
all_issues: List[Issue] = []
|
||||
@@ -277,20 +285,29 @@ def fetch_issues_with_spent_time(
|
||||
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
|
||||
# Записи без метаданных (created_on/updated_on == 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 = []
|
||||
for entry in time_entries:
|
||||
created = _parse_datetime(getattr(entry, "created_on", None))
|
||||
updated = _parse_datetime(getattr(entry, "updated_on", None))
|
||||
try:
|
||||
for entry in time_entries:
|
||||
created = _parse_datetime(getattr(entry, "created_on", None))
|
||||
updated = _parse_datetime(getattr(entry, "updated_on", 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:
|
||||
if created is None and updated is None:
|
||||
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)
|
||||
elif created is not None and updated is not None:
|
||||
if created >= dedup_before and updated >= dedup_before:
|
||||
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
|
||||
|
||||
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||
|
||||
@@ -656,6 +656,49 @@ class TestCommitFlag:
|
||||
assert "T" in saved_from
|
||||
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("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
||||
|
||||
@@ -745,11 +745,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||
# -- #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("redmine_reporter.client.Redmine")
|
||||
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 [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]
|
||||
|
||||
Reference in New Issue
Block a user