From f23221420fea7df82d9ca532cf6b16a4868b1136 Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 30 Jul 2026 11:54:59 +0300 Subject: [PATCH] fix(skills): replace deprecated utcnow in skill timestamp helper (#5777) * fix(skills): replace deprecated utcnow in skill timestamp helper _now_iso() builds the 'created' value in skill frontmatter. datetime.utcnow() returns a naive datetime and has been deprecated since Python 3.12, scheduled for removal. Switch to the timezone-aware datetime.now(timezone.utc), keeping the serialized YYYY-MM-DDTHH:MM:SSZ shape unchanged so existing skill files keep parsing. timezone.utc is used rather than the datetime.UTC alias, which is 3.11+ only. Adds regression tests covering the deprecation, the serialized shape, and UTC correctness under a non-UTC local timezone -- the last guards against a bare datetime.now(), which yields the same shape but local wall time. Fixes #5697 * test(skills): skip timezone mutation where unsupported --------- Co-authored-by: Alexandre Teixeira --- services/memory/skill_format.py | 4 +- tests/test_skill_format_timestamp.py | 58 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/test_skill_format_timestamp.py diff --git a/services/memory/skill_format.py b/services/memory/skill_format.py index 2b2dfb1b3..628474b04 100644 --- a/services/memory/skill_format.py +++ b/services/memory/skill_format.py @@ -50,7 +50,7 @@ import json import logging import re from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -441,4 +441,4 @@ class Skill: def _now_iso() -> str: - return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/tests/test_skill_format_timestamp.py b/tests/test_skill_format_timestamp.py new file mode 100644 index 000000000..a9309bdc1 --- /dev/null +++ b/tests/test_skill_format_timestamp.py @@ -0,0 +1,58 @@ +"""Regression for issue #5697 — skill timestamps must not use ``datetime.utcnow()``. + +``_now_iso()`` builds the ``created`` value in skill frontmatter. ``utcnow()`` +returns a *naive* datetime and has been deprecated since Python 3.12, scheduled +for removal. The replacement must stay timezone-aware while keeping the +serialized ``YYYY-MM-DDTHH:MM:SSZ`` shape, so skill files written by older +versions keep parsing. + +The UTC check matters on its own: a bare ``datetime.now()`` also produces the +right shape, but emits local wall time, which would silently backdate or +postdate skills for every user outside UTC. +""" + +import os +import re +import time +import warnings +from datetime import datetime, timezone + +import pytest + +from services.memory.skill_format import _now_iso + +_ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + + +def test_now_iso_keeps_serialized_shape(): + assert _ISO_Z.match(_now_iso()) + + +def test_now_iso_emits_no_deprecation_warning(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _now_iso() + assert not [w for w in caught if issubclass(w.category, DeprecationWarning)] + + +@pytest.mark.skipif( + not hasattr(time, "tzset"), + reason="time.tzset is unavailable on this platform", +) +def test_now_iso_is_utc_not_local_time(): + """Pin UTC under a non-UTC local timezone, where the two visibly diverge.""" + original_tz = os.environ.get("TZ") + os.environ["TZ"] = "Asia/Amman" # UTC+3, never UTC + time.tzset() + try: + emitted = datetime.strptime(_now_iso(), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + drift = abs((emitted - datetime.now(timezone.utc)).total_seconds()) + assert drift < 60, f"timestamp is {drift}s off UTC — local time leaked in" + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset()