Skip to content

Commit 5d33d82

Browse files
phernandezclaude
andcommitted
fix: prevent CLI commands from hanging on exit (Python 3.14)
CLI commands were using asyncio.run() directly instead of run_with_cleanup(), which meant db.shutdown_db() was never called. This left SQLite/SQLAlchemy connection threads running after commands completed. On Python 3.14, this caused the process to hang during _thread._shutdown(). Changes: - Update all CLI commands to use run_with_cleanup() for proper db cleanup - Update pydantic requirement to >=2.12.0 for Python 3.14 support - Add Python 3.14 to SQLite test matrix in CI Fixes #504 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent eeeade4 commit 5d33d82

File tree

13 files changed

+1072
-1040
lines changed

13 files changed

+1072
-1040
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
fail-fast: false
2020
matrix:
2121
os: [ubuntu-latest, windows-latest]
22-
python-version: [ "3.12", "3.13" ]
22+
python-version: [ "3.12", "3.13", "3.14" ]
2323
runs-on: ${{ matrix.os }}
2424

2525
steps:
@@ -75,7 +75,7 @@ jobs:
7575
strategy:
7676
fail-fast: false
7777
matrix:
78-
python-version: [ "3.12", "3.13" ]
78+
python-version: [ "3.12", "3.13", "3.14" ]
7979
runs-on: ubuntu-latest
8080

8181
# Note: No services section needed - testcontainers handles Postgres in Docker
@@ -164,4 +164,4 @@ jobs:
164164
uses: actions/upload-artifact@v4
165165
with:
166166
name: htmlcov
167-
path: htmlcov/
167+
path: htmlcov/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies = [
1414
"typer>=0.9.0",
1515
"aiosqlite>=0.20.0",
1616
"greenlet>=3.1.1",
17-
"pydantic[email,timezone]>=2.10.3",
17+
"pydantic[email,timezone]>=2.12.0",
1818
"mcp>=1.23.1",
1919
"pydantic-settings>=2.6.1",
2020
"loguru>=0.7.3",

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from rich.console import Console
77

88
from basic_memory.cli.app import cloud_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
910
from basic_memory.cli.auth import CLIAuth
1011
from basic_memory.config import ConfigManager
1112
from basic_memory.cli.commands.cloud.api_client import (
@@ -64,7 +65,7 @@ async def _login():
6465
)
6566
raise typer.Exit(1)
6667

67-
asyncio.run(_login())
68+
run_with_cleanup(_login())
6869

6970

7071
@cloud_app.command()
@@ -110,7 +111,7 @@ def status() -> None:
110111
console.print("\n[blue]Checking cloud instance health...[/blue]")
111112

112113
# Make API request to check health
113-
response = asyncio.run(
114+
response = run_with_cleanup(
114115
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
115116
)
116117

@@ -156,12 +157,12 @@ def setup() -> None:
156157

157158
# Step 2: Get tenant info
158159
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
159-
tenant_info = asyncio.run(get_mount_info())
160+
tenant_info = run_with_cleanup(get_mount_info())
160161
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
161162

162163
# Step 3: Generate credentials
163164
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
164-
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
165+
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
165166
console.print("[green]Generated secure credentials[/green]")
166167

167168
# Step 4: Configure rclone remote

src/basic_memory/cli/commands/cloud/upload_command.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Upload CLI commands for basic-memory projects."""
22

3-
import asyncio
43
from pathlib import Path
54

65
import typer
76
from rich.console import Console
87

98
from basic_memory.cli.app import cloud_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1010
from basic_memory.cli.commands.cloud.cloud_utils import (
1111
create_cloud_project,
1212
project_exists,
@@ -121,4 +121,4 @@ async def _upload():
121121
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
122122
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
123123

124-
asyncio.run(_upload())
124+
run_with_cleanup(_upload())

src/basic_memory/cli/commands/db.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from basic_memory import db
1212
from basic_memory.cli.app import app
13+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1314
from basic_memory.config import ConfigManager
1415
from basic_memory.repository import ProjectRepository
1516
from basic_memory.services.initialization import reconcile_projects_with_config
@@ -81,7 +82,7 @@ def reset(
8182

8283
# Create a new empty database (preserves project configuration)
8384
try:
84-
asyncio.run(db.run_migrations(app_config))
85+
run_with_cleanup(db.run_migrations(app_config))
8586
except OperationalError as e:
8687
if "disk I/O error" in str(e) or "database is locked" in str(e):
8788
console.print(
@@ -99,5 +100,7 @@ def reset(
99100
console.print("[yellow]No projects configured. Skipping reindex.[/yellow]")
100101
else:
101102
console.print(f"Rebuilding search index for {len(projects)} project(s)...")
102-
asyncio.run(_reindex_projects(app_config))
103+
# Note: _reindex_projects has its own cleanup, but run_with_cleanup
104+
# ensures db.shutdown_db() is called even if _reindex_projects changes
105+
run_with_cleanup(_reindex_projects(app_config))
103106
console.print("[green]Reindex complete[/green]")

src/basic_memory/cli/commands/format.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Format command for basic-memory CLI."""
22

3-
import asyncio
43
from pathlib import Path
54
from typing import Annotated, Optional
65

@@ -10,6 +9,7 @@
109
from rich.progress import Progress, SpinnerColumn, TextColumn
1110

1211
from basic_memory.cli.app import app
12+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1313
from basic_memory.config import ConfigManager, get_project_config
1414
from basic_memory.file_utils import format_file
1515

@@ -189,7 +189,7 @@ def format(
189189
basic-memory format notes/ # Format all files in directory
190190
"""
191191
try:
192-
asyncio.run(run_format(path, project))
192+
run_with_cleanup(run_format(path, project))
193193
except Exception as e:
194194
if not isinstance(e, typer.Exit):
195195
logger.error(f"Error formatting files: {e}")

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Import command for ChatGPT conversations."""
22

3-
import asyncio
43
import json
54
from pathlib import Path
65
from typing import Annotated, Tuple
76

87
import typer
98
from basic_memory.cli.app import import_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1010
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers import ChatGPTImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -53,7 +53,7 @@ def import_chatgpt(
5353
raise typer.Exit(1)
5454

5555
# Get importer dependencies
56-
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
56+
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5757
config = get_project_config()
5858
# Process the file
5959
base_path = config.home / folder
@@ -63,7 +63,7 @@ def import_chatgpt(
6363
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
6464
with conversations_json.open("r", encoding="utf-8") as file:
6565
json_data = json.load(file)
66-
result = asyncio.run(importer.import_data(json_data, folder))
66+
result = run_with_cleanup(importer.import_data(json_data, folder))
6767

6868
if not result.success: # pragma: no cover
6969
typer.echo(f"Error during import: {result.error_message}", err=True)

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
22

3-
import asyncio
43
import json
54
from pathlib import Path
65
from typing import Annotated, Tuple
76

87
import typer
98
from basic_memory.cli.app import claude_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1010
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -54,7 +54,7 @@ def import_claude(
5454
raise typer.Exit(1)
5555

5656
# Get importer dependencies
57-
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
57+
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5858

5959
# Create the importer
6060
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
@@ -66,7 +66,7 @@ def import_claude(
6666
# Run the import
6767
with conversations_json.open("r", encoding="utf-8") as file:
6868
json_data = json.load(file)
69-
result = asyncio.run(importer.import_data(json_data, folder))
69+
result = run_with_cleanup(importer.import_data(json_data, folder))
7070

7171
if not result.success: # pragma: no cover
7272
typer.echo(f"Error during import: {result.error_message}", err=True)

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Import command for basic-memory CLI to import project data from Claude.ai."""
22

3-
import asyncio
43
import json
54
from pathlib import Path
65
from typing import Annotated, Tuple
76

87
import typer
98
from basic_memory.cli.app import claude_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1010
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -53,7 +53,7 @@ def import_projects(
5353
raise typer.Exit(1)
5454

5555
# Get importer dependencies
56-
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
56+
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5757

5858
# Create the importer
5959
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
@@ -65,7 +65,7 @@ def import_projects(
6565
# Run the import
6666
with projects_json.open("r", encoding="utf-8") as file:
6767
json_data = json.load(file)
68-
result = asyncio.run(importer.import_data(json_data, base_folder))
68+
result = run_with_cleanup(importer.import_data(json_data, base_folder))
6969

7070
if not result.success: # pragma: no cover
7171
typer.echo(f"Error during import: {result.error_message}", err=True)

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Import command for basic-memory CLI to import from JSON memory format."""
22

3-
import asyncio
43
import json
54
from pathlib import Path
65
from typing import Annotated, Tuple
76

87
import typer
98
from basic_memory.cli.app import import_app
9+
from basic_memory.cli.commands.command_utils import run_with_cleanup
1010
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -52,7 +52,7 @@ def memory_json(
5252
config = get_project_config()
5353
try:
5454
# Get importer dependencies
55-
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
55+
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5656

5757
# Create the importer
5858
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
@@ -67,7 +67,7 @@ def memory_json(
6767
for line in file:
6868
json_data = json.loads(line)
6969
file_data.append(json_data)
70-
result = asyncio.run(importer.import_data(file_data, destination_folder))
70+
result = run_with_cleanup(importer.import_data(file_data, destination_folder))
7171

7272
if not result.success: # pragma: no cover
7373
typer.echo(f"Error during import: {result.error_message}", err=True)

0 commit comments

Comments
 (0)