Compare commits

...

5 Commits

Author SHA1 Message Date
Drew Cain a258b73e1d chore: update version to 0.16.2 for v0.16.2 release 2025-11-16 21:30:59 -06:00
Drew Cain 9a845f2906 docs: prepare for v0.16.2 release 2025-11-16 21:28:13 -06:00
Drew Cain 6517e9845f fix: Use platform-native path separators in config.json (#429)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 09:12:26 -06:00
Drew Cain 1af05392ee fix: Add rclone installation checks for Windows bisync commands (#427) 2025-11-12 14:22:14 -06:00
Brandon Mayes cad7019c89 fix: main project always recreated on project list command (#421) 2025-11-12 09:57:08 -05:00
12 changed files with 533 additions and 111 deletions
+94 -17
View File
@@ -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)
+22
View File
@@ -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 -1
View File
@@ -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:
+12 -9
View File
@@ -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
+8 -8
View File
@@ -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
+24 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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()