fix: Ensure all datetime operations return timezone-aware objects (#268)

Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jope-bm
2025-08-22 13:43:55 -06:00
committed by GitHub
parent f3d8d8d617
commit 2cd2a62f30
14 changed files with 89 additions and 41 deletions
@@ -188,7 +188,7 @@ async def write_resource(
"content_type": content_type,
"file_path": file_path,
"checksum": checksum,
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
},
)
status_code = 200
@@ -200,8 +200,8 @@ async def write_resource(
content_type=content_type,
file_path=file_path,
checksum=checksum,
created_at=datetime.fromtimestamp(file_stats.st_ctime),
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
)
entity = await entity_repository.add(entity)
status_code = 201
@@ -93,7 +93,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
break
# Generate permalink
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
clean_title = clean_filename(conversation["title"])
# Format content
+2 -2
View File
@@ -43,13 +43,13 @@ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
except ValueError:
try:
# Try unix timestamp as string
timestamp = datetime.fromtimestamp(float(timestamp))
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
except ValueError:
# Return as is if we can't parse it
return timestamp
elif isinstance(timestamp, (int, float)):
# Unix timestamp
timestamp = datetime.fromtimestamp(timestamp)
timestamp = datetime.fromtimestamp(timestamp).astimezone()
if isinstance(timestamp, datetime):
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
+2 -2
View File
@@ -130,6 +130,6 @@ class EntityParser:
content=post.content,
observations=entity_content.observations,
relations=entity_content.relations,
created=datetime.fromtimestamp(file_stats.st_ctime),
modified=datetime.fromtimestamp(file_stats.st_mtime),
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Build context tool for Basic Memory MCP server."""
from typing import Optional, Union
from typing import Optional
from loguru import logger
@@ -111,7 +111,7 @@ async def build_context(
metadata=MemoryMetadata(
depth=depth or 1,
timeframe=timeframe,
generated_at=datetime.now(),
generated_at=datetime.now().astimezone(),
primary_count=0,
related_count=0,
uri=migration_status, # Include status in metadata
+14 -3
View File
@@ -1,6 +1,7 @@
"""Knowledge graph models."""
from datetime import datetime
from datetime import datetime, timezone
from basic_memory.utils import ensure_timezone_aware
from typing import Optional
from sqlalchemy import (
@@ -73,8 +74,8 @@ class Entity(Base):
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Metadata and tracking
created_at: Mapped[datetime] = mapped_column(DateTime)
updated_at: Mapped[datetime] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
# Relationships
project = relationship("Project", back_populates="entities")
@@ -103,6 +104,16 @@ class Entity(Base):
def is_markdown(self):
"""Check if the entity is a markdown file."""
return self.content_type == "text/markdown"
def __getattribute__(self, name):
"""Override attribute access to ensure datetime fields are timezone-aware."""
value = super().__getattribute__(name)
# Ensure datetime fields are timezone-aware
if name in ('created_at', 'updated_at') and isinstance(value, datetime):
return ensure_timezone_aware(value)
return value
def __repr__(self) -> str:
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
+2 -2
View File
@@ -52,9 +52,9 @@ class Project(Base):
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
# Define relationships to entities, observations, and relations
+15 -9
View File
@@ -13,7 +13,7 @@ Key Concepts:
import mimetypes
import re
from datetime import datetime, time
from datetime import datetime, time, timezone
from pathlib import Path
from typing import List, Optional, Annotated, Dict
@@ -53,22 +53,28 @@ def parse_timeframe(timeframe: str) -> datetime:
timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
Returns:
datetime: The parsed datetime for the start of the timeframe
datetime: The parsed datetime for the start of the timeframe, timezone-aware in local system timezone
Examples:
parse_timeframe('today') -> 2025-06-05 00:00:00 (start of today)
parse_timeframe('1d') -> 2025-06-04 14:50:00 (24 hours ago)
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00 (1 week ago)
parse_timeframe('today') -> 2025-06-05 00:00:00-07:00 (start of today with local timezone)
parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
"""
if timeframe.lower() == "today":
# Return start of today (00:00:00)
return datetime.combine(datetime.now().date(), time.min)
# Return start of today (00:00:00) in local timezone
naive_dt = datetime.combine(datetime.now().date(), time.min)
return naive_dt.astimezone()
else:
# Use dateparser for other formats
parsed = parse(timeframe)
if not parsed:
raise ValueError(f"Could not parse timeframe: {timeframe}")
return parsed
# If the parsed datetime is naive, make it timezone-aware in local system timezone
if parsed.tzinfo is None:
return parsed.astimezone()
else:
return parsed
def validate_timeframe(timeframe: str) -> str:
@@ -85,7 +91,7 @@ def validate_timeframe(timeframe: str) -> str:
parsed = parse_timeframe(timeframe)
# Convert to duration
now = datetime.now()
now = datetime.now().astimezone()
if parsed > now:
raise ValueError("Timeframe cannot be in the future")
+2 -2
View File
@@ -357,8 +357,8 @@ class SyncService:
# get file timestamps
file_stats = self.file_service.file_stats(path)
created = datetime.fromtimestamp(file_stats.st_ctime)
modified = datetime.fromtimestamp(file_stats.st_mtime)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
# get mime type
content_type = self.file_service.content_type(path)
+22 -1
View File
@@ -5,6 +5,7 @@ import os
import logging
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Protocol, Union, runtime_checkable, List
@@ -318,4 +319,24 @@ def validate_project_path(path: str, project_path: Path) -> bool:
resolved = (project_path / path).resolve()
return resolved.is_relative_to(project_path.resolve())
except (ValueError, OSError):
return False
return False
def ensure_timezone_aware(dt: datetime) -> datetime:
"""Ensure a datetime is timezone-aware using system timezone.
If the datetime is naive, convert it to timezone-aware using the system's local timezone.
If it's already timezone-aware, return it unchanged.
Args:
dt: The datetime to ensure is timezone-aware
Returns:
A timezone-aware datetime
"""
if dt.tzinfo is None:
# Naive datetime - assume it's in local time and add timezone
return dt.astimezone()
else:
# Already timezone-aware
return dt
+1 -1
View File
@@ -309,7 +309,7 @@ def test_build_context_with_options(cli_env, setup_test_note):
# Check that metadata reflects our options
assert context_result["metadata"]["depth"] == 2
timeframe = datetime.fromisoformat(context_result["metadata"]["timeframe"])
assert datetime.now() - timeframe <= timedelta(days=2) # don't bother about timezones
assert datetime.now().astimezone() - timeframe <= timedelta(days=2) # Compare timezone-aware datetimes
# Results should include our test note
found = False
+10 -6
View File
@@ -1,7 +1,7 @@
"""Tests for Pydantic schema validation and conversion."""
import pytest
from datetime import datetime, time, timedelta
from datetime import datetime, time, timedelta, timezone
from pydantic import ValidationError, BaseModel
from basic_memory.schemas import (
@@ -315,20 +315,21 @@ class TestTimeframeParsing:
"""Test cases for parse_timeframe() and validate_timeframe() functions."""
def test_parse_timeframe_today(self):
"""Test that parse_timeframe('today') returns start of current day."""
"""Test that parse_timeframe('today') returns start of current day with timezone."""
result = parse_timeframe("today")
expected = datetime.combine(datetime.now().date(), time.min)
expected = datetime.combine(datetime.now().date(), time.min).astimezone()
assert result == expected
assert result.hour == 0
assert result.minute == 0
assert result.second == 0
assert result.microsecond == 0
assert result.tzinfo is not None
def test_parse_timeframe_today_case_insensitive(self):
"""Test that parse_timeframe handles 'today' case-insensitively."""
test_cases = ["today", "TODAY", "Today", "ToDay"]
expected = datetime.combine(datetime.now().date(), time.min)
expected = datetime.combine(datetime.now().date(), time.min).astimezone()
for case in test_cases:
result = parse_timeframe(case)
@@ -336,24 +337,27 @@ class TestTimeframeParsing:
def test_parse_timeframe_other_formats(self):
"""Test that parse_timeframe works with other dateparser formats."""
now = datetime.now()
now = datetime.now().astimezone()
# Test 1d ago - should be approximately 24 hours ago
result_1d = parse_timeframe("1d")
expected_1d = now - timedelta(days=1)
diff = abs((result_1d - expected_1d).total_seconds())
assert diff < 60 # Within 1 minute tolerance
assert result_1d.tzinfo is not None
# Test yesterday - should be yesterday at same time
result_yesterday = parse_timeframe("yesterday")
# dateparser returns yesterday at current time, not start of yesterday
assert result_yesterday.date() == (now.date() - timedelta(days=1))
assert result_yesterday.tzinfo is not None
# Test 1 week ago
result_week = parse_timeframe("1 week ago")
expected_week = now - timedelta(weeks=1)
diff = abs((result_week - expected_week).total_seconds())
assert diff < 3600 # Within 1 hour tolerance
assert result_week.tzinfo is not None
def test_parse_timeframe_invalid(self):
"""Test that parse_timeframe raises ValueError for invalid input."""
@@ -448,7 +452,7 @@ class TestTimeframeParsing:
assert today_parsed.minute == 0
# '1d' should be 24 hours ago (same time yesterday)
now = datetime.now()
now = datetime.now().astimezone()
expected_1d = now - timedelta(days=1)
diff = abs((oneday_parsed - expected_1d).total_seconds())
assert diff < 60 # Within 1 minute
+2 -2
View File
@@ -155,7 +155,7 @@ async def test_after_date(search_service, test_graph):
"""Test search filters."""
# Should find with past date
past_date = datetime(2020, 1, 1)
past_date = datetime(2020, 1, 1).astimezone()
results = await search_service.search(
SearchQuery(
text="entity",
@@ -166,7 +166,7 @@ async def test_after_date(search_service, test_graph):
assert datetime.fromisoformat(r.created_at) > past_date
# Should not find with future date
future_date = datetime(2030, 1, 1)
future_date = datetime(2030, 1, 1).astimezone()
results = await search_service.search(
SearchQuery(
text="entity",
+11 -5
View File
@@ -617,7 +617,7 @@ type: knowledge
# File Dates
Testing file timestamps
"""
file_path = project_dir / "file_dates.md"
file_path = project_dir / "file_dates3.md"
await create_test_file(file_path, file_dates_content)
# Run sync
@@ -629,13 +629,19 @@ Testing file timestamps
assert explicit_entity.updated_at is not None
# Check file timestamps
file_entity = await entity_service.get_by_permalink("file-dates")
file_entity = await entity_service.get_by_permalink("file-dates3")
file_stats = file_path.stat()
# Compare using epoch timestamps to handle timezone differences correctly
# This ensures we're comparing the actual points in time, not display representations
entity_created_epoch = file_entity.created_at.timestamp()
entity_updated_epoch = file_entity.updated_at.timestamp()
assert (
abs((file_entity.created_at.timestamp() - file_stats.st_ctime)) < 1
) # Allow 1s difference
abs(entity_created_epoch - file_stats.st_ctime) < 1
)
assert (
abs((file_entity.updated_at.timestamp() - file_stats.st_mtime)) < 1
abs(entity_updated_epoch - file_stats.st_mtime) < 1
) # Allow 1s difference