Skip to content

Commit 863e0a4

Browse files
phernandezclaude
andauthored
fix: prevent CLI commands from hanging on exit (Python 3.14) (#505)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent eeeade4 commit 863e0a4

File tree

71 files changed

+1895
-1841
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+1895
-1841
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/

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.12
1+
3.14

pyproject.toml

Lines changed: 7 additions & 3 deletions
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",
@@ -29,7 +29,7 @@ dependencies = [
2929
"alembic>=1.14.1",
3030
"pillow>=11.1.0",
3131
"pybars3>=0.9.7",
32-
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
32+
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
3333
"pyjwt>=2.10.1",
3434
"python-dotenv>=1.1.0",
3535
"pytest-aio>=1.9.0",
@@ -41,7 +41,10 @@ dependencies = [
4141
"mdformat>=0.7.22",
4242
"mdformat-gfm>=0.3.7",
4343
"mdformat-frontmatter>=2.0.8",
44-
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
44+
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
45+
"sniffio>=1.3.1",
46+
"anyio>=4.10.0",
47+
"httpx>=0.28.0",
4548
]
4649

4750

@@ -88,6 +91,7 @@ dev = [
8891
"freezegun>=1.5.5",
8992
"testcontainers[postgres]>=4.0.0",
9093
"psycopg>=3.2.0",
94+
"pyright>=1.1.408",
9195
]
9296

9397
[tool.hatch.version]

src/basic_memory/alembic/env.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
from logging.config import fileConfig
66

77
# 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
8+
# Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
9+
import sys
10+
11+
if sys.version_info < (3, 14):
12+
try:
13+
import nest_asyncio
14+
15+
nest_asyncio.apply()
16+
except (ImportError, ValueError):
17+
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
18+
pass
19+
# For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
1620

1721
from sqlalchemy import engine_from_config, pool
1822
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

src/basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
Create Date: 2025-12-29 12:46:46.476268
66
77
"""
8-
from typing import Sequence, Union
98

9+
from typing import Sequence, Union
1010

1111

1212
# revision identifiers, used by Alembic.
13-
revision: str = '6830751f5fb6'
14-
down_revision: Union[str, Sequence[str], None] = ('a2b3c4d5e6f7', 'g9a0b3c4d5e6')
13+
revision: str = "6830751f5fb6"
14+
down_revision: Union[str, Sequence[str], None] = ("a2b3c4d5e6f7", "g9a0b3c4d5e6")
1515
branch_labels: Union[str, Sequence[str], None] = None
1616
depends_on: Union[str, Sequence[str], None] = None
1717

src/basic_memory/api/v2/routers/knowledge_router.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,7 @@ async def move_entity(
414414

415415
result = EntityResponseV2.model_validate(moved_entity)
416416

417-
logger.info(
418-
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
419-
)
417+
logger.info(f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'")
420418

421419
return result
422420

src/basic_memory/api/v2/routers/project_router.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,7 @@ async def delete_project_by_id(
264264
# Use is_default from database, not ConfigManager (which doesn't work in cloud mode)
265265
if old_project.is_default:
266266
available_projects = await project_service.list_projects()
267-
other_projects = [
268-
p.name for p in available_projects if p.external_id != project_id
269-
]
267+
other_projects = [p.name for p in available_projects if p.external_id != project_id]
270268
detail = f"Cannot delete default project '{old_project.name}'. "
271269
if other_projects:
272270
detail += ( # pragma: no cover

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Core cloud commands for Basic Memory CLI."""
22

3-
import asyncio
4-
53
import typer
64
from rich.console import Console
75

86
from basic_memory.cli.app import cloud_app
7+
from basic_memory.cli.commands.command_utils import run_with_cleanup
98
from basic_memory.cli.auth import CLIAuth
109
from basic_memory.config import ConfigManager
1110
from basic_memory.cli.commands.cloud.api_client import (
@@ -64,7 +63,7 @@ async def _login():
6463
)
6564
raise typer.Exit(1)
6665

67-
asyncio.run(_login())
66+
run_with_cleanup(_login())
6867

6968

7069
@cloud_app.command()
@@ -110,7 +109,7 @@ def status() -> None:
110109
console.print("\n[blue]Checking cloud instance health...[/blue]")
111110

112111
# Make API request to check health
113-
response = asyncio.run(
112+
response = run_with_cleanup(
114113
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
115114
)
116115

@@ -156,12 +155,12 @@ def setup() -> None:
156155

157156
# Step 2: Get tenant info
158157
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
159-
tenant_info = asyncio.run(get_mount_info())
158+
tenant_info = run_with_cleanup(get_mount_info())
160159
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
161160

162161
# Step 3: Generate credentials
163162
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
164-
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
163+
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
165164
console.print("[green]Generated secure credentials[/green]")
166165

167166
# Step 4: Configure rclone remote

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
# Minimum rclone version for --create-empty-src-dirs support
2828
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
2929

30+
3031
class RunResult(Protocol):
3132
returncode: int
3233
stdout: str

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())

0 commit comments

Comments
 (0)