From 4f03f5ccdd4de807a56d1787f087c2d5971bcad8 Mon Sep 17 00:00:00 2001 From: Marius Oppedal Ringsby <163775282+mringsby@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:14:27 +0200 Subject: [PATCH] Replace cleanup service datetime.utcnow calls (#1494) datetime.utcnow() is deprecated in Python 3.12 and removed in 3.14. Swap the five calls in src/cleanup_service.py for a local _utcnow() helper returning naive UTC, matching the naive DateTime columns the archive/delete cutoffs compare against (same approach as the task-scheduler and core-database slices). Add a regression test asserting the helper stays naive so the cutoff math can't hit a naive/aware TypeError. Part of #1116 --- src/cleanup_service.py | 22 ++++++++++++++++------ tests/test_cleanup_service_utcnow.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 tests/test_cleanup_service_utcnow.py diff --git a/src/cleanup_service.py b/src/cleanup_service.py index 95c7cb5..ec1503d 100644 --- a/src/cleanup_service.py +++ b/src/cleanup_service.py @@ -1,10 +1,20 @@ # src/cleanup_service.py import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Tuple, Dict, Any, Optional logger = logging.getLogger(__name__) + +def _utcnow() -> datetime: + """Naive UTC for this module's DB-bound timestamps. + + Mirrors the naive DateTime columns these values are compared against, + without the deprecated stdlib UTC-now call (removed in Python 3.14). + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + class CleanupConfig: """Configuration constants for cleanup operations.""" ARCHIVE_AFTER_DAYS = 7 @@ -38,7 +48,7 @@ async def archive_inactive_sessions(session_manager, owner: Optional[str] = None Returns: Number of sessions archived """ - cutoff_date = datetime.utcnow() - timedelta(days=CleanupConfig.ARCHIVE_AFTER_DAYS) + cutoff_date = _utcnow() - timedelta(days=CleanupConfig.ARCHIVE_AFTER_DAYS) archived_count = 0 from src.database import SessionLocal, Session as DbSession @@ -53,7 +63,7 @@ async def archive_inactive_sessions(session_manager, owner: Optional[str] = None for session in sessions_to_archive: session.archived = True - session.updated_at = datetime.utcnow() + session.updated_at = _utcnow() archived_count += 1 if archived_count > 0: @@ -79,7 +89,7 @@ async def cleanup_old_sessions(session_manager, owner: Optional[str] = None) -> Returns: Tuple of (number of sessions deleted, space freed in MB) """ - cutoff_date = datetime.utcnow() - timedelta(days=CleanupConfig.DELETE_AFTER_DAYS) + cutoff_date = _utcnow() - timedelta(days=CleanupConfig.DELETE_AFTER_DAYS) deleted_count = 0 space_freed = 0 @@ -158,8 +168,8 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]: Returns: Dictionary containing preview information """ - cutoff_archive = datetime.utcnow() - timedelta(days=CleanupConfig.ARCHIVE_AFTER_DAYS) - cutoff_delete = datetime.utcnow() - timedelta(days=CleanupConfig.DELETE_AFTER_DAYS) + cutoff_archive = _utcnow() - timedelta(days=CleanupConfig.ARCHIVE_AFTER_DAYS) + cutoff_delete = _utcnow() - timedelta(days=CleanupConfig.DELETE_AFTER_DAYS) sessions_to_archive = [] sessions_to_delete = [] diff --git a/tests/test_cleanup_service_utcnow.py b/tests/test_cleanup_service_utcnow.py new file mode 100644 index 0000000..a4e2381 --- /dev/null +++ b/tests/test_cleanup_service_utcnow.py @@ -0,0 +1,25 @@ +"""Regression tests for the datetime.utcnow() removal in src/cleanup_service.py (#1116). + +Importing src.cleanup_service is cheap and dependency-free: its only module-level +imports are logging/datetime/typing, and the `from src.database import ...` calls are +lazy (inside the functions), so no DB/sqlalchemy stack is pulled in here. +""" +from datetime import datetime, timedelta, timezone + +from src.cleanup_service import _utcnow + + +def test_utcnow_returns_naive_utc(): + now = _utcnow() + # Must be naive to match the naive DateTime columns this module compares against. + assert now.tzinfo is None + ref = datetime.now(timezone.utc).replace(tzinfo=None) + assert abs((ref - now).total_seconds()) < 5 + + +def test_cutoff_math_stays_naive_and_comparable(): + # Guards the archive/delete cutoffs against a naive/aware TypeError regression: + # an aware _utcnow() would raise when compared with the naive last_accessed column. + cutoff = _utcnow() - timedelta(days=7) + assert cutoff.tzinfo is None + assert cutoff < _utcnow()