Skip to content

Commit 9e1af44

Browse files
phernandezclaude
andcommitted
feat: add type safety and comprehensive tests for bisync logic
- Add Pydantic schemas for cloud API responses (cloud.py) - TenantMountInfo for /tenant/mount/info endpoint - MountCredentials for /tenant/mount/credentials endpoint - CloudProjectList and CloudProject for project data - CloudProjectCreateRequest/Response for project creation - Update bisync_commands.py to use Pydantic schemas - Replace dict returns with typed Pydantic models - Use model_validate() for response parsing - Move generate_permalink import to top level - Update all function calls to use model properties - Add comprehensive unit tests (23 tests, all passing) - Test bmignore pattern conversion to rclone filters - Test local directory scanning logic - Test bisync directory validation - Test bisync command building with profiles - Test state management - Test project auto-registration logic Addresses PR #322 feedback: - Critical: Add tests for 724 lines of untested bisync logic - Critical: Fix type annotations with Pydantic schemas - Code quality: All linting and type checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fe30ecc commit 9e1af44

File tree

3 files changed

+557
-52
lines changed

3 files changed

+557
-52
lines changed

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

Lines changed: 46 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
1919
from basic_memory.config import ConfigManager
2020
from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
21+
from basic_memory.schemas.cloud import (
22+
TenantMountInfo,
23+
MountCredentials,
24+
CloudProjectList,
25+
CloudProjectCreateRequest,
26+
CloudProjectCreateResponse,
27+
)
28+
from basic_memory.utils import generate_permalink
2129

2230
console = Console()
2331

@@ -74,7 +82,7 @@ def __init__(
7482
}
7583

7684

77-
async def get_mount_info() -> dict:
85+
async def get_mount_info() -> TenantMountInfo:
7886
"""Get current tenant information from cloud API."""
7987
try:
8088
config_manager = ConfigManager()
@@ -83,12 +91,12 @@ async def get_mount_info() -> dict:
8391

8492
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
8593

86-
return response.json()
94+
return TenantMountInfo.model_validate(response.json())
8795
except Exception as e:
8896
raise BisyncError(f"Failed to get tenant info: {e}") from e
8997

9098

91-
async def generate_mount_credentials(tenant_id: str) -> dict:
99+
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
92100
"""Generate scoped credentials for syncing."""
93101
try:
94102
config_manager = ConfigManager()
@@ -97,16 +105,16 @@ async def generate_mount_credentials(tenant_id: str) -> dict:
97105

98106
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
99107

100-
return response.json()
108+
return MountCredentials.model_validate(response.json())
101109
except Exception as e:
102110
raise BisyncError(f"Failed to generate credentials: {e}") from e
103111

104112

105-
async def fetch_cloud_projects() -> dict:
113+
async def fetch_cloud_projects() -> CloudProjectList:
106114
"""Fetch list of projects from cloud API.
107115
108116
Returns:
109-
Dict with 'projects' list from cloud
117+
CloudProjectList with projects from cloud
110118
"""
111119
try:
112120
config_manager = ConfigManager()
@@ -115,7 +123,7 @@ async def fetch_cloud_projects() -> dict:
115123

116124
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
117125

118-
return response.json()
126+
return CloudProjectList.model_validate(response.json())
119127
except Exception as e:
120128
raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
121129

@@ -140,39 +148,37 @@ def scan_local_directories(sync_dir: Path) -> list[str]:
140148
return directories
141149

142150

143-
async def create_cloud_project(project_name: str) -> dict:
151+
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
144152
"""Create a new project on cloud.
145153
146154
Args:
147155
project_name: Name of project to create
148156
149157
Returns:
150-
Project details from API response
158+
CloudProjectCreateResponse with project details from API
151159
"""
152160
try:
153161
config_manager = ConfigManager()
154162
config = config_manager.config
155163
host_url = config.cloud_host.rstrip("/")
156164

157165
# Use generate_permalink to ensure consistent naming
158-
from basic_memory.utils import generate_permalink
159-
160166
project_path = generate_permalink(project_name)
161167

162-
project_data = {
163-
"name": project_name,
164-
"path": project_path,
165-
"set_default": False,
166-
}
168+
project_data = CloudProjectCreateRequest(
169+
name=project_name,
170+
path=project_path,
171+
set_default=False,
172+
)
167173

168174
response = await make_api_request(
169175
method="POST",
170176
url=f"{host_url}/proxy/projects/projects",
171177
headers={"Content-Type": "application/json"},
172-
json_data=project_data,
178+
json_data=project_data.model_dump(),
173179
)
174180

175-
return response.json()
181+
return CloudProjectCreateResponse.model_validate(response.json())
176182
except Exception as e:
177183
raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
178184

@@ -364,11 +370,8 @@ def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
364370
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
365371
tenant_info = asyncio.run(get_mount_info())
366372

367-
tenant_id = tenant_info.get("tenant_id")
368-
bucket_name = tenant_info.get("bucket_name")
369-
370-
if not tenant_id or not bucket_name:
371-
raise BisyncError("Invalid tenant information received from cloud API")
373+
tenant_id = tenant_info.tenant_id
374+
bucket_name = tenant_info.bucket_name
372375

373376
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
374377
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
@@ -377,11 +380,8 @@ def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
377380
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
378381
creds = asyncio.run(generate_mount_credentials(tenant_id))
379382

380-
access_key = creds.get("access_key")
381-
secret_key = creds.get("secret_key")
382-
383-
if not access_key or not secret_key:
384-
raise BisyncError("Failed to generate credentials")
383+
access_key = creds.access_key
384+
secret_key = creds.secret_key
385385

386386
console.print("[green]✓ Generated secure credentials[/green]")
387387

@@ -459,11 +459,8 @@ def run_bisync(
459459
# Get tenant info if not provided
460460
if not tenant_id or not bucket_name:
461461
tenant_info = asyncio.run(get_mount_info())
462-
tenant_id = tenant_info.get("tenant_id")
463-
bucket_name = tenant_info.get("bucket_name")
464-
465-
if not tenant_id or not bucket_name:
466-
raise BisyncError("Could not determine tenant information")
462+
tenant_id = tenant_info.tenant_id
463+
bucket_name = tenant_info.bucket_name
467464

468465
# Set default local path if not provided
469466
if not local_path:
@@ -494,17 +491,17 @@ def run_bisync(
494491

495492
# Fetch cloud projects and extract directory names from paths
496493
cloud_data = asyncio.run(fetch_cloud_projects())
497-
cloud_projects = cloud_data.get("projects", [])
494+
cloud_projects = cloud_data.projects
498495

499496
# Extract directory names from cloud project paths
500497
# Compare directory names, not project names
501498
# Cloud path /app/data/basic-memory -> directory name "basic-memory"
502499
cloud_dir_names = set()
503500
for p in cloud_projects:
504-
path = p["path"]
501+
path = p.path
505502
# Strip /app/data/ prefix if present (cloud mode)
506503
if path.startswith("/app/data/"):
507-
path = path[len("/app/data/"):]
504+
path = path[len("/app/data/") :]
508505
# Get the last segment (directory name)
509506
dir_name = Path(path).name
510507
cloud_dir_names.add(dir_name)
@@ -546,7 +543,13 @@ def run_bisync(
546543

547544
# Build and execute bisync command
548545
bisync_cmd = build_bisync_command(
549-
tenant_id, bucket_name, local_path, profile, dry_run=dry_run, resync=resync, verbose=verbose
546+
tenant_id,
547+
bucket_name,
548+
local_path,
549+
profile,
550+
dry_run=dry_run,
551+
resync=resync,
552+
verbose=verbose,
550553
)
551554

552555
if dry_run:
@@ -595,18 +598,16 @@ async def notify_container_sync(tenant_id: str) -> None:
595598

596599
# Fetch all projects and sync each one
597600
cloud_data = await fetch_cloud_projects()
598-
projects = cloud_data.get("projects", [])
601+
projects = cloud_data.projects
599602

600603
if not projects:
601604
console.print("[dim]No projects to sync[/dim]")
602605
return
603606

604-
console.print(
605-
f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]"
606-
)
607+
console.print(f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]")
607608

608609
for project in projects:
609-
project_name = project.get("name")
610+
project_name = project.name
610611
if project_name:
611612
try:
612613
await run_sync(project=project_name)
@@ -669,11 +670,7 @@ def show_bisync_status() -> None:
669670
try:
670671
# Get tenant info
671672
tenant_info = asyncio.run(get_mount_info())
672-
tenant_id = tenant_info.get("tenant_id")
673-
674-
if not tenant_id:
675-
console.print("[red]Could not determine tenant ID[/red]")
676-
return
673+
tenant_id = tenant_info.tenant_id
677674

678675
local_path = get_bisync_directory()
679676
state_path = get_bisync_state_path(tenant_id)
@@ -753,11 +750,8 @@ def run_check(
753750
# Get tenant info if not provided
754751
if not tenant_id or not bucket_name:
755752
tenant_info = asyncio.run(get_mount_info())
756-
tenant_id = tenant_id or tenant_info.get("tenant_id")
757-
bucket_name = bucket_name or tenant_info.get("bucket_name")
758-
759-
if not tenant_id or not bucket_name:
760-
raise BisyncError("Could not determine tenant_id or bucket_name")
753+
tenant_id = tenant_id or tenant_info.tenant_id
754+
bucket_name = bucket_name or tenant_info.bucket_name
761755

762756
# Get local path from config
763757
if not local_path:

src/basic_memory/schemas/cloud.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Schemas for cloud-related API responses."""
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class TenantMountInfo(BaseModel):
7+
"""Response from /tenant/mount/info endpoint."""
8+
9+
tenant_id: str = Field(..., description="Unique identifier for the tenant")
10+
bucket_name: str = Field(..., description="S3 bucket name for the tenant")
11+
12+
13+
class MountCredentials(BaseModel):
14+
"""Response from /tenant/mount/credentials endpoint."""
15+
16+
access_key: str = Field(..., description="S3 access key for mount")
17+
secret_key: str = Field(..., description="S3 secret key for mount")
18+
19+
20+
class CloudProject(BaseModel):
21+
"""Representation of a cloud project."""
22+
23+
name: str = Field(..., description="Project name")
24+
path: str = Field(..., description="Project path on cloud")
25+
26+
27+
class CloudProjectList(BaseModel):
28+
"""Response from /proxy/projects/projects endpoint."""
29+
30+
projects: list[CloudProject] = Field(default_factory=list, description="List of cloud projects")
31+
32+
33+
class CloudProjectCreateRequest(BaseModel):
34+
"""Request to create a new cloud project."""
35+
36+
name: str = Field(..., description="Project name")
37+
path: str = Field(..., description="Project path (permalink)")
38+
set_default: bool = Field(default=False, description="Set as default project")
39+
40+
41+
class CloudProjectCreateResponse(BaseModel):
42+
"""Response from creating a cloud project."""
43+
44+
name: str = Field(..., description="Created project name")
45+
path: str = Field(..., description="Created project path")
46+
message: str = Field(default="", description="Success message")

0 commit comments

Comments
 (0)