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