mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a258b73e1d | |||
| 9a845f2906 | |||
| 6517e9845f | |||
| 1af05392ee | |||
| cad7019c89 |
@@ -15,10 +15,16 @@ Create a stable release using the automated justfile target with comprehensive v
|
||||
You are an expert release manager for the Basic Memory project. When the user runs `/release`, execute the following steps:
|
||||
|
||||
### Step 1: Pre-flight Validation
|
||||
1. Verify version format matches `v\d+\.\d+\.\d+` pattern
|
||||
2. Check current git status for uncommitted changes
|
||||
3. Verify we're on the `main` branch
|
||||
4. Confirm no existing tag with this version
|
||||
|
||||
#### Version Check
|
||||
1. Check current version in `src/basic_memory/__init__.py`
|
||||
2. Verify new version format matches `v\d+\.\d+\.\d+` pattern
|
||||
3. Confirm version is higher than current version
|
||||
|
||||
#### Git Status
|
||||
1. Check current git status for uncommitted changes
|
||||
2. Verify we're on the `main` branch
|
||||
3. Confirm no existing tag with this version
|
||||
|
||||
#### Documentation Validation
|
||||
1. **Changelog Check**
|
||||
@@ -39,19 +45,83 @@ The justfile target handles:
|
||||
- ✅ Version update in `src/basic_memory/__init__.py`
|
||||
- ✅ Automatic commit with proper message
|
||||
- ✅ Tag creation and pushing to GitHub
|
||||
- ✅ Release workflow trigger
|
||||
- ✅ Release workflow trigger (automatic on tag push)
|
||||
|
||||
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
|
||||
- ✅ Builds the package using `uv build`
|
||||
- ✅ Creates GitHub release with auto-generated notes
|
||||
- ✅ Publishes to PyPI
|
||||
- ✅ Updates Homebrew formula (stable releases only)
|
||||
|
||||
### Step 3: Monitor Release Process
|
||||
1. Check that GitHub Actions workflow starts successfully
|
||||
2. Monitor workflow completion at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Verify PyPI publication
|
||||
4. Test installation: `uv tool install basic-memory`
|
||||
1. Verify tag push triggered the workflow (should start automatically within seconds)
|
||||
2. Monitor workflow progress at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Watch for successful completion of both jobs:
|
||||
- `release` - Builds package and publishes to PyPI
|
||||
- `homebrew` - Updates Homebrew formula (stable releases only)
|
||||
4. Check for any workflow failures and investigate logs if needed
|
||||
|
||||
### Step 4: Post-Release Validation
|
||||
1. Verify GitHub release is created automatically
|
||||
2. Check PyPI publication
|
||||
3. Validate release assets
|
||||
4. Update any post-release documentation
|
||||
|
||||
#### GitHub Release
|
||||
1. Verify GitHub release is created at: https://github.com/basicmachines-co/basic-memory/releases/tag/<version>
|
||||
2. Check that release notes are auto-generated from commits
|
||||
3. Validate release assets (`.whl` and `.tar.gz` files are attached)
|
||||
|
||||
#### PyPI Publication
|
||||
1. Verify package published at: https://pypi.org/project/basic-memory/<version>/
|
||||
2. Test installation: `uv tool install basic-memory`
|
||||
3. Verify installed version: `basic-memory --version`
|
||||
|
||||
#### Homebrew Formula (Stable Releases Only)
|
||||
1. Check formula update at: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
2. Verify formula version matches release
|
||||
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
|
||||
|
||||
#### Website Updates
|
||||
|
||||
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
|
||||
- **Goal**: Update version number displayed on the homepage
|
||||
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
|
||||
- **What to update**:
|
||||
- Hero section heading that shows "Basic Memory v{VERSION}"
|
||||
- "What's New in v{VERSION}" section heading
|
||||
- Feature highlights array (look for array of features with title/description)
|
||||
- **Process**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Search codebase for current version number (e.g., "v0.16.1")
|
||||
4. Update version numbers to new release version
|
||||
5. Update feature highlights with 3-5 key features from this release (extract from CHANGELOG.md)
|
||||
6. Commit changes: `git commit -m "chore: update to v{VERSION}"`
|
||||
7. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Deploy**: Follow deployment process for basicmachines.co
|
||||
|
||||
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
|
||||
- **Goal**: Add new release notes section to the latest-releases page
|
||||
- **File**: `src/pages/latest-releases.mdx`
|
||||
- **What to do**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Read the existing file to understand the format and structure
|
||||
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
|
||||
5. Add new release section **at the top** (after MDX imports, before other releases)
|
||||
6. Follow the existing pattern:
|
||||
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
|
||||
- Focus statement if applicable
|
||||
- `<Info>` block with highlights (3-5 key items)
|
||||
- Sections for Features, Bug Fixes, Breaking Changes, etc.
|
||||
- Link to full changelog at the end
|
||||
- Separator `---` between releases
|
||||
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
|
||||
8. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Source content**: Extract and format sections from CHANGELOG.md for this version
|
||||
- **Deploy**: Follow deployment process for docs.basicmemory.com
|
||||
|
||||
**4. Announce Release**
|
||||
- Post to Discord community if significant changes
|
||||
- Update social media if major release
|
||||
- Notify users via appropriate channels
|
||||
|
||||
## Pre-conditions Check
|
||||
Before starting, verify:
|
||||
@@ -74,13 +144,18 @@ Before starting, verify:
|
||||
🏷️ Tag: v0.13.2
|
||||
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
|
||||
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
|
||||
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
🚀 GitHub Actions: Completed
|
||||
|
||||
Install with:
|
||||
uv tool install basic-memory
|
||||
Install with pip/uv:
|
||||
uv tool install basic-memory
|
||||
|
||||
Install with Homebrew:
|
||||
brew install basicmachines-co/basic-memory/basic-memory
|
||||
|
||||
Users can now upgrade:
|
||||
uv tool upgrade basic-memory
|
||||
uv tool upgrade basic-memory
|
||||
brew upgrade basic-memory
|
||||
```
|
||||
|
||||
## Context
|
||||
@@ -89,4 +164,6 @@ uv tool upgrade basic-memory
|
||||
- Uses the automated justfile target for consistency
|
||||
- Version is automatically updated in `__init__.py`
|
||||
- Triggers automated GitHub release with changelog
|
||||
- Leverages uv-dynamic-versioning for package version management
|
||||
- Package is published to PyPI for `pip` and `uv` users
|
||||
- Homebrew formula is automatically updated for stable releases
|
||||
- Supports multiple installation methods (uv, pip, Homebrew)
|
||||
@@ -1,5 +1,27 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.16.2 (2025-11-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#429**: Use platform-native path separators in config.json
|
||||
([`6517e98`](https://github.com/basicmachines-co/basic-memory/commit/6517e98))
|
||||
- Fixes config.json path separator issues on Windows
|
||||
- Uses os.path.join for platform-native path construction
|
||||
- Ensures consistent path handling across platforms
|
||||
|
||||
- **#427**: Add rclone installation checks for Windows bisync commands
|
||||
([`1af0539`](https://github.com/basicmachines-co/basic-memory/commit/1af0539))
|
||||
- Validates rclone installation before running bisync commands
|
||||
- Provides clear error messages when rclone is not installed
|
||||
- Improves user experience on Windows
|
||||
|
||||
- **#421**: Main project always recreated on project list command
|
||||
([`cad7019`](https://github.com/basicmachines-co/basic-memory/commit/cad7019))
|
||||
- Fixes issue where main project was recreated unnecessarily
|
||||
- Improves project list command reliability
|
||||
- Reduces unnecessary file system operations
|
||||
|
||||
## v0.16.1 (2025-11-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.16.1"
|
||||
__version__ = "0.16.2"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
console = Console()
|
||||
@@ -27,6 +28,21 @@ class RcloneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def check_rclone_installed() -> None:
|
||||
"""Check if rclone is installed and raise helpful error if not.
|
||||
|
||||
Raises:
|
||||
RcloneError: If rclone is not installed with installation instructions
|
||||
"""
|
||||
if not is_rclone_installed():
|
||||
raise RcloneError(
|
||||
"rclone is not installed.\n\n"
|
||||
"Install rclone by running: bm cloud setup\n"
|
||||
"Or install manually from: https://rclone.org/downloads/\n\n"
|
||||
"Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncProject:
|
||||
"""Project configured for cloud sync.
|
||||
@@ -124,8 +140,10 @@ def project_sync(
|
||||
True if sync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -180,8 +198,10 @@ def project_bisync(
|
||||
True if bisync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path or needs --resync
|
||||
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -249,8 +269,10 @@ def project_check(
|
||||
True if files match, False if differences found
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
@@ -291,7 +313,10 @@ def project_ls(
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If rclone command fails
|
||||
RcloneError: If rclone is not installed
|
||||
"""
|
||||
check_rclone_installed()
|
||||
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
if path:
|
||||
remote_path = f"{remote_path}/{path}"
|
||||
|
||||
@@ -151,11 +151,25 @@ def install_rclone_windows() -> None:
|
||||
except RcloneInstallError:
|
||||
console.print("[yellow]scoop installation failed[/yellow]")
|
||||
|
||||
# No package manager available
|
||||
raise RcloneInstallError(
|
||||
"Could not install rclone automatically. Please install a package manager "
|
||||
"(winget, chocolatey, or scoop) or install rclone manually from https://rclone.org/downloads/"
|
||||
# No package manager available - provide detailed instructions
|
||||
error_msg = (
|
||||
"Could not install rclone automatically.\n\n"
|
||||
"Windows requires a package manager to install rclone. Options:\n\n"
|
||||
"1. Install winget (recommended, built into Windows 11):\n"
|
||||
" - Windows 11: Already installed\n"
|
||||
" - Windows 10: Install 'App Installer' from Microsoft Store\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"2. Install chocolatey:\n"
|
||||
" - Visit: https://chocolatey.org/install\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"3. Install scoop:\n"
|
||||
" - Visit: https://scoop.sh\n"
|
||||
" - Then run: bm cloud setup\n\n"
|
||||
"4. Manual installation:\n"
|
||||
" - Download from: https://rclone.org/downloads/\n"
|
||||
" - Extract and add to PATH\n"
|
||||
)
|
||||
raise RcloneInstallError(error_msg)
|
||||
|
||||
|
||||
def install_rclone(platform_override: Optional[str] = None) -> None:
|
||||
|
||||
@@ -63,8 +63,10 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
|
||||
},
|
||||
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
}
|
||||
if os.getenv("BASIC_MEMORY_HOME")
|
||||
else {},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
)
|
||||
default_project: str = Field(
|
||||
@@ -192,15 +194,16 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
# Ensure main project exists
|
||||
if "main" not in self.projects: # pragma: no cover
|
||||
self.projects["main"] = (
|
||||
# Ensure at least one project exists; if none exist then create main
|
||||
if not self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
).as_posix()
|
||||
)
|
||||
|
||||
# Ensure default project is valid
|
||||
# Ensure default project is valid (i.e. points to an existing project)
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
self.default_project = "main"
|
||||
# Set default to first available project
|
||||
self.default_project = next(iter(self.projects.keys()))
|
||||
|
||||
@property
|
||||
def app_database_path(self) -> Path:
|
||||
@@ -358,7 +361,7 @@ class ConfigManager:
|
||||
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = project_path.as_posix()
|
||||
config.projects[name] = str(project_path)
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
|
||||
@@ -119,3 +119,45 @@ def test_project_set_default(app_config, config_manager):
|
||||
for line in lines:
|
||||
if "another-project" in line:
|
||||
assert "[X]" in line
|
||||
|
||||
|
||||
def test_remove_main_project(app_config, config_manager):
|
||||
"""Test that removing main project then listing projects prevents main from reappearing (issue #397)."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create separate temp dirs for each project
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as main_dir,
|
||||
tempfile.TemporaryDirectory() as new_default_dir,
|
||||
):
|
||||
main_path = Path(main_dir)
|
||||
new_default_path = Path(new_default_dir)
|
||||
|
||||
# Ensure main exists
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
if "main" not in result.stdout:
|
||||
result = runner.invoke(app, ["project", "add", "main", str(main_path)])
|
||||
print(result.stdout)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm main is present
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
assert "main" in result.stdout
|
||||
|
||||
# Add a second project
|
||||
result = runner.invoke(app, ["project", "add", "new_default", str(new_default_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Set new_default as default (if needed)
|
||||
result = runner.invoke(app, ["project", "default", "new_default"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Remove main
|
||||
result = runner.invoke(app, ["project", "remove", "main"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm only new_default exists and main does not
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "main" not in result.stdout
|
||||
assert "new_default" in result.stdout
|
||||
|
||||
@@ -219,20 +219,20 @@ async def test_update_project_path_endpoint(test_config, client, project_service
|
||||
test_project_name = "test-update-project"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
old_path = (test_root / "old-location").as_posix()
|
||||
new_path = (test_root / "new-location").as_posix()
|
||||
old_path = test_root / "old-location"
|
||||
new_path = test_root / "new-location"
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
await project_service.add_project(test_project_name, str(old_path))
|
||||
|
||||
try:
|
||||
# Verify initial state
|
||||
project = await project_service.get_project(test_project_name)
|
||||
assert project is not None
|
||||
assert project.path == old_path
|
||||
assert Path(project.path) == old_path
|
||||
|
||||
# Update the project path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": new_path}
|
||||
f"{project_url}/project/{test_project_name}", json={"path": str(new_path)}
|
||||
)
|
||||
|
||||
# Verify response
|
||||
@@ -248,16 +248,16 @@ async def test_update_project_path_endpoint(test_config, client, project_service
|
||||
|
||||
# Check old project data
|
||||
assert data["old_project"]["name"] == test_project_name
|
||||
assert data["old_project"]["path"] == old_path
|
||||
assert Path(data["old_project"]["path"]) == old_path
|
||||
|
||||
# Check new project data
|
||||
assert data["new_project"]["name"] == test_project_name
|
||||
assert data["new_project"]["path"] == new_path
|
||||
assert Path(data["new_project"]["path"]) == new_path
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
assert Path(updated_project.path) == new_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
|
||||
@@ -77,18 +77,18 @@ async def test_project_operations_sync_methods(
|
||||
test_project_name = f"test-project-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_project_path = (test_root / "test-project").as_posix()
|
||||
test_project_path = test_root / "test-project"
|
||||
|
||||
# Make sure the test directory exists
|
||||
os.makedirs(test_project_path, exist_ok=True)
|
||||
test_project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Test adding a project (using ConfigManager directly)
|
||||
config_manager.add_project(test_project_name, test_project_path)
|
||||
config_manager.add_project(test_project_name, str(test_project_path))
|
||||
|
||||
# Verify it was added
|
||||
assert test_project_name in project_service.projects
|
||||
assert project_service.projects[test_project_name] == test_project_path
|
||||
assert Path(project_service.projects[test_project_name]) == test_project_path
|
||||
|
||||
# Test setting as default
|
||||
original_default = project_service.default_project
|
||||
@@ -173,24 +173,24 @@ async def test_add_project_async(project_service: ProjectService):
|
||||
test_project_name = f"test-async-project-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_project_path = (test_root / "test-async-project").as_posix()
|
||||
test_project_path = test_root / "test-async-project"
|
||||
|
||||
# Make sure the test directory exists
|
||||
os.makedirs(test_project_path, exist_ok=True)
|
||||
test_project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Test adding a project
|
||||
await project_service.add_project(test_project_name, test_project_path)
|
||||
await project_service.add_project(test_project_name, str(test_project_path))
|
||||
|
||||
# Verify it was added to config
|
||||
assert test_project_name in project_service.projects
|
||||
assert project_service.projects[test_project_name] == test_project_path
|
||||
assert Path(project_service.projects[test_project_name]) == test_project_path
|
||||
|
||||
# Verify it was added to the database
|
||||
project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert project is not None
|
||||
assert project.name == test_project_name
|
||||
assert project.path == test_project_path
|
||||
assert Path(project.path) == test_project_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
@@ -569,34 +569,34 @@ async def test_move_project(project_service: ProjectService):
|
||||
test_project_name = f"test-move-project-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
old_path = (test_root / "old-location").as_posix()
|
||||
new_path = (test_root / "new-location").as_posix()
|
||||
old_path = test_root / "old-location"
|
||||
new_path = test_root / "new-location"
|
||||
|
||||
# Create old directory
|
||||
os.makedirs(old_path, exist_ok=True)
|
||||
old_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Add project with initial path
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
await project_service.add_project(test_project_name, str(old_path))
|
||||
|
||||
# Verify initial state
|
||||
assert test_project_name in project_service.projects
|
||||
assert project_service.projects[test_project_name] == old_path
|
||||
assert Path(project_service.projects[test_project_name]) == old_path
|
||||
|
||||
project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert project is not None
|
||||
assert project.path == old_path
|
||||
assert Path(project.path) == old_path
|
||||
|
||||
# Move project to new location
|
||||
await project_service.move_project(test_project_name, new_path)
|
||||
await project_service.move_project(test_project_name, str(new_path))
|
||||
|
||||
# Verify config was updated
|
||||
assert project_service.projects[test_project_name] == new_path
|
||||
assert Path(project_service.projects[test_project_name]) == new_path
|
||||
|
||||
# Verify database was updated
|
||||
updated_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
assert Path(updated_project.path) == new_path
|
||||
|
||||
# Verify new directory was created
|
||||
assert os.path.exists(new_path)
|
||||
@@ -624,17 +624,17 @@ async def test_move_project_db_mismatch(project_service: ProjectService):
|
||||
test_project_name = f"test-move-mismatch-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
old_path = (test_root / "old-location").as_posix()
|
||||
new_path = (test_root / "new-location").as_posix()
|
||||
old_path = test_root / "old-location"
|
||||
new_path = test_root / "new-location"
|
||||
|
||||
# Create directories
|
||||
os.makedirs(old_path, exist_ok=True)
|
||||
old_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_manager = project_service.config_manager
|
||||
|
||||
try:
|
||||
# Add project to config only (not to database)
|
||||
config_manager.add_project(test_project_name, old_path)
|
||||
config_manager.add_project(test_project_name, str(old_path))
|
||||
|
||||
# Verify it's in config but not in database
|
||||
assert test_project_name in project_service.projects
|
||||
@@ -643,10 +643,10 @@ async def test_move_project_db_mismatch(project_service: ProjectService):
|
||||
|
||||
# Try to move project - should fail and restore config
|
||||
with pytest.raises(ValueError, match="not found in database"):
|
||||
await project_service.move_project(test_project_name, new_path)
|
||||
await project_service.move_project(test_project_name, str(new_path))
|
||||
|
||||
# Verify config was restored to original path
|
||||
assert project_service.projects[test_project_name] == old_path
|
||||
assert Path(project_service.projects[test_project_name]) == old_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
|
||||
@@ -53,18 +53,18 @@ async def test_add_project_to_config(project_service: ProjectService, config_man
|
||||
test_project_name = f"config-project-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_path = (test_root / "config-project").as_posix()
|
||||
test_path = test_root / "config-project"
|
||||
|
||||
# Make sure directory exists
|
||||
os.makedirs(test_path, exist_ok=True)
|
||||
test_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Add a project to config only (using ConfigManager directly)
|
||||
config_manager.add_project(test_project_name, test_path)
|
||||
config_manager.add_project(test_project_name, str(test_path))
|
||||
|
||||
# Verify it's in the config
|
||||
assert test_project_name in project_service.projects
|
||||
assert project_service.projects[test_project_name] == test_path
|
||||
assert Path(project_service.projects[test_project_name]) == test_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
@@ -79,23 +79,23 @@ async def test_update_project_path(project_service: ProjectService, config_manag
|
||||
test_project = f"path-update-test-project-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
original_path = (test_root / "original-path").as_posix()
|
||||
new_path = (test_root / "new-path").as_posix()
|
||||
original_path = test_root / "original-path"
|
||||
new_path = test_root / "new-path"
|
||||
|
||||
# Make sure directories exist
|
||||
os.makedirs(original_path, exist_ok=True)
|
||||
os.makedirs(new_path, exist_ok=True)
|
||||
original_path.mkdir(parents=True, exist_ok=True)
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Add the project
|
||||
await project_service.add_project(test_project, original_path)
|
||||
await project_service.add_project(test_project, str(original_path))
|
||||
|
||||
# Mock the update_project method to avoid issues with complex DB updates
|
||||
with patch.object(project_service, "update_project"):
|
||||
# Just check if the project exists
|
||||
project = await project_service.repository.get_by_name(test_project)
|
||||
assert project is not None
|
||||
assert project.path == original_path
|
||||
assert Path(project.path) == original_path
|
||||
|
||||
# Since we mock the update_project method, we skip verifying path updates
|
||||
|
||||
|
||||
+132
-21
@@ -19,45 +19,60 @@ class TestBasicMemoryConfig:
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
# Should use the default path (home/basic-memory)
|
||||
expected_path = (config_home / "basic-memory").as_posix()
|
||||
assert config.projects["main"] == Path(expected_path).as_posix()
|
||||
expected_path = config_home / "basic-memory"
|
||||
assert Path(config.projects["main"]) == expected_path
|
||||
|
||||
def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch):
|
||||
"""Test that config respects BASIC_MEMORY_HOME environment variable."""
|
||||
custom_path = (config_home / "app" / "data").as_posix()
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", custom_path)
|
||||
custom_path = config_home / "app" / "data"
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
# Should use the custom path from environment variable
|
||||
assert config.projects["main"] == custom_path
|
||||
assert Path(config.projects["main"]) == custom_path
|
||||
|
||||
def test_model_post_init_respects_basic_memory_home(self, config_home, monkeypatch):
|
||||
"""Test that model_post_init creates main project with BASIC_MEMORY_HOME when missing."""
|
||||
custom_path = str(config_home / "custom" / "memory" / "path")
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", custom_path)
|
||||
def test_model_post_init_respects_basic_memory_home_creates_main(
|
||||
self, config_home, monkeypatch
|
||||
):
|
||||
"""Test that model_post_init creates main project with BASIC_MEMORY_HOME when missing and no other projects."""
|
||||
custom_path = config_home / "custom" / "memory" / "path"
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
|
||||
|
||||
# Create config without main project
|
||||
other_path = str(config_home / "some" / "path")
|
||||
config = BasicMemoryConfig(projects={"other": other_path})
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
# model_post_init should have added main project with BASIC_MEMORY_HOME
|
||||
assert "main" in config.projects
|
||||
assert config.projects["main"] == Path(custom_path).as_posix()
|
||||
assert Path(config.projects["main"]) == custom_path
|
||||
|
||||
def test_model_post_init_respects_basic_memory_home_sets_non_main_default(
|
||||
self, config_home, monkeypatch
|
||||
):
|
||||
"""Test that model_post_init does not create main project with BASIC_MEMORY_HOME when another project exists."""
|
||||
custom_path = config_home / "custom" / "memory" / "path"
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
|
||||
|
||||
# Create config without main project
|
||||
other_path = config_home / "some" / "path"
|
||||
config = BasicMemoryConfig(projects={"other": str(other_path)})
|
||||
|
||||
# model_post_init should not add main project with BASIC_MEMORY_HOME
|
||||
assert "main" not in config.projects
|
||||
assert Path(config.projects["other"]) == other_path
|
||||
|
||||
def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch):
|
||||
"""Test that model_post_init falls back to default when BASIC_MEMORY_HOME is not set."""
|
||||
"""Test that model_post_init can set a non-main default when BASIC_MEMORY_HOME is not set."""
|
||||
# Ensure BASIC_MEMORY_HOME is not set
|
||||
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
||||
|
||||
# Create config without main project
|
||||
other_path = (config_home / "some" / "path").as_posix()
|
||||
config = BasicMemoryConfig(projects={"other": other_path})
|
||||
other_path = config_home / "some" / "path"
|
||||
config = BasicMemoryConfig(projects={"other": str(other_path)})
|
||||
|
||||
# model_post_init should have added main project with default path
|
||||
expected_path = (config_home / "basic-memory").as_posix()
|
||||
assert "main" in config.projects
|
||||
assert config.projects["main"] == Path(expected_path).as_posix()
|
||||
# model_post_init should not add main project, but "other" should now be the default
|
||||
assert "main" not in config.projects
|
||||
assert Path(config.projects["other"]) == other_path
|
||||
|
||||
def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch):
|
||||
"""Test that BASIC_MEMORY_HOME works with relative paths."""
|
||||
@@ -66,8 +81,8 @@ class TestBasicMemoryConfig:
|
||||
|
||||
config = BasicMemoryConfig()
|
||||
|
||||
# Should use the exact value from environment variable
|
||||
assert config.projects["main"] == relative_path
|
||||
# Should normalize to platform-native path format
|
||||
assert Path(config.projects["main"]) == Path(relative_path)
|
||||
|
||||
def test_basic_memory_home_overrides_existing_main_project(self, config_home, monkeypatch):
|
||||
"""Test that BASIC_MEMORY_HOME is not used when a map is passed in the constructor."""
|
||||
@@ -367,3 +382,99 @@ class TestConfigManager:
|
||||
config = config_manager.load_config()
|
||||
assert config.cloud_projects == {}
|
||||
assert config.projects == {"main": str(temp_path / "main")}
|
||||
|
||||
|
||||
class TestPlatformNativePathSeparators:
|
||||
"""Test that config uses platform-native path separators."""
|
||||
|
||||
def test_project_paths_use_platform_native_separators_in_config(self, monkeypatch):
|
||||
"""Test that project paths use platform-native separators when created."""
|
||||
import platform
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Set up ConfigManager with temp directory
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a project path
|
||||
project_path = temp_path / "my" / "project"
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Add project via ConfigManager
|
||||
config = BasicMemoryConfig(projects={})
|
||||
config.projects["test-project"] = str(project_path)
|
||||
config_manager.save_config(config)
|
||||
|
||||
# Read the raw JSON file
|
||||
import json
|
||||
|
||||
config_data = json.loads(config_manager.config_file.read_text())
|
||||
|
||||
# Verify path uses platform-native separators
|
||||
saved_path = config_data["projects"]["test-project"]
|
||||
|
||||
# On Windows, should have backslashes; on Unix, forward slashes
|
||||
if platform.system() == "Windows":
|
||||
# Windows paths should contain backslashes
|
||||
assert "\\" in saved_path or ":" in saved_path # C:\\ or \\UNC
|
||||
assert "/" not in saved_path.replace(":/", "") # Exclude drive letter
|
||||
else:
|
||||
# Unix paths should use forward slashes
|
||||
assert "/" in saved_path
|
||||
# Should not force POSIX on non-Windows
|
||||
assert saved_path == str(project_path)
|
||||
|
||||
def test_add_project_uses_platform_native_separators(self, monkeypatch):
|
||||
"""Test that ConfigManager.add_project() uses platform-native separators."""
|
||||
import platform
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Set up ConfigManager
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize with empty projects
|
||||
initial_config = BasicMemoryConfig(projects={})
|
||||
config_manager.save_config(initial_config)
|
||||
|
||||
# Add project
|
||||
project_path = temp_path / "new" / "project"
|
||||
config_manager.add_project("new-project", str(project_path))
|
||||
|
||||
# Load and verify
|
||||
config = config_manager.load_config()
|
||||
saved_path = config.projects["new-project"]
|
||||
|
||||
# Verify platform-native separators
|
||||
if platform.system() == "Windows":
|
||||
assert "\\" in saved_path or ":" in saved_path
|
||||
else:
|
||||
assert "/" in saved_path
|
||||
assert saved_path == str(project_path)
|
||||
|
||||
def test_model_post_init_uses_platform_native_separators(self, config_home, monkeypatch):
|
||||
"""Test that model_post_init uses platform-native separators."""
|
||||
import platform
|
||||
|
||||
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
||||
|
||||
# Create config without projects (triggers model_post_init to add main)
|
||||
config = BasicMemoryConfig(projects={})
|
||||
|
||||
# Verify main project path uses platform-native separators
|
||||
main_path = config.projects["main"]
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Windows: should have backslashes or drive letter
|
||||
assert "\\" in main_path or ":" in main_path
|
||||
else:
|
||||
# Unix: should have forward slashes
|
||||
assert "/" in main_path
|
||||
|
||||
+142
-14
@@ -9,6 +9,7 @@ from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
RcloneError,
|
||||
SyncProject,
|
||||
bisync_initialized,
|
||||
check_rclone_installed,
|
||||
get_project_bisync_state,
|
||||
get_project_remote,
|
||||
project_bisync,
|
||||
@@ -110,9 +111,11 @@ def test_bisync_initialized_true_when_has_files(tmp_path, monkeypatch):
|
||||
assert bisync_initialized("research") is True
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_sync_success(mock_run):
|
||||
def test_project_sync_success(mock_run, mock_is_installed):
|
||||
"""Test successful project sync."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -136,9 +139,11 @@ def test_project_sync_success(mock_run):
|
||||
assert "--dry-run" in cmd
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_sync_with_verbose(mock_run):
|
||||
def test_project_sync_with_verbose(mock_run, mock_is_installed):
|
||||
"""Test project sync with verbose flag."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -154,9 +159,11 @@ def test_project_sync_with_verbose(mock_run):
|
||||
assert "--progress" not in cmd
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_sync_with_progress(mock_run):
|
||||
def test_project_sync_with_progress(mock_run, mock_is_installed):
|
||||
"""Test project sync with progress (default)."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -172,8 +179,10 @@ def test_project_sync_with_progress(mock_run):
|
||||
assert "--verbose" not in cmd
|
||||
|
||||
|
||||
def test_project_sync_no_local_path():
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_sync_no_local_path(mock_is_installed):
|
||||
"""Test project sync raises error when local_sync_path not configured."""
|
||||
mock_is_installed.return_value = True
|
||||
project = SyncProject(name="research", path="app/data/research")
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
@@ -182,10 +191,12 @@ def test_project_sync_no_local_path():
|
||||
assert "no local_sync_path configured" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_success(mock_bisync_init, mock_run):
|
||||
def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
|
||||
"""Test successful project bisync."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = True # Already initialized
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
@@ -209,10 +220,12 @@ def test_project_bisync_success(mock_bisync_init, mock_run):
|
||||
assert "--resilient" in cmd
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run):
|
||||
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, mock_is_installed):
|
||||
"""Test that first bisync requires --resync flag."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
|
||||
project = SyncProject(
|
||||
@@ -228,10 +241,12 @@ def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run):
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run):
|
||||
def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_installed):
|
||||
"""Test bisync with --resync flag for first time."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
@@ -248,10 +263,12 @@ def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run):
|
||||
assert "--resync" in cmd
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run):
|
||||
def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run, mock_is_installed):
|
||||
"""Test that dry-run skips initialization check."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_bisync_init.return_value = False # Not initialized
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
@@ -269,8 +286,10 @@ def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run):
|
||||
assert "--dry-run" in cmd
|
||||
|
||||
|
||||
def test_project_bisync_no_local_path():
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_bisync_no_local_path(mock_is_installed):
|
||||
"""Test project bisync raises error when local_sync_path not configured."""
|
||||
mock_is_installed.return_value = True
|
||||
project = SyncProject(name="research", path="app/data/research")
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
@@ -279,9 +298,11 @@ def test_project_bisync_no_local_path():
|
||||
assert "no local_sync_path configured" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_check_success(mock_run):
|
||||
def test_project_check_success(mock_run, mock_is_installed):
|
||||
"""Test successful project check."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -298,9 +319,11 @@ def test_project_check_success(mock_run):
|
||||
assert cmd[1] == "check"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_check_with_one_way(mock_run):
|
||||
def test_project_check_with_one_way(mock_run, mock_is_installed):
|
||||
"""Test project check with one-way flag."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
project = SyncProject(
|
||||
@@ -315,8 +338,10 @@ def test_project_check_with_one_way(mock_run):
|
||||
assert "--one-way" in cmd
|
||||
|
||||
|
||||
def test_project_check_no_local_path():
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_check_no_local_path(mock_is_installed):
|
||||
"""Test project check raises error when local_sync_path not configured."""
|
||||
mock_is_installed.return_value = True
|
||||
project = SyncProject(name="research", path="app/data/research")
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
@@ -325,9 +350,11 @@ def test_project_check_no_local_path():
|
||||
assert "no local_sync_path configured" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_ls_success(mock_run):
|
||||
def test_project_ls_success(mock_run, mock_is_installed):
|
||||
"""Test successful project ls."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="file1.md\nfile2.md\nsubdir/file3.md\n")
|
||||
|
||||
project = SyncProject(name="research", path="app/data/research")
|
||||
@@ -340,9 +367,11 @@ def test_project_ls_success(mock_run):
|
||||
assert "subdir/file3.md" in files
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
|
||||
def test_project_ls_with_subpath(mock_run):
|
||||
def test_project_ls_with_subpath(mock_run, mock_is_installed):
|
||||
"""Test project ls with subdirectory."""
|
||||
mock_is_installed.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
||||
|
||||
project = SyncProject(name="research", path="/research") # Normalized path
|
||||
@@ -351,3 +380,102 @@ def test_project_ls_with_subpath(mock_run):
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[-1] == "basic-memory-cloud:my-bucket/research/subdir"
|
||||
|
||||
|
||||
# Tests for rclone installation check
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_check_rclone_installed_success(mock_is_installed):
|
||||
"""Test check_rclone_installed when rclone is installed."""
|
||||
mock_is_installed.return_value = True
|
||||
|
||||
# Should not raise any error
|
||||
check_rclone_installed()
|
||||
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_check_rclone_installed_not_found(mock_is_installed):
|
||||
"""Test check_rclone_installed raises error when rclone not installed."""
|
||||
mock_is_installed.return_value = False
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
check_rclone_installed()
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "rclone is not installed" in error_msg
|
||||
assert "bm cloud setup" in error_msg
|
||||
assert "https://rclone.org/downloads/" in error_msg
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_sync_checks_rclone_installed(mock_is_installed):
|
||||
"""Test project_sync checks rclone is installed before running."""
|
||||
mock_is_installed.return_value = False
|
||||
|
||||
project = SyncProject(
|
||||
name="research",
|
||||
path="app/data/research",
|
||||
local_sync_path="/tmp/research",
|
||||
)
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
project_sync(project, "my-bucket")
|
||||
|
||||
assert "rclone is not installed" in str(exc_info.value)
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
|
||||
def test_project_bisync_checks_rclone_installed(mock_bisync_init, mock_is_installed):
|
||||
"""Test project_bisync checks rclone is installed before running."""
|
||||
mock_is_installed.return_value = False
|
||||
mock_bisync_init.return_value = True
|
||||
|
||||
project = SyncProject(
|
||||
name="research",
|
||||
path="app/data/research",
|
||||
local_sync_path="/tmp/research",
|
||||
)
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
project_bisync(project, "my-bucket")
|
||||
|
||||
assert "rclone is not installed" in str(exc_info.value)
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_check_checks_rclone_installed(mock_is_installed):
|
||||
"""Test project_check checks rclone is installed before running."""
|
||||
mock_is_installed.return_value = False
|
||||
|
||||
project = SyncProject(
|
||||
name="research",
|
||||
path="app/data/research",
|
||||
local_sync_path="/tmp/research",
|
||||
)
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
project_check(project, "my-bucket")
|
||||
|
||||
assert "rclone is not installed" in str(exc_info.value)
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
|
||||
def test_project_ls_checks_rclone_installed(mock_is_installed):
|
||||
"""Test project_ls checks rclone is installed before running."""
|
||||
mock_is_installed.return_value = False
|
||||
|
||||
project = SyncProject(name="research", path="app/data/research")
|
||||
|
||||
with pytest.raises(RcloneError) as exc_info:
|
||||
project_ls(project, "my-bucket")
|
||||
|
||||
assert "rclone is not installed" in str(exc_info.value)
|
||||
mock_is_installed.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user