fix: fastmcp deprecation warning (#150)

Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
This commit is contained in:
Drew Cain
2025-06-19 19:59:17 -05:00
committed by GitHub
parent 3269a2f33a
commit 7be001ca68
9 changed files with 126 additions and 121 deletions
+2
View File
@@ -78,6 +78,7 @@ def mcp(
if transport == "stdio":
mcp_server.run(
transport=transport,
log_level="INFO",
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
@@ -85,4 +86,5 @@ def mcp(
host=host,
port=port,
path=path,
log_level="INFO",
)
+5 -4
View File
@@ -95,11 +95,12 @@ async def get_or_create_db(
if _engine is None:
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
# Run migrations automatically unless explicitly disabled
if ensure_migrations:
if app_config is None:
from basic_memory.config import app_config as global_app_config
app_config = global_app_config
await run_migrations(app_config, db_type)
@@ -170,12 +171,12 @@ async def run_migrations(
): # pragma: no cover
"""Run any pending alembic migrations."""
global _migrations_completed
# Skip if migrations already completed unless forced
if _migrations_completed and not force:
logger.debug("Migrations already completed in this session, skipping")
return
logger.info("Running database migrations...")
try:
# Get the absolute path to the alembic directory relative to this file
@@ -206,7 +207,7 @@ async def run_migrations(
# initialize the search Index schema
# the project_id is not used for init_search_index, so we pass a dummy value
await SearchRepository(session_maker, 1).init_search_index()
# Mark migrations as completed
_migrations_completed = True
except Exception as e: # pragma: no cover
-1
View File
@@ -105,6 +105,5 @@ auth_settings, auth_provider = create_auth_config()
# Create the shared server instance
mcp = FastMCP(
name="Basic Memory",
log_level="DEBUG",
auth=auth_provider,
)
@@ -102,14 +102,14 @@ class EntityRepository(Repository[Entity]):
async def upsert_entity(self, entity: Entity) -> Entity:
"""Insert or update entity using a hybrid approach.
This method provides a cleaner alternative to the try/catch approach
for handling permalink and file_path conflicts. It first tries direct
for handling permalink and file_path conflicts. It first tries direct
insertion, then handles conflicts intelligently.
Args:
entity: The entity to insert or update
Returns:
The inserted or updated entity
"""
@@ -117,29 +117,28 @@ class EntityRepository(Repository[Entity]):
async with db.scoped_session(self.session_maker) as session:
# Set project_id if applicable and not already set
self._set_project_id_if_needed(entity)
# Check for existing entity with same file_path first
existing_by_path = await session.execute(
select(Entity).where(
Entity.file_path == entity.file_path,
Entity.project_id == entity.project_id
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
)
)
existing_path_entity = existing_by_path.scalar_one_or_none()
if existing_path_entity:
# Update existing entity with same file path
for key, value in {
'title': entity.title,
'entity_type': entity.entity_type,
'entity_metadata': entity.entity_metadata,
'content_type': entity.content_type,
'permalink': entity.permalink,
'checksum': entity.checksum,
'updated_at': entity.updated_at,
"title": entity.title,
"entity_type": entity.entity_type,
"entity_metadata": entity.entity_metadata,
"content_type": entity.content_type,
"permalink": entity.permalink,
"checksum": entity.checksum,
"updated_at": entity.updated_at,
}.items():
setattr(existing_path_entity, key, value)
await session.flush()
# Return with relationships loaded
query = (
@@ -150,15 +149,17 @@ class EntityRepository(Repository[Entity]):
result = await session.execute(query)
found = result.scalar_one_or_none()
if not found: # pragma: no cover
raise RuntimeError(f"Failed to retrieve entity after update: {entity.file_path}")
raise RuntimeError(
f"Failed to retrieve entity after update: {entity.file_path}"
)
return found
# No existing entity with same file_path, try insert
try:
# Simple insert for new entity
session.add(entity)
await session.flush()
# Return with relationships loaded
query = (
select(Entity)
@@ -168,36 +169,37 @@ class EntityRepository(Repository[Entity]):
result = await session.execute(query)
found = result.scalar_one_or_none()
if not found: # pragma: no cover
raise RuntimeError(f"Failed to retrieve entity after insert: {entity.file_path}")
raise RuntimeError(
f"Failed to retrieve entity after insert: {entity.file_path}"
)
return found
except IntegrityError:
# Could be either file_path or permalink conflict
await session.rollback()
# Check if it's a file_path conflict (race condition)
existing_by_path_check = await session.execute(
select(Entity).where(
Entity.file_path == entity.file_path,
Entity.project_id == entity.project_id
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
)
)
race_condition_entity = existing_by_path_check.scalar_one_or_none()
if race_condition_entity:
# Race condition: file_path conflict detected after our initial check
# Update the existing entity instead
for key, value in {
'title': entity.title,
'entity_type': entity.entity_type,
'entity_metadata': entity.entity_metadata,
'content_type': entity.content_type,
'permalink': entity.permalink,
'checksum': entity.checksum,
'updated_at': entity.updated_at,
"title": entity.title,
"entity_type": entity.entity_type,
"entity_metadata": entity.entity_metadata,
"content_type": entity.content_type,
"permalink": entity.permalink,
"checksum": entity.checksum,
"updated_at": entity.updated_at,
}.items():
setattr(race_condition_entity, key, value)
await session.flush()
# Return the updated entity with relationships loaded
query = (
@@ -208,7 +210,9 @@ class EntityRepository(Repository[Entity]):
result = await session.execute(query)
found = result.scalar_one_or_none()
if not found: # pragma: no cover
raise RuntimeError(f"Failed to retrieve entity after race condition update: {entity.file_path}")
raise RuntimeError(
f"Failed to retrieve entity after race condition update: {entity.file_path}"
)
return found
else:
# Must be permalink conflict - generate unique permalink
@@ -218,14 +222,13 @@ class EntityRepository(Repository[Entity]):
"""Handle permalink conflicts by generating a unique permalink."""
base_permalink = entity.permalink
suffix = 1
# Find a unique permalink
while True:
test_permalink = f"{base_permalink}-{suffix}"
existing = await session.execute(
select(Entity).where(
Entity.permalink == test_permalink,
Entity.project_id == entity.project_id
Entity.permalink == test_permalink, Entity.project_id == entity.project_id
)
)
if existing.scalar_one_or_none() is None:
@@ -233,11 +236,11 @@ class EntityRepository(Repository[Entity]):
entity.permalink = test_permalink
break
suffix += 1
# Insert with unique permalink (no conflict possible now)
session.add(entity)
await session.flush()
# Return the inserted entity with relationships loaded
query = (
select(Entity)
+2 -2
View File
@@ -302,7 +302,7 @@ class EntityService(BaseService[EntityModel]):
Creates the entity with null checksum to indicate sync not complete.
Relations will be added in second pass.
Uses UPSERT approach to handle permalink/file_path conflicts cleanly.
"""
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
@@ -310,7 +310,7 @@ class EntityService(BaseService[EntityModel]):
# Mark as incomplete because we still need to add relations
model.checksum = None
# Use UPSERT to handle conflicts cleanly
try:
return await self.repository.upsert_entity(model)
+11 -5
View File
@@ -21,9 +21,9 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None:
Args:
app_config: The Basic Memory project configuration
Note:
Database migrations are now handled automatically when the database
Database migrations are now handled automatically when the database
connection is first established via get_or_create_db().
"""
# Trigger database initialization and migrations by getting the database connection
@@ -50,7 +50,9 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
# Get database session - migrations handled centrally
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM, ensure_migrations=False
db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM,
ensure_migrations=False,
)
project_repository = ProjectRepository(session_maker)
@@ -71,7 +73,9 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
async def migrate_legacy_projects(app_config: BasicMemoryConfig):
# Get database session - migrations handled centrally
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM, ensure_migrations=False
db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM,
ensure_migrations=False,
)
logger.info("Migrating legacy projects...")
project_repository = ProjectRepository(session_maker)
@@ -140,7 +144,9 @@ async def initialize_file_sync(
# Load app configuration - migrations handled centrally
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM, ensure_migrations=False
db_path=app_config.database_path,
db_type=db.DatabaseType.FILESYSTEM,
ensure_migrations=False,
)
project_repository = ProjectRepository(session_maker)
+16 -22
View File
@@ -418,59 +418,53 @@ async def test_write_note_preserves_content_frontmatter(app):
@pytest.mark.asyncio
async def test_write_note_permalink_collision_fix_issue_139(app):
"""Test fix for GitHub Issue #139: UNIQUE constraint failed: entity.permalink.
This reproduces the exact scenario described in the issue:
1. Create a note with title "Note 1"
1. Create a note with title "Note 1"
2. Create another note with title "Note 2"
3. Try to create/replace first note again with same title "Note 1"
Before the fix, step 3 would fail with UNIQUE constraint error.
After the fix, it should either update the existing note or create with unique permalink.
"""
# Step 1: Create first note
result1 = await write_note.fn(
title="Note 1",
folder="test",
content="Original content for note 1"
title="Note 1", folder="test", content="Original content for note 1"
)
assert "# Created note" in result1
assert "permalink: test/note-1" in result1
# Step 2: Create second note with different title
result2 = await write_note.fn(
title="Note 2",
folder="test",
content="Content for note 2"
)
result2 = await write_note.fn(title="Note 2", folder="test", content="Content for note 2")
assert "# Created note" in result2
assert "permalink: test/note-2" in result2
# Step 3: Try to create/replace first note again
# This scenario would trigger the UNIQUE constraint failure before the fix
result3 = await write_note.fn(
title="Note 1", # Same title as first note
folder="test", # Same folder as first note
content="Replacement content for note 1" # Different content
folder="test", # Same folder as first note
content="Replacement content for note 1", # Different content
)
# This should not raise a UNIQUE constraint failure error
# It should succeed and either:
# 1. Update the existing note (preferred behavior)
# 2. Create a new note with unique permalink (fallback behavior)
assert result3 is not None
assert ("Updated note" in result3 or "Created note" in result3)
assert "Updated note" in result3 or "Created note" in result3
# The result should contain either the original permalink or a unique one
assert ("permalink: test/note-1" in result3 or "permalink: test/note-1-1" in result3)
assert "permalink: test/note-1" in result3 or "permalink: test/note-1-1" in result3
# Verify we can read back the content
if "permalink: test/note-1" in result3:
# Updated existing note case
content = await read_note.fn("test/note-1")
assert "Replacement content for note 1" in content
else:
# Created new note with unique permalink case
# Created new note with unique permalink case
content = await read_note.fn("test/note-1-1")
assert "Replacement content for note 1" in content
# Original note should still exist
@@ -22,7 +22,7 @@ async def test_upsert_entity_new_entity(entity_repository: EntityRepository):
)
result = await entity_repository.upsert_entity(entity)
assert result.id is not None
assert result.title == "Test Entity"
assert result.permalink == "test/test-entity"
@@ -60,7 +60,7 @@ async def test_upsert_entity_same_file_update(entity_repository: EntityRepositor
)
result2 = await entity_repository.upsert_entity(entity2)
# Should update existing entity (same ID)
assert result2.id == original_id
assert result2.title == "Updated Title"
@@ -92,20 +92,20 @@ async def test_upsert_entity_permalink_conflict_different_file(entity_repository
title="Second Entity",
entity_type="note",
permalink="test/shared-permalink", # Same permalink
file_path="test/second-file.md", # Different file_path
file_path="test/second-file.md", # Different file_path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
result2 = await entity_repository.upsert_entity(entity2)
# Should create new entity with unique permalink
assert result2.id != first_id
assert result2.title == "Second Entity"
assert result2.permalink == "test/shared-permalink-1" # Should get suffix
assert result2.file_path == "test/second-file.md"
# Original entity should be unchanged
original = await entity_repository.get_by_permalink("test/shared-permalink")
assert original is not None
@@ -117,30 +117,30 @@ async def test_upsert_entity_permalink_conflict_different_file(entity_repository
async def test_upsert_entity_multiple_permalink_conflicts(entity_repository: EntityRepository):
"""Test upserting multiple entities with permalink conflicts."""
base_permalink = "test/conflict"
# Create entities with conflicting permalinks
entities = []
for i in range(3):
entity = Entity(
project_id=entity_repository.project_id,
title=f"Entity {i+1}",
title=f"Entity {i + 1}",
entity_type="note",
permalink=base_permalink, # All try to use same permalink
file_path=f"test/file-{i+1}.md", # Different file paths
file_path=f"test/file-{i + 1}.md", # Different file paths
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
result = await entity_repository.upsert_entity(entity)
entities.append(result)
# Verify permalinks are unique
expected_permalinks = ["test/conflict", "test/conflict-1", "test/conflict-2"]
actual_permalinks = [entity.permalink for entity in entities]
assert set(actual_permalinks) == set(expected_permalinks)
# Verify all entities were created (different IDs)
entity_ids = [entity.id for entity in entities]
assert len(set(entity_ids)) == 3
@@ -151,7 +151,7 @@ async def test_upsert_entity_race_condition_file_path(entity_repository: EntityR
"""Test that upsert handles race condition where file_path conflict occurs after initial check."""
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError
# Create an entity first
entity1 = Entity(
project_id=entity_repository.project_id,
@@ -163,26 +163,26 @@ async def test_upsert_entity_race_condition_file_path(entity_repository: EntityR
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
result1 = await entity_repository.upsert_entity(entity1)
original_id = result1.id
# Create another entity with different file_path and permalink
entity2 = Entity(
project_id=entity_repository.project_id,
title="Race Condition Test",
entity_type="note",
entity_type="note",
permalink="test/race-entity",
file_path="test/different-file.md", # Different initially
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Now simulate race condition: change file_path to conflict after the initial check
original_add = entity_repository.session_maker().add
call_count = 0
def mock_add(obj):
nonlocal call_count
if isinstance(obj, Entity) and call_count == 0:
@@ -192,12 +192,12 @@ async def test_upsert_entity_race_condition_file_path(entity_repository: EntityR
# This should trigger IntegrityError for file_path constraint
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
return original_add(obj)
# Mock session.add to simulate the race condition
with patch.object(entity_repository.session_maker().__class__, 'add', side_effect=mock_add):
with patch.object(entity_repository.session_maker().__class__, "add", side_effect=mock_add):
# This should handle the race condition gracefully by updating the existing entity
result2 = await entity_repository.upsert_entity(entity2)
# Should return the updated original entity (same ID)
assert result2.id == original_id
assert result2.title == "Race Condition Test" # Updated title
@@ -210,24 +210,24 @@ async def test_upsert_entity_gap_in_suffixes(entity_repository: EntityRepository
"""Test that upsert finds the next available suffix even with gaps."""
# Manually create entities with non-sequential suffixes
base_permalink = "test/gap"
# Create entities with permalinks: "test/gap", "test/gap-1", "test/gap-3"
# (skipping "test/gap-2")
permalinks = [base_permalink, f"{base_permalink}-1", f"{base_permalink}-3"]
for i, permalink in enumerate(permalinks):
entity = Entity(
project_id=entity_repository.project_id,
title=f"Entity {i+1}",
title=f"Entity {i + 1}",
entity_type="note",
permalink=permalink,
file_path=f"test/gap-file-{i+1}.md",
file_path=f"test/gap-file-{i + 1}.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
await entity_repository.add(entity) # Use direct add to set specific permalinks
# Now try to upsert a new entity that should get "test/gap-2"
new_entity = Entity(
project_id=entity_repository.project_id,
@@ -239,10 +239,10 @@ async def test_upsert_entity_gap_in_suffixes(entity_repository: EntityRepository
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
result = await entity_repository.upsert_entity(new_entity)
# Should get the next available suffix - our implementation finds gaps
# so it should be "test/gap-2" (filling the gap)
assert result.permalink == "test/gap-2"
assert result.title == "Gap Filler"
assert result.title == "Gap Filler"
+19 -19
View File
@@ -46,18 +46,18 @@ async def test_migration_deduplication_single_call(
# First call should run migrations
await db.run_migrations(app_config)
# Verify migrations were called
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
mock_search_repository.init_search_index.assert_called_once()
# Reset mocks for second call
mock_alembic_command.reset_mock()
mock_search_repository.reset_mock()
# Second call should skip migrations
await db.run_migrations(app_config)
# Verify migrations were NOT called again
mock_alembic_command.upgrade.assert_not_called()
mock_search_repository.init_search_index.assert_not_called()
@@ -75,18 +75,18 @@ async def test_migration_force_parameter(
# First call should run migrations
await db.run_migrations(app_config)
# Verify migrations were called
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
mock_search_repository.init_search_index.assert_called_once()
# Reset mocks for forced call
mock_alembic_command.reset_mock()
mock_search_repository.reset_mock()
# Forced call should run migrations again
await db.run_migrations(app_config, force=True)
# Verify migrations were called again
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
mock_search_repository.init_search_index.assert_called_once()
@@ -99,10 +99,10 @@ async def test_migration_state_reset_on_shutdown():
db._migrations_completed = True
db._engine = AsyncMock()
db._session_maker = AsyncMock()
# Shutdown should reset state
await db.shutdown_db()
# Verify state was reset
assert db._migrations_completed is False
assert db._engine is None
@@ -123,11 +123,11 @@ async def test_get_or_create_db_runs_migrations_automatically(
engine, session_maker = await db.get_or_create_db(
app_config.database_path, app_config=app_config
)
# Verify we got valid objects
assert engine is not None
assert session_maker is not None
# Verify migrations were called
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
mock_search_repository.init_search_index.assert_called_once()
@@ -147,11 +147,11 @@ async def test_get_or_create_db_skips_migrations_when_disabled(
engine, session_maker = await db.get_or_create_db(
app_config.database_path, ensure_migrations=False
)
# Verify we got valid objects
assert engine is not None
assert session_maker is not None
# Verify migrations were NOT called
mock_alembic_command.upgrade.assert_not_called()
mock_search_repository.init_search_index.assert_not_called()
@@ -169,19 +169,19 @@ async def test_multiple_get_or_create_db_calls_deduplicated(
# First call should create engine and run migrations
await db.get_or_create_db(app_config.database_path, app_config=app_config)
# Verify migrations were called
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
mock_search_repository.init_search_index.assert_called_once()
# Reset mocks for subsequent calls
mock_alembic_command.reset_mock()
mock_search_repository.reset_mock()
# Subsequent calls should not run migrations again
await db.get_or_create_db(app_config.database_path, app_config=app_config)
await db.get_or_create_db(app_config.database_path, app_config=app_config)
# Verify migrations were NOT called again
mock_alembic_command.upgrade.assert_not_called()
mock_search_repository.init_search_index.assert_not_called()
mock_search_repository.init_search_index.assert_not_called()