mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user