Skip to content

Commit 2cd2a62

Browse files
jope-bmclaude
andauthored
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>
1 parent f3d8d8d commit 2cd2a62

14 files changed

Lines changed: 89 additions & 41 deletions

File tree

src/basic_memory/api/routers/resource_router.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ async def write_resource(
188188
"content_type": content_type,
189189
"file_path": file_path,
190190
"checksum": checksum,
191-
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
191+
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
192192
},
193193
)
194194
status_code = 200
@@ -200,8 +200,8 @@ async def write_resource(
200200
content_type=content_type,
201201
file_path=file_path,
202202
checksum=checksum,
203-
created_at=datetime.fromtimestamp(file_stats.st_ctime),
204-
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
203+
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
204+
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
205205
)
206206
entity = await entity_repository.add(entity)
207207
status_code = 201

src/basic_memory/importers/chatgpt_importer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _format_chat_content(
9393
break
9494

9595
# Generate permalink
96-
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
96+
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
9797
clean_title = clean_filename(conversation["title"])
9898

9999
# Format content

src/basic_memory/importers/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
4343
except ValueError:
4444
try:
4545
# Try unix timestamp as string
46-
timestamp = datetime.fromtimestamp(float(timestamp))
46+
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
4747
except ValueError:
4848
# Return as is if we can't parse it
4949
return timestamp
5050
elif isinstance(timestamp, (int, float)):
5151
# Unix timestamp
52-
timestamp = datetime.fromtimestamp(timestamp)
52+
timestamp = datetime.fromtimestamp(timestamp).astimezone()
5353

5454
if isinstance(timestamp, datetime):
5555
return timestamp.strftime("%Y-%m-%d %H:%M:%S")

src/basic_memory/markdown/entity_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,6 @@ async def parse_file_content(self, absolute_path, file_content):
130130
content=post.content,
131131
observations=entity_content.observations,
132132
relations=entity_content.relations,
133-
created=datetime.fromtimestamp(file_stats.st_ctime),
134-
modified=datetime.fromtimestamp(file_stats.st_mtime),
133+
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
134+
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
135135
)

src/basic_memory/mcp/tools/build_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Build context tool for Basic Memory MCP server."""
22

3-
from typing import Optional, Union
3+
from typing import Optional
44

55
from loguru import logger
66

@@ -111,7 +111,7 @@ async def build_context(
111111
metadata=MemoryMetadata(
112112
depth=depth or 1,
113113
timeframe=timeframe,
114-
generated_at=datetime.now(),
114+
generated_at=datetime.now().astimezone(),
115115
primary_count=0,
116116
related_count=0,
117117
uri=migration_status, # Include status in metadata

src/basic_memory/models/knowledge.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Knowledge graph models."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timezone
4+
from basic_memory.utils import ensure_timezone_aware
45
from typing import Optional
56

67
from sqlalchemy import (
@@ -73,8 +74,8 @@ class Entity(Base):
7374
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
7475

7576
# Metadata and tracking
76-
created_at: Mapped[datetime] = mapped_column(DateTime)
77-
updated_at: Mapped[datetime] = mapped_column(DateTime)
77+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
78+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
7879

7980
# Relationships
8081
project = relationship("Project", back_populates="entities")
@@ -103,6 +104,16 @@ def relations(self):
103104
def is_markdown(self):
104105
"""Check if the entity is a markdown file."""
105106
return self.content_type == "text/markdown"
107+
108+
def __getattribute__(self, name):
109+
"""Override attribute access to ensure datetime fields are timezone-aware."""
110+
value = super().__getattribute__(name)
111+
112+
# Ensure datetime fields are timezone-aware
113+
if name in ('created_at', 'updated_at') and isinstance(value, datetime):
114+
return ensure_timezone_aware(value)
115+
116+
return value
106117

107118
def __repr__(self) -> str:
108119
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"

src/basic_memory/models/project.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ class Project(Base):
5252
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
5353

5454
# Timestamps
55-
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
55+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
5656
updated_at: Mapped[datetime] = mapped_column(
57-
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
57+
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
5858
)
5959

6060
# Define relationships to entities, observations, and relations

src/basic_memory/schemas/base.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import mimetypes
1515
import re
16-
from datetime import datetime, time
16+
from datetime import datetime, time, timezone
1717
from pathlib import Path
1818
from typing import List, Optional, Annotated, Dict
1919

@@ -53,22 +53,28 @@ def parse_timeframe(timeframe: str) -> datetime:
5353
timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
5454
5555
Returns:
56-
datetime: The parsed datetime for the start of the timeframe
56+
datetime: The parsed datetime for the start of the timeframe, timezone-aware in local system timezone
5757
5858
Examples:
59-
parse_timeframe('today') -> 2025-06-05 00:00:00 (start of today)
60-
parse_timeframe('1d') -> 2025-06-04 14:50:00 (24 hours ago)
61-
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00 (1 week ago)
59+
parse_timeframe('today') -> 2025-06-05 00:00:00-07:00 (start of today with local timezone)
60+
parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
61+
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
6262
"""
6363
if timeframe.lower() == "today":
64-
# Return start of today (00:00:00)
65-
return datetime.combine(datetime.now().date(), time.min)
64+
# Return start of today (00:00:00) in local timezone
65+
naive_dt = datetime.combine(datetime.now().date(), time.min)
66+
return naive_dt.astimezone()
6667
else:
6768
# Use dateparser for other formats
6869
parsed = parse(timeframe)
6970
if not parsed:
7071
raise ValueError(f"Could not parse timeframe: {timeframe}")
71-
return parsed
72+
73+
# If the parsed datetime is naive, make it timezone-aware in local system timezone
74+
if parsed.tzinfo is None:
75+
return parsed.astimezone()
76+
else:
77+
return parsed
7278

7379

7480
def validate_timeframe(timeframe: str) -> str:
@@ -85,7 +91,7 @@ def validate_timeframe(timeframe: str) -> str:
8591
parsed = parse_timeframe(timeframe)
8692

8793
# Convert to duration
88-
now = datetime.now()
94+
now = datetime.now().astimezone()
8995
if parsed > now:
9096
raise ValueError("Timeframe cannot be in the future")
9197

src/basic_memory/sync/sync_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional
357357

358358
# get file timestamps
359359
file_stats = self.file_service.file_stats(path)
360-
created = datetime.fromtimestamp(file_stats.st_ctime)
361-
modified = datetime.fromtimestamp(file_stats.st_mtime)
360+
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
361+
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
362362

363363
# get mime type
364364
content_type = self.file_service.content_type(path)

src/basic_memory/utils.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import re
77
import sys
8+
from datetime import datetime
89
from pathlib import Path
910
from typing import Optional, Protocol, Union, runtime_checkable, List
1011

@@ -318,4 +319,24 @@ def validate_project_path(path: str, project_path: Path) -> bool:
318319
resolved = (project_path / path).resolve()
319320
return resolved.is_relative_to(project_path.resolve())
320321
except (ValueError, OSError):
321-
return False
322+
return False
323+
324+
325+
def ensure_timezone_aware(dt: datetime) -> datetime:
326+
"""Ensure a datetime is timezone-aware using system timezone.
327+
328+
If the datetime is naive, convert it to timezone-aware using the system's local timezone.
329+
If it's already timezone-aware, return it unchanged.
330+
331+
Args:
332+
dt: The datetime to ensure is timezone-aware
333+
334+
Returns:
335+
A timezone-aware datetime
336+
"""
337+
if dt.tzinfo is None:
338+
# Naive datetime - assume it's in local time and add timezone
339+
return dt.astimezone()
340+
else:
341+
# Already timezone-aware
342+
return dt

0 commit comments

Comments
 (0)