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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user