Skip to content

Commit b16b9d9

Browse files
authored
Merge branch 'main' into feature/api-v2-migration
2 parents 2bdcd74 + 9b7bbc7 commit b16b9d9

9 files changed

Lines changed: 249 additions & 121 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ dependencies = [
1515
"aiosqlite>=0.20.0",
1616
"greenlet>=3.1.1",
1717
"pydantic[email,timezone]>=2.10.3",
18-
"icecream>=2.1.3",
1918
"mcp>=1.2.0",
2019
"pydantic-settings>=2.6.1",
2120
"loguru>=0.7.3",
@@ -37,6 +36,7 @@ dependencies = [
3736
"aiofiles>=24.1.0", # Async file I/O
3837
"logfire>=0.73.0", # Optional observability (disabled by default via config)
3938
"asyncpg>=0.30.0",
39+
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
4040
]
4141

4242

@@ -81,8 +81,7 @@ dev = [
8181
"pytest-xdist>=3.0.0",
8282
"ruff>=0.1.6",
8383
"freezegun>=1.5.5",
84-
"nest-asyncio>=1.6.0",
85-
"psycopg2-binary>=2.9.0", # For Alembic migrations with Postgres
84+
8685
]
8786

8887
[tool.hatch.version]

src/basic_memory/alembic/env.py

Lines changed: 93 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
"""Alembic environment configuration."""
22

3+
import asyncio
34
import os
45
from logging.config import fileConfig
56

6-
from sqlalchemy import engine_from_config
7-
from sqlalchemy import pool
7+
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
8+
# Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
9+
try:
10+
import nest_asyncio
11+
12+
nest_asyncio.apply()
13+
except (ImportError, ValueError):
14+
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
15+
pass
16+
17+
from sqlalchemy import engine_from_config, pool
18+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
819

920
from alembic import context
1021

11-
from basic_memory.config import ConfigManager, DatabaseBackend
22+
from basic_memory.config import ConfigManager
1223

1324
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
1425
os.environ["BASIC_MEMORY_ENV"] = "test"
@@ -35,12 +46,6 @@
3546
sqlalchemy_url = DatabaseType.get_db_url(
3647
app_config.database_path, DatabaseType.FILESYSTEM, app_config
3748
)
38-
39-
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
40-
if app_config.database_backend == DatabaseBackend.POSTGRES:
41-
# Convert asyncpg URL to psycopg2 URL for Alembic
42-
sqlalchemy_url = sqlalchemy_url.replace("postgresql+asyncpg://", "postgresql://")
43-
4449
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
4550

4651
# Interpret the config file for Python logging.
@@ -85,28 +90,89 @@ def run_migrations_offline() -> None:
8590
context.run_migrations()
8691

8792

93+
def do_run_migrations(connection):
94+
"""Execute migrations with the given connection."""
95+
context.configure(
96+
connection=connection,
97+
target_metadata=target_metadata,
98+
include_object=include_object,
99+
render_as_batch=True,
100+
compare_type=True,
101+
)
102+
with context.begin_transaction():
103+
context.run_migrations()
104+
105+
106+
async def run_async_migrations(connectable):
107+
"""Run migrations asynchronously with AsyncEngine."""
108+
async with connectable.connect() as connection:
109+
await connection.run_sync(do_run_migrations)
110+
await connectable.dispose()
111+
112+
88113
def run_migrations_online() -> None:
89114
"""Run migrations in 'online' mode.
90115
91-
In this scenario we need to create an Engine
92-
and associate a connection with the context.
116+
Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
93117
"""
94-
connectable = engine_from_config(
95-
config.get_section(config.config_ini_section, {}),
96-
prefix="sqlalchemy.",
97-
poolclass=pool.NullPool,
98-
)
99-
100-
with connectable.connect() as connection:
101-
context.configure(
102-
connection=connection,
103-
target_metadata=target_metadata,
104-
include_object=include_object,
105-
render_as_batch=True,
106-
)
107-
108-
with context.begin_transaction():
109-
context.run_migrations()
118+
# Check if a connection/engine was provided (e.g., from run_migrations)
119+
connectable = context.config.attributes.get("connection", None)
120+
121+
if connectable is None:
122+
# No connection provided, create engine from config
123+
url = context.config.get_main_option("sqlalchemy.url")
124+
125+
# Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
126+
if url and ("+asyncpg" in url or "+aiosqlite" in url):
127+
# Create async engine for asyncpg or aiosqlite
128+
connectable = create_async_engine(
129+
url,
130+
poolclass=pool.NullPool,
131+
future=True,
132+
)
133+
else:
134+
# Create sync engine for regular sqlite or postgresql
135+
connectable = engine_from_config(
136+
context.config.get_section(context.config.config_ini_section, {}),
137+
prefix="sqlalchemy.",
138+
poolclass=pool.NullPool,
139+
)
140+
141+
# Handle async engines (PostgreSQL with asyncpg)
142+
if isinstance(connectable, AsyncEngine):
143+
# Try to run async migrations
144+
# nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
145+
try:
146+
asyncio.run(run_async_migrations(connectable))
147+
except RuntimeError as e:
148+
if "cannot be called from a running event loop" in str(e):
149+
# We're in a running event loop (likely uvloop) - need to use a different approach
150+
# Create a new thread to run the async migrations
151+
import concurrent.futures
152+
153+
def run_in_thread():
154+
"""Run async migrations in a new event loop in a separate thread."""
155+
new_loop = asyncio.new_event_loop()
156+
asyncio.set_event_loop(new_loop)
157+
try:
158+
new_loop.run_until_complete(run_async_migrations(connectable))
159+
finally:
160+
new_loop.close()
161+
162+
with concurrent.futures.ThreadPoolExecutor() as executor:
163+
future = executor.submit(run_in_thread)
164+
future.result() # Wait for completion and re-raise any exceptions
165+
else:
166+
raise
167+
else:
168+
# Handle sync engines (SQLite) or sync connections
169+
if hasattr(connectable, "connect"):
170+
# It's an engine, get a connection
171+
with connectable.connect() as connection:
172+
do_run_migrations(connection)
173+
else:
174+
# It's already a connection
175+
do_run_migrations(connectable)
110176

111177

112178
if context.is_offline_mode():

src/basic_memory/db.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class DatabaseType(Enum):
3333

3434
MEMORY = auto()
3535
FILESYSTEM = auto()
36+
POSTGRES = auto()
3637

3738
@classmethod
3839
def get_db_url(
@@ -42,7 +43,7 @@ def get_db_url(
4243
4344
Args:
4445
db_path: Path to SQLite database file (ignored for Postgres)
45-
db_type: Type of database (MEMORY or FILESYSTEM)
46+
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
4647
config: Optional config to check for database backend and URL
4748
4849
Returns:
@@ -52,14 +53,21 @@ def get_db_url(
5253
if config is None:
5354
config = ConfigManager().config
5455

55-
# Check if Postgres backend is configured
56+
# Handle explicit Postgres type
57+
if db_type == cls.POSTGRES:
58+
if not config.database_url:
59+
raise ValueError("DATABASE_URL must be set when using Postgres backend")
60+
logger.info(f"Using Postgres database: {config.database_url}")
61+
return config.database_url
62+
63+
# Check if Postgres backend is configured (for backward compatibility)
5664
if config.database_backend == DatabaseBackend.POSTGRES:
5765
if not config.database_url:
5866
raise ValueError("DATABASE_URL must be set when using Postgres backend")
5967
logger.info(f"Using Postgres database: {config.database_url}")
6068
return config.database_url
6169

62-
# Default to SQLite
70+
# SQLite databases
6371
if db_type == cls.MEMORY:
6472
logger.info("Using in-memory SQLite database")
6573
return "sqlite+aiosqlite://"
@@ -208,7 +216,7 @@ def _create_engine_and_session(
208216
209217
Args:
210218
db_path: Path to database file (used for SQLite, ignored for Postgres)
211-
db_type: Type of database (MEMORY or FILESYSTEM)
219+
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
212220
213221
Returns:
214222
Tuple of (engine, session_maker)
@@ -218,7 +226,8 @@ def _create_engine_and_session(
218226
logger.debug(f"Creating engine for db_url: {db_url}")
219227

220228
# Delegate to backend-specific engine creation
221-
if config.database_backend == DatabaseBackend.POSTGRES:
229+
# Check explicit POSTGRES type first, then config setting
230+
if db_type == DatabaseType.POSTGRES or config.database_backend == DatabaseBackend.POSTGRES:
222231
engine = _create_postgres_engine(db_url)
223232
else:
224233
engine = _create_sqlite_engine(db_url, db_type)
@@ -324,16 +333,8 @@ async def run_migrations(
324333
config.set_main_option("revision_environment", "false")
325334

326335
# Get the correct database URL based on backend configuration
336+
# No URL conversion needed - env.py now handles both async and sync engines
327337
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
328-
329-
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
330-
if app_config.database_backend == DatabaseBackend.POSTGRES:
331-
# Convert asyncpg URL to psycopg2 URL for Alembic
332-
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
333-
elif app_config.database_backend == DatabaseBackend.SQLITE:
334-
# Convert aiosqlite URL to pysqlite URL for Alembic
335-
db_url = db_url.replace("sqlite+aiosqlite://", "sqlite:///")
336-
337338
config.set_main_option("sqlalchemy.url", db_url)
338339

339340
command.upgrade(config, "head")
@@ -349,7 +350,10 @@ async def run_migrations(
349350
# For SQLite: Create FTS5 virtual table
350351
# For Postgres: No-op (tsvector column added by migrations)
351352
# The project_id is not used for init_search_index, so we pass a dummy value
352-
if app_config.database_backend == DatabaseBackend.POSTGRES:
353+
if (
354+
database_type == DatabaseType.POSTGRES
355+
or app_config.database_backend == DatabaseBackend.POSTGRES
356+
):
353357
await PostgresSearchRepository(session_maker, 1).init_search_index()
354358
else:
355359
await SQLiteSearchRepository(session_maker, 1).init_search_index()

src/basic_memory/markdown/entity_parser.py

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -189,52 +189,84 @@ def get_file_path(self, path):
189189
return self.base_path / path
190190

191191
async def parse_file_content(self, absolute_path, file_content):
192-
# Parse frontmatter with proper error handling for malformed YAML (issue #185)
192+
"""Parse markdown content from file stats.
193+
194+
Delegates to parse_markdown_content() for actual parsing logic.
195+
Exists for backwards compatibility with code that passes file paths.
196+
"""
197+
# Extract file stat info for timestamps
198+
file_stats = absolute_path.stat()
199+
200+
# Delegate to parse_markdown_content with timestamps from file stats
201+
return await self.parse_markdown_content(
202+
file_path=absolute_path,
203+
content=file_content,
204+
mtime=file_stats.st_mtime,
205+
ctime=file_stats.st_ctime,
206+
)
207+
208+
async def parse_markdown_content(
209+
self,
210+
file_path: Path,
211+
content: str,
212+
mtime: Optional[float] = None,
213+
ctime: Optional[float] = None,
214+
) -> EntityMarkdown:
215+
"""Parse markdown content without requiring file to exist on disk.
216+
217+
Useful for parsing content from S3 or other remote sources where the file
218+
is not available locally.
219+
220+
Args:
221+
file_path: Path for metadata (doesn't need to exist on disk)
222+
content: Markdown content as string
223+
mtime: Optional modification time (Unix timestamp)
224+
ctime: Optional creation time (Unix timestamp)
225+
226+
Returns:
227+
EntityMarkdown with parsed content
228+
"""
229+
# Parse frontmatter with proper error handling for malformed YAML
193230
try:
194-
post = frontmatter.loads(file_content)
231+
post = frontmatter.loads(content)
195232
except yaml.YAMLError as e:
196-
# Log the YAML parsing error with file context
197233
logger.warning(
198-
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
234+
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
199235
f"Treating file as plain markdown without frontmatter."
200236
)
201-
# Create a post with no frontmatter - treat entire content as markdown
202-
post = frontmatter.Post(file_content, metadata={})
237+
post = frontmatter.Post(content, metadata={})
203238

204-
# Extract file stat info
205-
file_stats = absolute_path.stat()
206-
207-
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
208-
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
209-
# This normalization converts them back to ISO format strings to ensure compatibility
210-
# with code that expects string values
239+
# Normalize frontmatter values
211240
metadata = normalize_frontmatter_metadata(post.metadata)
212241

213-
# Ensure required fields have defaults (issue #184, #387)
214-
# Handle title - use default if missing, None/null, empty, or string "None"
242+
# Ensure required fields have defaults
215243
title = metadata.get("title")
216244
if not title or title == "None":
217-
metadata["title"] = absolute_path.stem
245+
metadata["title"] = file_path.stem
218246
else:
219247
metadata["title"] = title
220-
# Handle type - use default if missing OR explicitly set to None/null
248+
221249
entity_type = metadata.get("type")
222250
metadata["type"] = entity_type if entity_type is not None else "note"
223251

224252
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
225253
if tags:
226254
metadata["tags"] = tags
227255

228-
# frontmatter - use metadata with defaults applied
229-
entity_frontmatter = EntityFrontmatter(
230-
metadata=metadata,
231-
)
256+
# Parse content for observations and relations
257+
entity_frontmatter = EntityFrontmatter(metadata=metadata)
232258
entity_content = parse(post.content)
259+
260+
# Use provided timestamps or current time as fallback
261+
now = datetime.now().astimezone()
262+
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
263+
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
264+
233265
return EntityMarkdown(
234266
frontmatter=entity_frontmatter,
235267
content=post.content,
236268
observations=entity_content.observations,
237269
relations=entity_content.relations,
238-
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
239-
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
270+
created=created,
271+
modified=modified,
240272
)

0 commit comments

Comments
 (0)