mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac401ea254 | |||
| fb2fd62ed9 | |||
| 126d1655e6 | |||
| 2abf626c46 | |||
| ba8e3d112d | |||
| 7108a7baf1 | |||
| 35884ef3a7 | |||
| 040be05a81 | |||
| c141d7d1e6 | |||
| b73aeb5ed8 | |||
| 9a0e0bd82d | |||
| 117fa44ecf | |||
| 69d7610d47 | |||
| f608cd13f1 | |||
| 2162ad57fe | |||
| dd6ca80716 | |||
| ae3eeb0cc1 | |||
| 602c55fe90 | |||
| 91bfe2dc92 | |||
| a3cae1064d | |||
| c5c70cb0f4 | |||
| 80ec860a1c | |||
| f64d5b2152 | |||
| 69a625acd1 | |||
| 53c29a37ca |
@@ -54,9 +54,9 @@ Each command is implemented as a Markdown file containing structured prompts tha
|
||||
## Tooling Integration
|
||||
|
||||
Commands leverage existing project tooling:
|
||||
- `make check` - Quality checks
|
||||
- `make test` - Test suite
|
||||
- `make update-deps` - Dependency updates
|
||||
- `just check` - Quality checks
|
||||
- `just test` - Test suite
|
||||
- `just update-deps` - Dependency updates
|
||||
- `uv` - Package management
|
||||
- `git` - Version control
|
||||
- GitHub Actions - CI/CD pipeline
|
||||
@@ -20,9 +20,9 @@ You are an expert release manager for the Basic Memory project. When the user ru
|
||||
3. Get the latest beta tag to determine next version if not provided
|
||||
|
||||
### Step 2: Quality Assurance
|
||||
1. Run `make check` to ensure code quality
|
||||
1. Run `just check` to ensure code quality
|
||||
2. If any checks fail, report issues and stop
|
||||
3. Run `make update-deps` to ensure latest dependencies
|
||||
3. Run `just update-deps` to ensure latest dependencies
|
||||
4. Commit any dependency updates with proper message
|
||||
|
||||
### Step 3: Version Determination
|
||||
@@ -62,7 +62,7 @@ Monitor release: https://github.com/basicmachines-co/basic-memory/actions
|
||||
```
|
||||
|
||||
## Context
|
||||
- Use the existing Makefile targets (`make check`, `make update-deps`)
|
||||
- Use the existing justfile targets (`just check`, `just update-deps`)
|
||||
- Follow semantic versioning for beta releases
|
||||
- Maintain release notes in CHANGELOG.md
|
||||
- Use conventional commit messages
|
||||
|
||||
@@ -28,7 +28,7 @@ You are an expert QA engineer for the Basic Memory project. When the user runs `
|
||||
### Step 2: Code Quality Gates
|
||||
1. **Test Suite Validation**
|
||||
```bash
|
||||
make test
|
||||
just test
|
||||
```
|
||||
- All tests must pass
|
||||
- Check test coverage (target: 95%+)
|
||||
@@ -36,8 +36,8 @@ You are an expert QA engineer for the Basic Memory project. When the user runs `
|
||||
|
||||
2. **Code Quality Checks**
|
||||
```bash
|
||||
make lint
|
||||
make type-check
|
||||
just lint
|
||||
just type-check
|
||||
```
|
||||
- No linting errors
|
||||
- No type checking errors
|
||||
|
||||
@@ -21,7 +21,7 @@ You are an expert release manager for the Basic Memory project. When the user ru
|
||||
4. Confirm no existing tag with this version
|
||||
|
||||
### Step 2: Comprehensive Quality Checks
|
||||
1. Run `make check` (lint, format, type-check, full test suite)
|
||||
1. Run `just check` (lint, format, type-check, full test suite)
|
||||
2. Verify test coverage meets minimum requirements (95%+)
|
||||
3. Check that CHANGELOG.md contains entry for this version
|
||||
4. Validate all high-priority issues are closed
|
||||
|
||||
@@ -12,7 +12,8 @@ Execute comprehensive real-world testing of Basic Memory using the installed ver
|
||||
|
||||
## Implementation
|
||||
|
||||
You are an expert QA engineer conducting live testing of Basic Memory. When the user runs `/project:test-live`, execute comprehensive testing following the TESTING.md methodology:
|
||||
You are an expert QA engineer conducting live testing of Basic Memory.
|
||||
When the user runs `/project:test-live`, execute comprehensive testing following the TESTING.md methodology:
|
||||
|
||||
### Pre-Test Setup
|
||||
|
||||
@@ -22,12 +23,17 @@ You are an expert QA engineer conducting live testing of Basic Memory. When the
|
||||
- Test MCP connection and tool availability
|
||||
|
||||
2. **Test Project Creation**
|
||||
|
||||
Run the bash `date` command to get the current date/time.
|
||||
|
||||
```
|
||||
Create project: "basic-memory-testing-[timestamp]"
|
||||
Location: ~/basic-memory-testing-[timestamp]
|
||||
Purpose: Record all test observations and results
|
||||
```
|
||||
|
||||
Make sure to switch to the newly created project with the `switch_project()` tool.
|
||||
|
||||
3. **Baseline Documentation**
|
||||
Create initial test session note with:
|
||||
- Test environment details
|
||||
@@ -52,6 +58,13 @@ Test all fundamental MCP tools systematically:
|
||||
- Notes with complex formatting
|
||||
- Performance with large notes
|
||||
|
||||
**view_note Tests:**
|
||||
- View notes as formatted artifacts (Claude Desktop)
|
||||
- Title extraction from frontmatter and headings
|
||||
- Unicode and emoji content in artifacts
|
||||
- Error handling for non-existent notes
|
||||
- Artifact display quality and readability
|
||||
|
||||
**search_notes Tests:**
|
||||
- Simple text queries
|
||||
- Tag-based searches
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
- name: Check user permissions
|
||||
id: check_membership
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -41,29 +41,62 @@ jobs:
|
||||
actor = context.payload.issue.user.login;
|
||||
}
|
||||
|
||||
console.log(`Checking membership for user: ${actor}`);
|
||||
console.log(`Checking permissions for user: ${actor}`);
|
||||
|
||||
// List of explicitly allowed users (organization members)
|
||||
const allowedUsers = [
|
||||
'phernandez',
|
||||
'groksrc',
|
||||
'nellins',
|
||||
'bm-claudeai'
|
||||
];
|
||||
|
||||
if (allowedUsers.includes(actor)) {
|
||||
console.log(`User ${actor} is in the allowed list`);
|
||||
core.setOutput('is_member', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Check if user has repository permissions
|
||||
try {
|
||||
const membership = await github.rest.orgs.getMembershipForUser({
|
||||
org: 'basicmachines-co',
|
||||
const collaboration = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: actor
|
||||
});
|
||||
|
||||
console.log(`Membership status: ${membership.data.state}`);
|
||||
const permission = collaboration.data.permission;
|
||||
console.log(`User ${actor} has permission level: ${permission}`);
|
||||
|
||||
// Allow if user is a member (public or private) or admin
|
||||
const allowed = membership.data.state === 'active' &&
|
||||
(membership.data.role === 'member' || membership.data.role === 'admin');
|
||||
// Allow if user has push access or higher (write, maintain, admin)
|
||||
const allowed = ['write', 'maintain', 'admin'].includes(permission);
|
||||
|
||||
core.setOutput('is_member', allowed);
|
||||
|
||||
if (!allowed) {
|
||||
core.notice(`User ${actor} is not a member of basicmachines-co organization`);
|
||||
core.notice(`User ${actor} does not have sufficient repository permissions (has: ${permission})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error checking membership: ${error.message}`);
|
||||
core.setOutput('is_member', false);
|
||||
core.notice(`User ${actor} is not a member of basicmachines-co organization`);
|
||||
console.log(`Error checking permissions: ${error.message}`);
|
||||
|
||||
// Final fallback: Check if user is a public member of the organization
|
||||
try {
|
||||
const membership = await github.rest.orgs.getMembershipForUser({
|
||||
org: 'basicmachines-co',
|
||||
username: actor
|
||||
});
|
||||
|
||||
const allowed = membership.data.state === 'active';
|
||||
core.setOutput('is_member', allowed);
|
||||
|
||||
if (!allowed) {
|
||||
core.notice(`User ${actor} is not a public member of basicmachines-co organization`);
|
||||
}
|
||||
} catch (membershipError) {
|
||||
console.log(`Error checking organization membership: ${membershipError.message}`);
|
||||
core.setOutput('is_member', false);
|
||||
core.notice(`User ${actor} does not have access to this repository`);
|
||||
}
|
||||
}
|
||||
|
||||
- name: Checkout repository
|
||||
@@ -78,4 +111,4 @@ jobs:
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: Bash(uv run pytest),Bash(uv run ruff check . --fix),Bash(uv run ruff format .),Bash(uv run pyright),Bash(make test),Bash(make lint),Bash(make format),Bash(make type-check),Bash(make check),Read,Write,Edit,MultiEdit,Glob,Grep,LS
|
||||
allowed_tools: Bash(uv run pytest),Bash(uv run ruff check . --fix),Bash(uv run ruff format .),Bash(uv run pyright),Bash(just test),Bash(just lint),Bash(just format),Bash(just type-check),Bash(just check),Read,Write,Edit,MultiEdit,Glob,Grep,LS
|
||||
@@ -32,17 +32,11 @@ jobs:
|
||||
uv sync
|
||||
uv build
|
||||
|
||||
- name: Verify version matches tag
|
||||
- name: Verify build succeeded
|
||||
run: |
|
||||
# Get version from built package
|
||||
PACKAGE_VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
|
||||
TAG_VERSION=${GITHUB_REF_NAME#v} # Remove 'v' prefix from tag
|
||||
echo "Package version: $PACKAGE_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "Version mismatch! Package: $PACKAGE_VERSION, Tag: $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
# Verify that build artifacts exist
|
||||
ls -la dist/
|
||||
echo "Build completed successfully"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -35,6 +35,10 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
@@ -45,9 +49,9 @@ jobs:
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
uv run make type-check
|
||||
just type-check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
uv run make test
|
||||
just test
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ ENV/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
/.coverage.*
|
||||
.coverage.*
|
||||
|
||||
# obsidian docs:
|
||||
/docs/.obsidian/
|
||||
@@ -52,4 +52,4 @@ ENV/
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
+208
-57
@@ -1,80 +1,231 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.13.0 (2025-06-11)
|
||||
|
||||
## v0.13.0 (2025-06-03)
|
||||
### Overview
|
||||
|
||||
### Features
|
||||
Basic Memory v0.13.0 is a **major release** that transforms Basic Memory into a true multi-project knowledge management system. This release introduces fluid project switching, advanced note editing capabilities, robust file management, and production-ready OAuth authentication - all while maintaining full backward compatibility.
|
||||
|
||||
- **Multi-Project Management System** - Switch between projects instantly during conversations
|
||||
([`993e88a`](https://github.com/basicmachines-co/basic-memory/commit/993e88a))
|
||||
- Instant project switching with session context
|
||||
- Project-specific operations and isolation
|
||||
- Project discovery and management tools
|
||||
**What's New for Users:**
|
||||
- 🎯 **Switch between projects instantly** during conversations with Claude
|
||||
- ✏️ **Edit notes incrementally** without rewriting entire documents
|
||||
- 📁 **Move and organize notes** with full database consistency
|
||||
- 📖 **View notes as formatted artifacts** for better readability in Claude Desktop
|
||||
- 🔍 **Search frontmatter tags** to discover content more easily
|
||||
- 🔐 **OAuth authentication** for secure remote access
|
||||
- ⚡ **Development builds** automatically published for beta testing
|
||||
|
||||
- **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
|
||||
([`6fc3904`](https://github.com/basicmachines-co/basic-memory/commit/6fc3904))
|
||||
- `edit_note` tool with multiple operation types
|
||||
- Smart frontmatter-aware editing
|
||||
- Validation and error handling
|
||||
**Key v0.13.0 Accomplishments:**
|
||||
- ✅ **Complete Project Management System** - Project switching and project-specific operations
|
||||
- ✅ **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
|
||||
- ✅ **View Notes as Artifacts in Claude Desktop/Web** - Use the view_note tool to view a note as an artifact
|
||||
- ✅ **File Management System** - Full move operations with database consistency and rollback protection
|
||||
- ✅ **Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discoverability
|
||||
- ✅ **Unified Database Architecture** - Single app-level database for better performance and project management
|
||||
|
||||
- **Smart File Management** - Move notes with database consistency and search reindexing
|
||||
([`9fb931c`](https://github.com/basicmachines-co/basic-memory/commit/9fb931c))
|
||||
- `move_note` tool with rollback protection
|
||||
- Automatic folder creation and permalink updates
|
||||
- Full database consistency maintenance
|
||||
### Major Features
|
||||
|
||||
- **Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discovery
|
||||
([`3f5368e`](https://github.com/basicmachines-co/basic-memory/commit/3f5368e))
|
||||
- YAML frontmatter tag indexing
|
||||
- Improved FTS5 search functionality
|
||||
- Project-scoped search operations
|
||||
#### 1. Multiple Project Management
|
||||
|
||||
- **Production Features** - OAuth authentication, development builds, comprehensive testing
|
||||
([`5f8d945`](https://github.com/basicmachines-co/basic-memory/commit/5f8d945))
|
||||
- Development build automation
|
||||
- MCP integration testing framework
|
||||
- Enhanced CI/CD pipeline
|
||||
**Switch between projects instantly during conversations:**
|
||||
|
||||
### Bug Fixes
|
||||
```
|
||||
💬 "What projects do I have?"
|
||||
🤖 Available projects:
|
||||
• main (current, default)
|
||||
• work-notes
|
||||
• personal-journal
|
||||
• code-snippets
|
||||
|
||||
- **#118**: Fix YAML tag formatting to follow standard specification
|
||||
([`2dc7e27`](https://github.com/basicmachines-co/basic-memory/commit/2dc7e27))
|
||||
💬 "Switch to work-notes"
|
||||
🤖 ✓ Switched to work-notes project
|
||||
|
||||
Project Summary:
|
||||
• 47 entities
|
||||
• 125 observations
|
||||
• 23 relations
|
||||
|
||||
- **#110**: Make --project flag work consistently across CLI commands
|
||||
([`02dd91a`](https://github.com/basicmachines-co/basic-memory/commit/02dd91a))
|
||||
💬 "What did I work on yesterday?"
|
||||
🤖 [Shows recent activity from work-notes project]
|
||||
```
|
||||
|
||||
- **#93**: Respect custom permalinks in frontmatter for write_note
|
||||
([`6b6fd76`](https://github.com/basicmachines-co/basic-memory/commit/6b6fd76))
|
||||
**Key Capabilities:**
|
||||
- **Instant Project Switching**: Change project context mid-conversation without restart
|
||||
- **Project-Specific Operations**: Operations work within the currently active project context
|
||||
- **Project Discovery**: List all available projects with status indicators
|
||||
- **Session Context**: Maintains active project throughout conversation
|
||||
- **Backward Compatibility**: Existing single-project setups continue to work seamlessly
|
||||
|
||||
- Fix list_directory path display to not include leading slash
|
||||
([`6057126`](https://github.com/basicmachines-co/basic-memory/commit/6057126))
|
||||
#### 2. Advanced Note Editing
|
||||
|
||||
### Technical Improvements
|
||||
**Edit notes incrementally without rewriting entire documents:**
|
||||
|
||||
- **Unified Database Architecture** - Single app-level database for better performance
|
||||
- Migration from per-project databases to unified structure
|
||||
- Project isolation with foreign key relationships
|
||||
- Optimized queries and reduced file I/O
|
||||
```python
|
||||
# Append new sections to existing notes
|
||||
edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y")
|
||||
|
||||
- **Comprehensive Testing** - 100% test coverage with integration testing
|
||||
([`468a22f`](https://github.com/basicmachines-co/basic-memory/commit/468a22f))
|
||||
- MCP integration test suite
|
||||
- End-to-end testing framework
|
||||
- Performance and edge case validation
|
||||
# Prepend timestamps to meeting notes
|
||||
edit_note("meeting-notes", "prepend", "## 2025-05-27 Update\n- Progress update...")
|
||||
|
||||
### Documentation
|
||||
# Replace specific sections under headers
|
||||
edit_note("api-spec", "replace_section", "New implementation details", section="## Implementation")
|
||||
|
||||
- Add comprehensive testing documentation (TESTING.md)
|
||||
- Update project management guides (PROJECT_MANAGEMENT.md)
|
||||
- Enhanced note editing documentation (EDIT_NOTE.md)
|
||||
- Updated release workflow documentation
|
||||
# Find and replace with validation
|
||||
edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0", expected_replacements=2)
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
**Key Capabilities:**
|
||||
- **Append Operations**: Add content to end of notes (most common use case)
|
||||
- **Prepend Operations**: Add content to beginning of notes
|
||||
- **Section Replacement**: Replace content under specific markdown headers
|
||||
- **Find & Replace**: Simple text replacements with occurrence counting
|
||||
- **Smart Error Handling**: Helpful guidance when operations fail
|
||||
- **Project Context**: Works within the active project with session awareness
|
||||
|
||||
#### 3. Smart File Management
|
||||
|
||||
**Move and organize notes:**
|
||||
|
||||
```python
|
||||
# Simple moves with automatic folder creation
|
||||
move_note("my-note", "work/projects/my-note.md")
|
||||
|
||||
# Organize within the active project
|
||||
move_note("shared-doc", "archive/old-docs/shared-doc.md")
|
||||
|
||||
# Rename operations
|
||||
move_note("old-name", "same-folder/new-name.md")
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Database Consistency**: Updates file paths, permalinks, and checksums automatically
|
||||
- **Search Reindexing**: Maintains search functionality after moves
|
||||
- **Folder Creation**: Automatically creates destination directories
|
||||
- **Project Isolation**: Operates within the currently active project
|
||||
- **Link Preservation**: Maintains internal links and references
|
||||
|
||||
#### 4. Enhanced Search & Discovery
|
||||
|
||||
**Find content more easily with improved search capabilities:**
|
||||
|
||||
- **Frontmatter Tag Search**: Tags from YAML frontmatter are now indexed and searchable
|
||||
- **Improved Content Discovery**: Search across titles, content, tags, and metadata
|
||||
- **Project-Scoped Search**: Search within the currently active project
|
||||
- **Better Search Quality**: Enhanced FTS5 indexing with tag content inclusion
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
tags: [coffee, brewing, equipment]
|
||||
---
|
||||
```
|
||||
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
|
||||
|
||||
#### 5. Unified Database Architecture
|
||||
|
||||
**Single app-level database for better performance and project management:**
|
||||
|
||||
- **Migration from Per-Project DBs**: Moved from multiple SQLite files to single app database
|
||||
- **Project Isolation**: Proper data separation with project_id foreign keys
|
||||
- **Better Performance**: Optimized queries and reduced file I/O
|
||||
|
||||
### Complete MCP Tool Suite
|
||||
|
||||
#### New Project Management Tools
|
||||
- **`list_projects()`** - Discover and list all available projects with status
|
||||
- **`switch_project(project_name)`** - Change active project context during conversations
|
||||
- **`get_current_project()`** - Show currently active project with statistics
|
||||
- **`set_default_project(project_name)`** - Update default project configuration
|
||||
- **`sync_status()`** - Check file synchronization status and background operations
|
||||
|
||||
#### New Note Operations Tools
|
||||
- **`edit_note()`** - Incremental note editing (append, prepend, find/replace, section replace)
|
||||
- **`move_note()`** - Move notes with database consistency and search reindexing
|
||||
- **`view_note()`** - Display notes as formatted artifacts for better readability in Claude Desktop
|
||||
|
||||
#### Enhanced Existing Tools
|
||||
All existing tools now support:
|
||||
- **Session context awareness** (operates within the currently active project)
|
||||
- **Enhanced error messages** with project context metadata
|
||||
- **Improved response formatting** with project information footers
|
||||
- **Project isolation** ensures operations stay within the correct project boundaries
|
||||
|
||||
|
||||
### User Experience Improvements
|
||||
|
||||
#### Installation Options
|
||||
|
||||
**Multiple ways to install and test Basic Memory:**
|
||||
|
||||
```bash
|
||||
# Stable release
|
||||
uv tool install basic-memory
|
||||
|
||||
# Beta/pre-releases
|
||||
uv tool install basic-memory --pre
|
||||
```
|
||||
|
||||
|
||||
#### Bug Fixes & Quality Improvements
|
||||
|
||||
**Major issues resolved in v0.13.0:**
|
||||
|
||||
- **#118**: Fixed YAML tag formatting to follow standard specification
|
||||
- **#110**: Fixed `--project` flag consistency across all CLI commands
|
||||
- **#107**: Fixed write_note update failures with existing notes
|
||||
- **#93**: Fixed custom permalink handling in frontmatter
|
||||
- **#52**: Enhanced search capabilities with frontmatter tag indexing
|
||||
- **FTS5 Search**: Fixed special character handling in search queries
|
||||
- **Error Handling**: Improved error messages and validation across all tools
|
||||
|
||||
### Breaking Changes & Migration
|
||||
|
||||
#### For Existing Users
|
||||
|
||||
**Automatic Migration**: First run will automatically migrate existing data to the new unified database structure. No manual action required.
|
||||
|
||||
**What Changes:**
|
||||
- Database location: Moved to `~/.basic-memory/memory.db` (unified across projects)
|
||||
- Configuration: Projects defined in `~/.basic-memory/config.json` are synced with database
|
||||
|
||||
**What Stays the Same:**
|
||||
- All existing notes and data remain unchanged
|
||||
- Default project behavior maintained for single-project users
|
||||
- All existing MCP tools continue to work without modification
|
||||
|
||||
### Documentation & Resources
|
||||
|
||||
#### New Documentation
|
||||
- [Project Management Guide](docs/Project%20Management.md) - Multi-project workflows
|
||||
- [Note Editing Guide](docs/Note%20Editing.md) - Advanced editing techniques
|
||||
|
||||
#### Updated Documentation
|
||||
- [README.md](README.md) - Installation options and beta build instructions
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Release process and version management
|
||||
- [CLAUDE.md](CLAUDE.md) - Development workflow and CI/CD documentation
|
||||
- [Claude.ai Integration](docs/Claude.ai%20Integration.md) - Updated MCP tool examples
|
||||
|
||||
#### Quick Start Examples
|
||||
|
||||
**Project Switching:**
|
||||
```
|
||||
💬 "Switch to my work project and show recent activity"
|
||||
🤖 [Calls switch_project("work") then recent_activity()]
|
||||
```
|
||||
|
||||
**Note Editing:**
|
||||
```
|
||||
💬 "Add a section about deployment to my API docs"
|
||||
🤖 [Calls edit_note("api-docs", "append", "## Deployment\n...")]
|
||||
```
|
||||
|
||||
**File Organization:**
|
||||
```
|
||||
💬 "Move my old meeting notes to the archive folder"
|
||||
🤖 [Calls move_note("meeting-notes", "archive/old-meetings.md")]
|
||||
```
|
||||
|
||||
- **Database Migration**: Automatic migration from per-project to unified database.
|
||||
Data will be re-index from the filesystem, resulting in no data loss.
|
||||
- **Configuration Changes**: Projects now synced between config.json and database
|
||||
- **Full Backward Compatibility**: All existing setups continue to work seamlessly
|
||||
|
||||
|
||||
## v0.12.3 (2025-04-17)
|
||||
@@ -861,4 +1012,4 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
### Chores
|
||||
|
||||
- Remove basic-foundation src ref in pyproject.toml
|
||||
([`29fce8b`](https://github.com/basicmachines-co/basic-memory/commit/29fce8b0b922d54d7799bf2534107ee6cfb961b8))
|
||||
([`29fce8b`](https://github.com/basicmachines-co/basic-memory/commit/29fce8b0b922d54d7799bf2534107ee6cfb961b8))
|
||||
@@ -14,15 +14,15 @@ See the [README.md](README.md) file for a project overview.
|
||||
|
||||
### Build and Test Commands
|
||||
|
||||
- Install: `make install` or `pip install -e ".[dev]"`
|
||||
- Run tests: `uv run pytest -p pytest_mock -v` or `make test`
|
||||
- Install: `just install` or `pip install -e ".[dev]"`
|
||||
- Run tests: `uv run pytest -p pytest_mock -v` or `just test`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Lint: `make lint` or `ruff check . --fix`
|
||||
- Type check: `make type-check` or `uv run pyright`
|
||||
- Format: `make format` or `uv run ruff format .`
|
||||
- Run all code checks: `make check` (runs lint, format, type-check, test)
|
||||
- Create db migration: `make migration m="Your migration message"`
|
||||
- Run development MCP Inspector: `make run-inspector`
|
||||
- Lint: `just lint` or `ruff check . --fix`
|
||||
- Type check: `just type-check` or `uv run pyright`
|
||||
- Format: `just format` or `uv run ruff format .`
|
||||
- Run all code checks: `just check` (runs lint, format, type-check, test)
|
||||
- Create db migration: `just migration "Your migration message"`
|
||||
- Run development MCP Inspector: `just run-inspector`
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
|
||||
+10
-8
@@ -15,8 +15,8 @@ project and how to get started as a developer.
|
||||
|
||||
2. **Install Dependencies**:
|
||||
```bash
|
||||
# Using make (recommended)
|
||||
make install
|
||||
# Using just (recommended)
|
||||
just install
|
||||
|
||||
# Or using uv
|
||||
uv install -e ".[dev]"
|
||||
@@ -25,10 +25,12 @@ project and how to get started as a developer.
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
> **Note**: Basic Memory uses [just](https://just.systems) as a modern command runner. Install with `brew install just` or `cargo install just`.
|
||||
|
||||
3. **Run the Tests**:
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
just test
|
||||
# or
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
@@ -49,16 +51,16 @@ project and how to get started as a developer.
|
||||
4. **Check Code Quality**:
|
||||
```bash
|
||||
# Run all checks at once
|
||||
make check
|
||||
just check
|
||||
|
||||
# Or run individual checks
|
||||
make lint # Run linting
|
||||
make format # Format code
|
||||
make type-check # Type checking
|
||||
just lint # Run linting
|
||||
just format # Format code
|
||||
just type-check # Type checking
|
||||
```
|
||||
5. **Test Your Changes**: Ensure all tests pass locally and maintain 100% test coverage.
|
||||
```bash
|
||||
make test
|
||||
just test
|
||||
```
|
||||
6. **Submit a PR**: Submit a pull request with a detailed description of your changes.
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
.PHONY: install test test-module lint clean format type-check installer-mac installer-win check test-int
|
||||
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
|
||||
test: test-unit test-int
|
||||
|
||||
lint:
|
||||
ruff check . --fix
|
||||
|
||||
type-check:
|
||||
uv run pyright
|
||||
|
||||
clean:
|
||||
find . -type f -name '*.pyc' -delete
|
||||
find . -type d -name '__pycache__' -exec rm -r {} +
|
||||
rm -rf installer/build/
|
||||
rm -rf installer/dist/
|
||||
rm -f rw.*.dmg
|
||||
rm -rf dist
|
||||
rm -rf installer/build
|
||||
rm -rf installer/dist
|
||||
rm -f .coverage.*
|
||||
|
||||
format:
|
||||
uv run ruff format .
|
||||
|
||||
# run inspector tool
|
||||
run-inspector:
|
||||
npx @modelcontextprotocol/inspector
|
||||
|
||||
# Build app installer
|
||||
installer-mac:
|
||||
cd installer && chmod +x make_icons.sh && ./make_icons.sh
|
||||
cd installer && uv run python setup.py bdist_mac
|
||||
|
||||
installer-win:
|
||||
cd installer && uv run python setup.py bdist_win32
|
||||
|
||||
|
||||
update-deps:
|
||||
uv lock --upgrade
|
||||
|
||||
check: lint format type-check test
|
||||
|
||||
|
||||
# Target for generating Alembic migrations with a message from command line
|
||||
migration:
|
||||
@if [ -z "$(m)" ]; then \
|
||||
echo "Usage: make migration m=\"Your migration message\""; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cd src/basic_memory/alembic && alembic revision --autogenerate -m "$(m)"
|
||||
@@ -1,237 +0,0 @@
|
||||
# Release Notes v0.13.0
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory v0.13.0 is a **major release** that transforms Basic Memory into a true multi-project knowledge management system. This release introduces fluid project switching, advanced note editing capabilities, robust file management, and production-ready OAuth authentication - all while maintaining full backward compatibility.
|
||||
|
||||
**What's New for Users:**
|
||||
- 🎯 **Switch between projects instantly** during conversations with Claude
|
||||
- ✏️ **Edit notes incrementally** without rewriting entire documents
|
||||
- 📁 **Move and organize notes** with full database consistency
|
||||
- 🔍 **Search frontmatter tags** to discover content more easily
|
||||
- 🔐 **OAuth authentication** for secure remote access
|
||||
- ⚡ **Development builds** automatically published for beta testing
|
||||
|
||||
**Key v0.13.0 Accomplishments:**
|
||||
- ✅ **Complete Project Management System** - Project switching and project-specific operations
|
||||
- ✅ **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
|
||||
- ✅ **File Management System** - Full move operations with database consistency and rollback protection
|
||||
- ✅ **Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discoverability
|
||||
- ✅ **Unified Database Architecture** - Single app-level database for better performance and project management
|
||||
|
||||
## Major Features
|
||||
|
||||
### 1. Multiple Project Management 🎯
|
||||
|
||||
**Switch between projects instantly during conversations:**
|
||||
|
||||
```
|
||||
💬 "What projects do I have?"
|
||||
🤖 Available projects:
|
||||
• main (current, default)
|
||||
• work-notes
|
||||
• personal-journal
|
||||
• code-snippets
|
||||
|
||||
💬 "Switch to work-notes"
|
||||
🤖 ✓ Switched to work-notes project
|
||||
|
||||
Project Summary:
|
||||
• 47 entities
|
||||
• 125 observations
|
||||
• 23 relations
|
||||
|
||||
💬 "What did I work on yesterday?"
|
||||
🤖 [Shows recent activity from work-notes project]
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Instant Project Switching**: Change project context mid-conversation without restart
|
||||
- **Project-Specific Operations**: Operations work within the currently active project context
|
||||
- **Project Discovery**: List all available projects with status indicators
|
||||
- **Session Context**: Maintains active project throughout conversation
|
||||
- **Backward Compatibility**: Existing single-project setups continue to work seamlessly
|
||||
|
||||
### 2. Advanced Note Editing ✏️
|
||||
|
||||
**Edit notes incrementally without rewriting entire documents:**
|
||||
|
||||
```python
|
||||
# Append new sections to existing notes
|
||||
edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y")
|
||||
|
||||
# Prepend timestamps to meeting notes
|
||||
edit_note("meeting-notes", "prepend", "## 2025-05-27 Update\n- Progress update...")
|
||||
|
||||
# Replace specific sections under headers
|
||||
edit_note("api-spec", "replace_section", "New implementation details", section="## Implementation")
|
||||
|
||||
# Find and replace with validation
|
||||
edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0", expected_replacements=2)
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Append Operations**: Add content to end of notes (most common use case)
|
||||
- **Prepend Operations**: Add content to beginning of notes
|
||||
- **Section Replacement**: Replace content under specific markdown headers
|
||||
- **Find & Replace**: Simple text replacements with occurrence counting
|
||||
- **Smart Error Handling**: Helpful guidance when operations fail
|
||||
- **Project Context**: Works within the active project with session awareness
|
||||
|
||||
### 3. Smart File Management 📁
|
||||
|
||||
**Move and organize notes:**
|
||||
|
||||
```python
|
||||
# Simple moves with automatic folder creation
|
||||
move_note("my-note", "work/projects/my-note.md")
|
||||
|
||||
# Organize within the active project
|
||||
move_note("shared-doc", "archive/old-docs/shared-doc.md")
|
||||
|
||||
# Rename operations
|
||||
move_note("old-name", "same-folder/new-name.md")
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Database Consistency**: Updates file paths, permalinks, and checksums automatically
|
||||
- **Search Reindexing**: Maintains search functionality after moves
|
||||
- **Folder Creation**: Automatically creates destination directories
|
||||
- **Project Isolation**: Operates within the currently active project
|
||||
- **Link Preservation**: Maintains internal links and references
|
||||
|
||||
### 4. Enhanced Search & Discovery 🔍
|
||||
|
||||
**Find content more easily with improved search capabilities:**
|
||||
|
||||
- **Frontmatter Tag Search**: Tags from YAML frontmatter are now indexed and searchable
|
||||
- **Improved Content Discovery**: Search across titles, content, tags, and metadata
|
||||
- **Project-Scoped Search**: Search within the currently active project
|
||||
- **Better Search Quality**: Enhanced FTS5 indexing with tag content inclusion
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
tags: [coffee, brewing, equipment]
|
||||
---
|
||||
```
|
||||
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
|
||||
|
||||
### 5. Unified Database Architecture 🗄️
|
||||
|
||||
**Single app-level database for better performance and project management:**
|
||||
|
||||
- **Migration from Per-Project DBs**: Moved from multiple SQLite files to single app database
|
||||
- **Project Isolation**: Proper data separation with project_id foreign keys
|
||||
- **Better Performance**: Optimized queries and reduced file I/O
|
||||
|
||||
## Complete MCP Tool Suite 🛠️
|
||||
|
||||
### New Project Management Tools
|
||||
- **`list_projects()`** - Discover and list all available projects with status
|
||||
- **`switch_project(project_name)`** - Change active project context during conversations
|
||||
- **`get_current_project()`** - Show currently active project with statistics
|
||||
- **`set_default_project(project_name)`** - Update default project configuration
|
||||
|
||||
### New Note Operations Tools
|
||||
- **`edit_note()`** - Incremental note editing (append, prepend, find/replace, section replace)
|
||||
- **`move_note()`** - Move notes with database consistency and search reindexing
|
||||
|
||||
### Enhanced Existing Tools
|
||||
All existing tools now support:
|
||||
- **Session context awareness** (operates within the currently active project)
|
||||
- **Enhanced error messages** with project context metadata
|
||||
- **Improved response formatting** with project information footers
|
||||
- **Project isolation** ensures operations stay within the correct project boundaries
|
||||
|
||||
|
||||
## User Experience Improvements
|
||||
|
||||
### Installation Options
|
||||
|
||||
**Multiple ways to install and test Basic Memory:**
|
||||
|
||||
```bash
|
||||
# Stable release
|
||||
uv tool install basic-memory
|
||||
|
||||
# Beta/pre-releases
|
||||
uv tool install basic-memory --pre
|
||||
```
|
||||
|
||||
|
||||
### Bug Fixes & Quality Improvements
|
||||
|
||||
**Major issues resolved in v0.13.0:**
|
||||
|
||||
- **#118**: Fixed YAML tag formatting to follow standard specification
|
||||
- **#110**: Fixed `--project` flag consistency across all CLI commands
|
||||
- **#107**: Fixed write_note update failures with existing notes
|
||||
- **#93**: Fixed custom permalink handling in frontmatter
|
||||
- **#52**: Enhanced search capabilities with frontmatter tag indexing
|
||||
- **FTS5 Search**: Fixed special character handling in search queries
|
||||
- **Error Handling**: Improved error messages and validation across all tools
|
||||
|
||||
## Breaking Changes & Migration
|
||||
|
||||
### For Existing Users
|
||||
|
||||
**Automatic Migration**: First run will automatically migrate existing data to the new unified database structure. No manual action required.
|
||||
|
||||
**What Changes:**
|
||||
- Database location: Moved to `~/.basic-memory/memory.db` (unified across projects)
|
||||
- Configuration: Projects defined in `~/.basic-memory/config.json` are synced with database
|
||||
|
||||
**What Stays the Same:**
|
||||
- All existing notes and data remain unchanged
|
||||
- Default project behavior maintained for single-project users
|
||||
- All existing MCP tools continue to work without modification
|
||||
|
||||
|
||||
|
||||
|
||||
## Documentation & Resources
|
||||
|
||||
### New Documentation
|
||||
- [Project Management Guide](docs/Project%20Management.md) - Multi-project workflows
|
||||
- [Note Editing Guide](docs/Note%20Editing.md) - Advanced editing techniques
|
||||
|
||||
### Updated Documentation
|
||||
- [README.md](README.md) - Installation options and beta build instructions
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Release process and version management
|
||||
- [CLAUDE.md](CLAUDE.md) - Development workflow and CI/CD documentation
|
||||
- [Claude.ai Integration](docs/Claude.ai%20Integration.md) - Updated MCP tool examples
|
||||
|
||||
### Quick Start Examples
|
||||
|
||||
**Project Switching:**
|
||||
```
|
||||
💬 "Switch to my work project and show recent activity"
|
||||
🤖 [Calls switch_project("work") then recent_activity()]
|
||||
```
|
||||
|
||||
**Note Editing:**
|
||||
```
|
||||
💬 "Add a section about deployment to my API docs"
|
||||
🤖 [Calls edit_note("api-docs", "append", "## Deployment\n...")]
|
||||
```
|
||||
|
||||
**File Organization:**
|
||||
```
|
||||
💬 "Move my old meeting notes to the archive folder"
|
||||
🤖 [Calls move_note("meeting-notes", "archive/old-meetings.md")]
|
||||
```
|
||||
|
||||
|
||||
### Getting Updates
|
||||
```bash
|
||||
# Stable releases
|
||||
uv tool upgrade basic-memory
|
||||
|
||||
# Beta releases
|
||||
uv tool install basic-memory --pre --force-reinstall
|
||||
|
||||
# Latest development
|
||||
uv tool install basic-memory --pre --force-reinstall
|
||||
```
|
||||
-337
@@ -1,337 +0,0 @@
|
||||
# Manual Testing Suite for Basic Memory
|
||||
|
||||
This document outlines a comprehensive manual testing approach where an AI assistant (Claude) executes real-world usage scenarios using Basic Memory's MCP tools. The unique aspect: **Basic Memory tests itself** - all test observations and results are recorded as notes in a dedicated test project.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Integration over Isolation**: Test the full MCP→API→DB→File stack
|
||||
- **Real Usage Patterns**: Creative exploration, not just checklist validation
|
||||
- **Self-Documenting**: Use Basic Memory to record all test observations
|
||||
- **Living Documentation**: Test results become part of the knowledge base
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Environment Preparation
|
||||
|
||||
```bash
|
||||
# Ensure latest basic-memory is installed
|
||||
pip install --upgrade basic-memory
|
||||
|
||||
# Verify MCP server is available
|
||||
basic-memory --version
|
||||
```
|
||||
|
||||
### 2. MCP Integration Setup
|
||||
|
||||
**Option A: Claude Desktop Integration**
|
||||
```json
|
||||
// Add to ~/.config/claude-desktop/claude_desktop_config.json
|
||||
// or
|
||||
// .mcp.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/Users/phernandez/dev/basicmachines/basic-memory",
|
||||
"run",
|
||||
"src/basic_memory/cli/main.py",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Claude Code MCP**
|
||||
```bash
|
||||
claude mcp add basic-memory basic-memory mcp
|
||||
```
|
||||
|
||||
### 3. Test Project Creation
|
||||
|
||||
During testing, create a dedicated test project:
|
||||
```
|
||||
- Project name: "basic-memory-testing"
|
||||
- Location: ~/basic-memory-testing
|
||||
- Purpose: Contains all test observations and results
|
||||
```
|
||||
|
||||
## Testing Categories
|
||||
|
||||
### Phase 1: Core Functionality Validation
|
||||
|
||||
**Objective**: Verify all basic operations work correctly
|
||||
|
||||
**Test Areas:**
|
||||
- [ ] **Note Creation**: Various content types, structures, frontmatter
|
||||
- [ ] **Note Reading**: By title, path, memory:// URLs, non-existent notes
|
||||
- [ ] **Search Operations**: Simple queries, boolean operators, tag searches
|
||||
- [ ] **Context Building**: Different depths, timeframes, relation traversal
|
||||
- [ ] **Recent Activity**: Various timeframes, filtering options
|
||||
|
||||
**Success Criteria:**
|
||||
- All operations complete without errors
|
||||
- Files appear correctly in filesystem
|
||||
- Search returns expected results
|
||||
- Context includes appropriate related content
|
||||
|
||||
**Observations to Record:**
|
||||
```markdown
|
||||
# Core Functionality Test Results
|
||||
|
||||
## Test Execution
|
||||
- [timestamp] Test started at 2025-01-06 15:30:00
|
||||
- [setup] Created test project successfully
|
||||
- [environment] MCP connection established
|
||||
|
||||
## write_note Tests
|
||||
- [success] Basic note creation works
|
||||
- [success] Frontmatter tags are preserved
|
||||
- [issue] Special characters in titles need investigation
|
||||
|
||||
## Relations
|
||||
- validates [[Search Operations Test]]
|
||||
- part_of [[Manual Testing Suite]]
|
||||
```
|
||||
|
||||
### Phase 2: v0.13.0 Feature Deep Dive
|
||||
|
||||
**Objective**: Thoroughly test new project management and editing capabilities
|
||||
|
||||
**Project Management Tests:**
|
||||
- [ ] Create multiple projects dynamically
|
||||
- [ ] Switch between projects mid-conversation
|
||||
- [ ] Cross-project operations (create notes in different projects)
|
||||
- [ ] Project discovery and status checking
|
||||
- [ ] Default project behavior
|
||||
|
||||
**Note Editing Tests:**
|
||||
- [ ] Append operations (add content to end)
|
||||
- [ ] Prepend operations (add content to beginning)
|
||||
- [ ] Find/replace operations with validation
|
||||
- [ ] Section replacement under headers
|
||||
- [ ] Edit operations across different projects
|
||||
|
||||
**File Management Tests:**
|
||||
- [ ] Move notes within same project
|
||||
- [ ] Move notes between projects
|
||||
- [ ] Automatic folder creation during moves
|
||||
- [ ] Move operations with special characters
|
||||
- [ ] Database consistency after moves
|
||||
|
||||
**Success Criteria:**
|
||||
- Project switching preserves context correctly
|
||||
- Edit operations modify files as expected
|
||||
- Move operations maintain database consistency
|
||||
- Search indexes update after moves and edits
|
||||
|
||||
### Phase 3: Edge Case Exploration
|
||||
|
||||
**Objective**: Discover limits and handle unusual scenarios gracefully
|
||||
|
||||
**Boundary Testing:**
|
||||
- [ ] Very long note titles and content
|
||||
- [ ] Empty notes and projects
|
||||
- [ ] Special characters: unicode, emojis, symbols
|
||||
- [ ] Deeply nested folder structures
|
||||
- [ ] Circular relations and self-references
|
||||
|
||||
**Error Scenario Testing:**
|
||||
- [ ] Invalid memory:// URLs
|
||||
- [ ] Missing files referenced in database
|
||||
- [ ] Concurrent operations (if possible)
|
||||
- [ ] Invalid project names
|
||||
- [ ] Disk space constraints (if applicable)
|
||||
|
||||
**Performance Testing:**
|
||||
- [ ] Large numbers of notes (100+)
|
||||
- [ ] Complex search queries
|
||||
- [ ] Deep relation chains (5+ levels)
|
||||
- [ ] Rapid successive operations
|
||||
|
||||
### Phase 4: Real-World Workflow Scenarios
|
||||
|
||||
**Objective**: Test realistic usage patterns that users might follow
|
||||
|
||||
**Scenario 1: Meeting Notes Pipeline**
|
||||
1. Create meeting notes with action items
|
||||
2. Extract action items into separate notes
|
||||
3. Link to project planning documents
|
||||
4. Update progress over time using edit operations
|
||||
5. Archive completed items
|
||||
|
||||
**Scenario 2: Research Knowledge Building**
|
||||
1. Create research topic notes
|
||||
2. Build complex relation networks
|
||||
3. Add incremental findings over time
|
||||
4. Search and discover connections
|
||||
5. Reorganize as knowledge grows
|
||||
|
||||
**Scenario 3: Multi-Project Workflow**
|
||||
1. Work project: Technical documentation
|
||||
2. Personal project: Recipe collection
|
||||
3. Learning project: Course notes
|
||||
4. Switch between projects during conversation
|
||||
5. Cross-reference related concepts
|
||||
|
||||
**Scenario 4: Content Evolution**
|
||||
1. Start with basic notes
|
||||
2. Gradually enhance with relations
|
||||
3. Reorganize file structure
|
||||
4. Update existing content incrementally
|
||||
5. Build comprehensive knowledge graph
|
||||
|
||||
### Phase 5: Creative Stress Testing
|
||||
|
||||
**Objective**: Push the system to discover unexpected behaviors
|
||||
|
||||
**Creative Exploration Areas:**
|
||||
- [ ] Rapid project creation and switching
|
||||
- [ ] Unusual but valid markdown structures
|
||||
- [ ] Creative use of observation categories
|
||||
- [ ] Novel relation types and patterns
|
||||
- [ ] Combining tools in unexpected ways
|
||||
|
||||
**Stress Scenarios:**
|
||||
- [ ] Bulk operations (create many notes quickly)
|
||||
- [ ] Complex nested moves and edits
|
||||
- [ ] Deep context building with large graphs
|
||||
- [ ] Search with complex boolean expressions
|
||||
|
||||
## Test Execution Process
|
||||
|
||||
### Pre-Test Checklist
|
||||
- [ ] MCP connection verified
|
||||
- [ ] Test project created
|
||||
- [ ] Baseline notes recorded
|
||||
|
||||
### During Testing
|
||||
1. **Execute test scenarios** using actual MCP tool calls
|
||||
2. **Record observations** immediately in test project
|
||||
3. **Note timestamps** for performance tracking
|
||||
4. **Document any errors** with reproduction steps
|
||||
5. **Explore variations** when something interesting happens
|
||||
|
||||
### Test Observation Format
|
||||
|
||||
Record all observations as Basic Memory notes using this structure:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Test Session YYYY-MM-DD HH:MM
|
||||
tags: [testing, session, v0.13.0]
|
||||
---
|
||||
|
||||
# Test Session YYYY-MM-DD HH:MM
|
||||
|
||||
## Test Focus
|
||||
- Primary objective
|
||||
- Features being tested
|
||||
|
||||
## Observations
|
||||
- [success] Feature X worked as expected #functionality
|
||||
- [performance] Operation Y took 2.3 seconds #timing
|
||||
- [issue] Error with special characters #bug
|
||||
- [enhancement] Could improve UX for scenario Z #improvement
|
||||
|
||||
## Discovered Issues
|
||||
- [bug] Description of problem with reproduction steps
|
||||
- [limitation] Current system boundary encountered
|
||||
|
||||
## Relations
|
||||
- tests [[Feature X]]
|
||||
- part_of [[Manual Testing Suite]]
|
||||
- found_issue [[Bug Report: Special Characters]]
|
||||
```
|
||||
|
||||
### Post-Test Analysis
|
||||
- [ ] Review all test observations
|
||||
- [ ] Create summary report with findings
|
||||
- [ ] Identify patterns in successes/failures
|
||||
- [ ] Generate improvement recommendations
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Quantitative Measures:**
|
||||
- % of test scenarios completed successfully
|
||||
- Number of bugs discovered and documented
|
||||
- Performance benchmarks established
|
||||
- Coverage of all MCP tools and operations
|
||||
|
||||
**Qualitative Measures:**
|
||||
- Natural conversation flow maintained
|
||||
- Knowledge graph quality and connections
|
||||
- User experience insights captured
|
||||
- System reliability under various conditions
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
**For the System:**
|
||||
- Validation of v0.13.0 features in real usage
|
||||
- Discovery of edge cases not covered by unit tests
|
||||
- Performance baseline establishment
|
||||
- Bug identification with reproduction cases
|
||||
|
||||
**For the Knowledge Base:**
|
||||
- Comprehensive testing documentation
|
||||
- Real usage examples for documentation
|
||||
- Edge case scenarios for future reference
|
||||
- Performance insights and optimization opportunities
|
||||
|
||||
**For Development:**
|
||||
- Priority list for bug fixes
|
||||
- Enhancement ideas from real usage
|
||||
- Validation of architectural decisions
|
||||
- User experience insights
|
||||
|
||||
## Test Reporting
|
||||
|
||||
All test results will be captured in the Basic Memory test project, creating a living knowledge base of:
|
||||
|
||||
- Test execution logs with detailed observations
|
||||
- Bug reports with reproduction steps
|
||||
- Performance benchmarks and timing data
|
||||
- Feature enhancement ideas discovered during testing
|
||||
- Knowledge graphs showing test coverage relationships
|
||||
- Summary reports for development team review
|
||||
|
||||
This approach ensures that the testing process itself validates Basic Memory's core value proposition: effectively capturing, organizing, and connecting knowledge through natural interaction patterns.
|
||||
|
||||
## Things to note
|
||||
|
||||
### User Experience & Usability:
|
||||
- are tool instructions clear with working examples?
|
||||
- Do error messages provide actionable guidance for resolution?
|
||||
- Are response times acceptable for interactive use?
|
||||
- Do tools feel consistent in their parameter patterns and behavior?
|
||||
- Can users easily discover what tools are available and their capabilities?
|
||||
|
||||
### System Behavior:
|
||||
- Does context preservation work as expected across tool calls?
|
||||
- Do memory:// URLs behave intuitively for knowledge navigation?
|
||||
- How well do tools work together in multi-step workflows?
|
||||
- Does the system gracefully handle edge cases and invalid inputs?
|
||||
|
||||
### Documentation Alignment:
|
||||
- does tool output provide clear results and helpful information?
|
||||
- Do actual tool behaviors match their documented descriptions?
|
||||
- Are the examples in tool help accurate and useful?
|
||||
- Do real-world usage patterns align with documented workflows?
|
||||
|
||||
### Mental Model Validation:
|
||||
- Does the system work the way users would naturally expect?
|
||||
- Are there surprising behaviors that break user assumptions?
|
||||
- Can users easily recover from mistakes or wrong turns?
|
||||
- Do the knowledge graph concepts (entities, relations, observations) feel natural?
|
||||
|
||||
### Performance & Reliability:
|
||||
- Do operations complete in reasonable time for the data size?
|
||||
- Is system behavior consistent across multiple test sessions?
|
||||
- How does performance change as the knowledge base grows?
|
||||
- Are there any operations that feel unexpectedly slow?
|
||||
|
||||
---
|
||||
|
||||
**Ready to begin testing?** Start by creating the test project and recording your first observation about the testing setup process itself.
|
||||
@@ -77,22 +77,31 @@ read_note("specs/search-design") # By path
|
||||
read_note("memory://specs/search") # By memory URL
|
||||
```
|
||||
|
||||
**Viewing notes as formatted artifacts (Claude Desktop):**
|
||||
```
|
||||
view_note("Search Design") # Creates readable artifact
|
||||
view_note("specs/search-design") # By permalink
|
||||
view_note("memory://specs/search") # By memory URL
|
||||
```
|
||||
|
||||
**Incremental editing** (v0.13.0):
|
||||
```
|
||||
edit_note(
|
||||
identifier="Search Design",
|
||||
identifier="Search Design", # Must be EXACT title/permalink (strict matching)
|
||||
operation="append", # append, prepend, find_replace, replace_section
|
||||
content="\n## New Section\nContent here..."
|
||||
)
|
||||
```
|
||||
**⚠️ Important:** `edit_note` requires exact identifiers (no fuzzy matching). Use `search_notes()` first if uncertain.
|
||||
|
||||
**File organization** (v0.13.0):
|
||||
```
|
||||
move_note(
|
||||
identifier="Old Note",
|
||||
identifier="Old Note", # Must be EXACT title/permalink (strict matching)
|
||||
destination="archive/old-note.md" # Folders created automatically
|
||||
)
|
||||
```
|
||||
**⚠️ Important:** `move_note` requires exact identifiers (no fuzzy matching). Use `search_notes()` first if uncertain.
|
||||
|
||||
### Project Management (v0.13.0)
|
||||
|
||||
@@ -364,6 +373,20 @@ When creating relations:
|
||||
- If information seems outdated, suggest `basic-memory sync`
|
||||
- Use `recent_activity()` to check if content is current
|
||||
|
||||
**Strict Mode for Edit/Move Operations:**
|
||||
- `edit_note()` and `move_note()` require **exact identifiers** (no fuzzy matching for safety)
|
||||
- If identifier not found: use `search_notes()` first to find the exact title/permalink
|
||||
- Error messages will guide you to find correct identifiers
|
||||
- Example workflow:
|
||||
```
|
||||
# ❌ This might fail if identifier isn't exact
|
||||
edit_note("Meeting Note", "append", "content")
|
||||
|
||||
# ✅ Safe approach: search first, then use exact result
|
||||
results = search_notes("meeting")
|
||||
edit_note("Meeting Notes 2024", "append", "content") # Use exact title from search
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Proactively Record Context**
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Basic Memory - Modern Command Runner
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run unit tests in parallel
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v -n auto
|
||||
|
||||
# Run integration tests in parallel
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto test-int
|
||||
|
||||
# Run all tests
|
||||
test: test-unit test-int
|
||||
|
||||
# Lint and fix code
|
||||
lint:
|
||||
ruff check . --fix
|
||||
|
||||
# Type check code
|
||||
type-check:
|
||||
uv run pyright
|
||||
|
||||
# Clean build artifacts and cache files
|
||||
clean:
|
||||
find . -type f -name '*.pyc' -delete
|
||||
find . -type d -name '__pycache__' -exec rm -r {} +
|
||||
rm -rf installer/build/ installer/dist/ dist/
|
||||
rm -f rw.*.dmg .coverage.*
|
||||
|
||||
# Format code with ruff
|
||||
format:
|
||||
uv run ruff format .
|
||||
|
||||
# Run MCP inspector tool
|
||||
run-inspector:
|
||||
npx @modelcontextprotocol/inspector
|
||||
|
||||
# Build macOS installer
|
||||
installer-mac:
|
||||
cd installer && chmod +x make_icons.sh && ./make_icons.sh
|
||||
cd installer && uv run python setup.py bdist_mac
|
||||
|
||||
# Build Windows installer
|
||||
installer-win:
|
||||
cd installer && uv run python setup.py bdist_win32
|
||||
|
||||
# Update all dependencies to latest versions
|
||||
update-deps:
|
||||
uv sync --upgrade
|
||||
|
||||
# Run all code quality checks and tests
|
||||
check: lint format type-check test
|
||||
|
||||
# Generate Alembic migration with descriptive message
|
||||
migration message:
|
||||
cd src/basic_memory/alembic && alembic revision --autogenerate -m "{{message}}"
|
||||
|
||||
# List all available recipes
|
||||
default:
|
||||
@just --list
|
||||
+4
-8
@@ -28,12 +28,12 @@ dependencies = [
|
||||
"watchfiles>=1.0.4",
|
||||
"fastapi[standard]>=0.115.8",
|
||||
"alembic>=1.14.1",
|
||||
"qasync>=0.27.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=2.3.4",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,14 +69,8 @@ dev-dependencies = [
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"ruff>=0.1.6",
|
||||
"pytest>=8.3.4",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"ruff>=0.1.6",
|
||||
"cx-freeze>=7.2.10",
|
||||
"pyqt6>=6.8.1",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
@@ -124,6 +118,8 @@ omit = [
|
||||
"*/background_sync.py", # Background processes
|
||||
"*/cli/main.py", # CLI entry point
|
||||
"*/mcp/tools/project_management.py", # Covered by integration tests
|
||||
"*/mcp/tools/sync_status.py", # Covered by integration tests
|
||||
"*/services/migration_service.py", # Complex migration scenarios
|
||||
]
|
||||
|
||||
[tool.logfire]
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("basic-memory")
|
||||
except Exception: # pragma: no cover
|
||||
# Fallback if package not installed (e.g., during development)
|
||||
__version__ = "0.0.0" # pragma: no cover
|
||||
# API version for FastAPI - independent of package version
|
||||
__version__ = "v0"
|
||||
|
||||
@@ -14,6 +14,7 @@ from basic_memory.deps import (
|
||||
FileServiceDep,
|
||||
ProjectConfigDep,
|
||||
AppConfigDep,
|
||||
SyncServiceDep,
|
||||
)
|
||||
from basic_memory.schemas import (
|
||||
EntityListResponse,
|
||||
@@ -63,6 +64,7 @@ async def create_or_update_entity(
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
sync_service: SyncServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
|
||||
logger.info(
|
||||
@@ -85,6 +87,17 @@ async def create_or_update_entity(
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Attempt immediate relation resolution when creating new entities
|
||||
# This helps resolve forward references when related entities are created in the same session
|
||||
if created:
|
||||
try:
|
||||
await sync_service.resolve_relations()
|
||||
logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
|
||||
except Exception as e: # pragma: no cover
|
||||
# Don't fail the entire request if relation resolution fails
|
||||
logger.warning(f"Failed to resolve relations after entity creation: {e}")
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from dateparser import parse
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
@@ -40,7 +39,7 @@ async def recent(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
@@ -78,7 +77,7 @@ async def get_memory_context(
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe) if timeframe else None
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import APIRouter, HTTPException, Path, Body
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.deps import ProjectServiceDep
|
||||
from basic_memory.deps import ProjectServiceDep, ProjectPathDep
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectList,
|
||||
@@ -22,9 +22,10 @@ project_resource_router = APIRouter(prefix="/projects", tags=["project_managemen
|
||||
@project_router.get("/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info(
|
||||
project_service: ProjectServiceDep,
|
||||
project: ProjectPathDep,
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the current Basic Memory project."""
|
||||
return await project_service.get_project_info()
|
||||
"""Get comprehensive information about the specified Basic Memory project."""
|
||||
return await project_service.get_project_info(project)
|
||||
|
||||
|
||||
# Update a project
|
||||
@@ -47,7 +48,7 @@ async def update_project(
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
# Get original project info for the response
|
||||
old_project = ProjectItem(
|
||||
old_project_info = ProjectItem(
|
||||
name=project_name,
|
||||
path=project_service.projects.get(project_name, ""),
|
||||
)
|
||||
@@ -61,7 +62,7 @@ async def update_project(
|
||||
message=f"Project '{project_name}' updated successfully",
|
||||
status="success",
|
||||
default=(project_name == project_service.default_project),
|
||||
old_project=old_project,
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(name=project_name, path=updated_path),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
|
||||
@@ -5,12 +5,12 @@ It centralizes all prompt formatting logic that was previously in the MCP prompt
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from dateparser import parse
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
ContextServiceDep,
|
||||
EntityRepositoryDep,
|
||||
@@ -51,7 +51,7 @@ async def continue_conversation(
|
||||
f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse(request.timeframe) if request.timeframe else None
|
||||
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
|
||||
@@ -174,7 +174,7 @@ def display_project_info(
|
||||
"""Display detailed information and statistics about the current project."""
|
||||
try:
|
||||
# Get project info
|
||||
info = asyncio.run(project_info())
|
||||
info = asyncio.run(project_info.fn()) # type: ignore # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
if json_output:
|
||||
# Convert to JSON and print
|
||||
@@ -221,7 +221,7 @@ def display_project_info(
|
||||
console.print(entity_types_table)
|
||||
|
||||
# Most connected entities
|
||||
if info.statistics.most_connected_entities:
|
||||
if info.statistics.most_connected_entities: # pragma: no cover
|
||||
connected_table = Table(title="🔗 Most Connected Entities")
|
||||
connected_table.add_column("Title", style="blue")
|
||||
connected_table.add_column("Permalink", style="cyan")
|
||||
@@ -235,7 +235,7 @@ def display_project_info(
|
||||
console.print(connected_table)
|
||||
|
||||
# Recent activity
|
||||
if info.activity.recently_updated:
|
||||
if info.activity.recently_updated: # pragma: no cover
|
||||
recent_table = Table(title="🕒 Recent Activity")
|
||||
recent_table.add_column("Title", style="blue")
|
||||
recent_table.add_column("Type", style="cyan")
|
||||
|
||||
@@ -122,7 +122,7 @@ def display_changes(project_name: str, title: str, changes: SyncReport, verbose:
|
||||
console.print(Panel(tree, expand=False))
|
||||
|
||||
|
||||
async def run_status(verbose: bool = False):
|
||||
async def run_status(verbose: bool = False): # pragma: no cover
|
||||
"""Check sync status of files vs database."""
|
||||
# Check knowledge/ directory
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ async def run_sync(verbose: bool = False):
|
||||
sync_service = await get_sync_service(project)
|
||||
|
||||
logger.info("Running one-time sync")
|
||||
knowledge_changes = await sync_service.sync(config.home)
|
||||
knowledge_changes = await sync_service.sync(config.home, project_name=project.name)
|
||||
|
||||
# Log results
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
@@ -90,7 +90,7 @@ def write_note(
|
||||
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
note = asyncio.run(mcp_write_note(title, content, folder, tags))
|
||||
note = asyncio.run(mcp_write_note.fn(title, content, folder, tags))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
@@ -103,7 +103,7 @@ def write_note(
|
||||
def read_note(identifier: str, page: int = 1, page_size: int = 10):
|
||||
"""Read a markdown note from the knowledge base."""
|
||||
try:
|
||||
note = asyncio.run(mcp_read_note(identifier, page, page_size))
|
||||
note = asyncio.run(mcp_read_note.fn(identifier, page, page_size))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
@@ -124,7 +124,7 @@ def build_context(
|
||||
"""Get context needed to continue a discussion."""
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_build_context(
|
||||
mcp_build_context.fn(
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
@@ -157,7 +157,7 @@ def recent_activity(
|
||||
"""Get recent activity across the knowledge base."""
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_recent_activity(
|
||||
mcp_recent_activity.fn(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
@@ -210,7 +210,7 @@ def search_notes(
|
||||
search_type = "text" if search_type is None else search_type
|
||||
|
||||
results = asyncio.run(
|
||||
mcp_search(
|
||||
mcp_search.fn(
|
||||
query,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
@@ -241,7 +241,7 @@ def continue_conversation(
|
||||
"""Prompt to continue a previous conversation or work session."""
|
||||
try:
|
||||
# Prompt functions return formatted strings directly
|
||||
session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
|
||||
session = asyncio.run(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
|
||||
rprint(session)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
|
||||
@@ -10,10 +10,12 @@ from basic_memory.mcp.prompts import continue_conversation
|
||||
from basic_memory.mcp.prompts import recent_activity
|
||||
from basic_memory.mcp.prompts import search
|
||||
from basic_memory.mcp.prompts import ai_assistant_guide
|
||||
from basic_memory.mcp.prompts import sync_status
|
||||
|
||||
__all__ = [
|
||||
"ai_assistant_guide",
|
||||
"continue_conversation",
|
||||
"recent_activity",
|
||||
"search",
|
||||
"sync_status",
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@ async def recent_activity_prompt(
|
||||
"""
|
||||
logger.info(f"Getting recent activity, timeframe: {timeframe}")
|
||||
|
||||
recent = await recent_activity(timeframe=timeframe, type=[SearchItemType.ENTITY])
|
||||
recent = await recent_activity.fn(timeframe=timeframe, type=[SearchItemType.ENTITY])
|
||||
|
||||
# Extract primary results from the hierarchical structure
|
||||
primary_results = []
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Sync status prompt for Basic Memory MCP server."""
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
description="""Get sync status with recommendations for AI assistants.
|
||||
|
||||
This prompt provides both current sync status and guidance on how
|
||||
AI assistants should respond when sync operations are in progress or completed.
|
||||
""",
|
||||
)
|
||||
async def sync_status_prompt() -> str:
|
||||
"""Get sync status with AI assistant guidance.
|
||||
|
||||
This prompt provides detailed sync status information along with
|
||||
recommendations for how AI assistants should handle different sync states.
|
||||
|
||||
Returns:
|
||||
Formatted sync status with AI assistant guidance
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
state = migration_manager.state
|
||||
|
||||
# Build status report
|
||||
lines = [
|
||||
"# Basic Memory Sync Status",
|
||||
"",
|
||||
f"**Current Status**: {state.status.value.replace('_', ' ').title()}",
|
||||
f"**System Ready**: {'Yes' if migration_manager.is_ready else 'No'}",
|
||||
"",
|
||||
]
|
||||
|
||||
if migration_manager.is_ready:
|
||||
lines.extend(
|
||||
[
|
||||
"✅ **All sync operations completed** - System is fully operational",
|
||||
"",
|
||||
"All Basic Memory tools are available and functioning normally.",
|
||||
"File indexing is complete and knowledge graphs are up to date.",
|
||||
"You can proceed with any knowledge management tasks.",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.append(f"**Status Message**: {state.message}")
|
||||
|
||||
if state.status.value == "in_progress":
|
||||
if state.projects_total > 0:
|
||||
progress = f" ({state.projects_migrated}/{state.projects_total})"
|
||||
lines.append(f"**Progress**: {progress}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"🔄 **File synchronization in progress** - Processing files and building knowledge graphs",
|
||||
"",
|
||||
"**Impact**: Some tools may show status messages instead of normal responses",
|
||||
"until sync completes (usually 1-3 minutes).",
|
||||
]
|
||||
)
|
||||
|
||||
elif state.status.value == "failed":
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"❌ **Sync failed**: {state.error or 'Unknown error'}",
|
||||
"",
|
||||
"**Impact**: System may have limited functionality until issue is resolved.",
|
||||
]
|
||||
)
|
||||
|
||||
# Add AI assistant recommendations
|
||||
if not migration_manager.is_ready:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## AI Assistant Recommendations",
|
||||
"",
|
||||
"**When sync is in progress:**",
|
||||
"- Inform the user about the background file processing",
|
||||
"- Suggest using `sync_status()` tool to check progress",
|
||||
"- Explain that tools will work normally once sync completes",
|
||||
"- Avoid creating complex workflows until sync is done",
|
||||
"",
|
||||
"**What to tell users:**",
|
||||
"- 'Basic Memory is processing your files and building knowledge graphs'",
|
||||
"- 'This usually takes 1-3 minutes depending on your content size'",
|
||||
"- 'You can check progress anytime with the sync_status tool'",
|
||||
"- 'Full functionality will be available once processing completes'",
|
||||
"",
|
||||
"**User-friendly language:**",
|
||||
"- Say 'processing files' instead of 'migration' or 'sync'",
|
||||
"- Say 'building knowledge graphs' instead of 'indexing'",
|
||||
"- Say 'setting up your knowledge base' instead of 'running migrations'",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
return f"""# Sync Status - Error
|
||||
|
||||
❌ **Unable to check sync status**: {str(e)}
|
||||
|
||||
## AI Assistant Recommendations
|
||||
|
||||
**When status is unavailable:**
|
||||
- Assume the system is likely working normally
|
||||
- Try proceeding with normal operations
|
||||
- If users report issues, suggest checking logs or restarting
|
||||
- Use user-friendly language about 'setting up the knowledge base'
|
||||
"""
|
||||
@@ -31,23 +31,23 @@ load_dotenv()
|
||||
@dataclass
|
||||
class AppContext:
|
||||
watch_task: Optional[asyncio.Task]
|
||||
migration_manager: Optional[Any] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
watch_task = await initialize_app(app_config)
|
||||
# Initialize on startup (now returns migration_manager)
|
||||
migration_manager = await initialize_app(app_config)
|
||||
|
||||
# Initialize project session with default project
|
||||
session.initialize(app_config.default_project)
|
||||
|
||||
try:
|
||||
yield AppContext(watch_task=watch_task)
|
||||
yield AppContext(watch_task=None, migration_manager=migration_manager)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
if watch_task:
|
||||
watch_task.cancel()
|
||||
# Cleanup on shutdown - migration tasks will be cancelled automatically
|
||||
pass
|
||||
|
||||
|
||||
# OAuth configuration function
|
||||
|
||||
@@ -11,12 +11,14 @@ from basic_memory.mcp.tools.read_content import read_content
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.canvas import canvas
|
||||
from basic_memory.mcp.tools.list_directory import list_directory
|
||||
from basic_memory.mcp.tools.edit_note import edit_note
|
||||
from basic_memory.mcp.tools.move_note import move_note
|
||||
from basic_memory.mcp.tools.sync_status import sync_status
|
||||
from basic_memory.mcp.tools.project_management import (
|
||||
list_projects,
|
||||
switch_project,
|
||||
@@ -43,5 +45,7 @@ __all__ = [
|
||||
"search_notes",
|
||||
"set_default_project",
|
||||
"switch_project",
|
||||
"sync_status",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -13,7 +13,6 @@ from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
MemoryUrl,
|
||||
memory_url_path,
|
||||
normalize_memory_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,12 +20,17 @@ from basic_memory.schemas.memory import (
|
||||
description="""Build context from a memory:// URI to continue conversations naturally.
|
||||
|
||||
Use this to follow up on previous discussions or explore related topics.
|
||||
|
||||
Memory URL Format:
|
||||
- Use paths like "folder/note" or "memory://folder/note"
|
||||
- Pattern matching: "folder/*" matches all notes in folder
|
||||
- Valid characters: letters, numbers, hyphens, underscores, forward slashes
|
||||
- Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|)
|
||||
- Examples: "specs/search", "projects/basic-memory", "notes/*"
|
||||
|
||||
Timeframes support natural language like:
|
||||
- "2 days ago"
|
||||
- "last week"
|
||||
- "today"
|
||||
- "3 months ago"
|
||||
Or standard formats like "7d", "24h"
|
||||
- "2 days ago", "last week", "today", "3 months ago"
|
||||
- Or standard formats like "7d", "24h"
|
||||
""",
|
||||
)
|
||||
async def build_context(
|
||||
@@ -76,7 +80,28 @@ async def build_context(
|
||||
build_context("memory://specs/search", project="work-project")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
# URL is already validated and normalized by MemoryUrl type annotation
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
if migration_status: # pragma: no cover
|
||||
# Return a proper GraphContext with status message
|
||||
from basic_memory.schemas.memory import MemoryMetadata
|
||||
from datetime import datetime
|
||||
|
||||
return GraphContext(
|
||||
results=[],
|
||||
metadata=MemoryMetadata(
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
generated_at=datetime.now(),
|
||||
primary_count=0,
|
||||
related_count=0,
|
||||
uri=migration_status, # Include status in metadata
|
||||
),
|
||||
)
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
@@ -35,7 +35,8 @@ async def canvas(
|
||||
nodes: List of node objects following JSON Canvas 1.0 spec
|
||||
edges: List of edge objects following JSON Canvas 1.0 spec
|
||||
title: The title of the canvas (will be saved as title.canvas)
|
||||
folder: The folder where the file should be saved
|
||||
folder: Folder path relative to project root where the canvas should be saved.
|
||||
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
|
||||
project: Optional project name to create canvas in. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
@@ -7,8 +10,148 @@ from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
|
||||
|
||||
def _format_delete_error_response(error_message: str, identifier: str) -> str:
|
||||
"""Format helpful error responses for delete failures that guide users to successful deletions."""
|
||||
|
||||
# Note not found errors
|
||||
if "entity not found" in error_message.lower() or "not found" in error_message.lower():
|
||||
search_term = identifier.split("/")[-1] if "/" in identifier else identifier
|
||||
title_format = (
|
||||
identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
|
||||
)
|
||||
permalink_format = identifier.lower().replace(" ", "-")
|
||||
|
||||
return dedent(f"""
|
||||
# Delete Failed - Note Not Found
|
||||
|
||||
The note '{identifier}' could not be found for deletion.
|
||||
|
||||
## This might mean:
|
||||
1. **Already deleted**: The note may have been deleted previously
|
||||
2. **Wrong identifier**: The identifier format might be incorrect
|
||||
3. **Different project**: The note might be in a different project
|
||||
|
||||
## How to verify:
|
||||
1. **Search for the note**: Use `search_notes("{search_term}")` to find it
|
||||
2. **Try different formats**:
|
||||
- If you used a permalink like "folder/note-title", try just the title: "{title_format}"
|
||||
- If you used a title, try the permalink format: "{permalink_format}"
|
||||
|
||||
3. **Check if already deleted**: Use `list_directory("/")` to see what notes exist
|
||||
4. **Check current project**: Use `get_current_project()` to verify you're in the right project
|
||||
|
||||
## If the note actually exists:
|
||||
```
|
||||
# First, find the correct identifier:
|
||||
search_notes("{identifier}")
|
||||
|
||||
# Then delete using the correct identifier:
|
||||
delete_note("correct-identifier-from-search")
|
||||
```
|
||||
|
||||
## If you want to delete multiple similar notes:
|
||||
Use search to find all related notes and delete them one by one.
|
||||
""").strip()
|
||||
|
||||
# Permission/access errors
|
||||
if (
|
||||
"permission" in error_message.lower()
|
||||
or "access" in error_message.lower()
|
||||
or "forbidden" in error_message.lower()
|
||||
):
|
||||
return f"""# Delete Failed - Permission Error
|
||||
|
||||
You don't have permission to delete '{identifier}': {error_message}
|
||||
|
||||
## How to resolve:
|
||||
1. **Check permissions**: Verify you have delete/write access to this project
|
||||
2. **File locks**: The note might be open in another application
|
||||
3. **Project access**: Ensure you're in the correct project with proper permissions
|
||||
|
||||
## Alternative actions:
|
||||
- Check current project: `get_current_project()`
|
||||
- Switch to correct project: `switch_project("project-name")`
|
||||
- Verify note exists first: `read_note("{identifier}")`
|
||||
|
||||
## If you have read-only access:
|
||||
Send a message to support@basicmachines.co to request deletion, or ask someone with write access to delete the note."""
|
||||
|
||||
# Server/filesystem errors
|
||||
if (
|
||||
"server error" in error_message.lower()
|
||||
or "filesystem" in error_message.lower()
|
||||
or "disk" in error_message.lower()
|
||||
):
|
||||
return f"""# Delete Failed - System Error
|
||||
|
||||
A system error occurred while deleting '{identifier}': {error_message}
|
||||
|
||||
## Immediate steps:
|
||||
1. **Try again**: The error might be temporary
|
||||
2. **Check file status**: Verify the file isn't locked or in use
|
||||
3. **Check disk space**: Ensure the system has adequate storage
|
||||
|
||||
## Troubleshooting:
|
||||
- Verify note exists: `read_note("{identifier}")`
|
||||
- Check project status: `get_current_project()`
|
||||
- Try again in a few moments
|
||||
|
||||
## If problem persists:
|
||||
Send a message to support@basicmachines.co - there may be a filesystem or database issue."""
|
||||
|
||||
# Database/sync errors
|
||||
if "database" in error_message.lower() or "sync" in error_message.lower():
|
||||
return f"""# Delete Failed - Database Error
|
||||
|
||||
A database error occurred while deleting '{identifier}': {error_message}
|
||||
|
||||
## This usually means:
|
||||
1. **Sync conflict**: The file system and database are out of sync
|
||||
2. **Database lock**: Another operation is accessing the database
|
||||
3. **Corrupted entry**: The database entry might be corrupted
|
||||
|
||||
## Steps to resolve:
|
||||
1. **Try again**: Wait a moment and retry the deletion
|
||||
2. **Check note status**: `read_note("{identifier}")` to see current state
|
||||
3. **Manual verification**: Use `list_directory()` to see if file still exists
|
||||
|
||||
## If the note appears gone but database shows it exists:
|
||||
Send a message to support@basicmachines.co - a manual database cleanup may be needed."""
|
||||
|
||||
# Generic fallback
|
||||
return f"""# Delete Failed
|
||||
|
||||
Error deleting note '{identifier}': {error_message}
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
|
||||
2. **Check permissions**: Ensure you can edit/delete files in this project
|
||||
3. **Try again**: The error might be temporary
|
||||
4. **Check project**: Make sure you're in the correct project
|
||||
|
||||
## Step-by-step approach:
|
||||
```
|
||||
# 1. Confirm note exists and get correct identifier
|
||||
search_notes("{identifier}")
|
||||
|
||||
# 2. Read the note to verify access
|
||||
read_note("correct-identifier-from-search")
|
||||
|
||||
# 3. Try deletion with correct identifier
|
||||
delete_note("correct-identifier-from-search")
|
||||
```
|
||||
|
||||
## Alternative approaches:
|
||||
- Check what notes exist: `list_directory("/")`
|
||||
- Verify current project: `get_current_project()`
|
||||
- Switch projects if needed: `switch_project("correct-project")`
|
||||
|
||||
## Need help?
|
||||
If the note should be deleted but the operation keeps failing, send a message to support@basicmachines.co."""
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
async def delete_note(identifier: str, project: Optional[str] = None) -> bool:
|
||||
async def delete_note(identifier: str, project: Optional[str] = None) -> bool | str:
|
||||
"""Delete a note from the knowledge base.
|
||||
|
||||
Args:
|
||||
@@ -31,6 +174,18 @@ async def delete_note(identifier: str, project: Optional[str] = None) -> bool:
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
try:
|
||||
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
if result.deleted:
|
||||
logger.info(f"Successfully deleted note: {identifier}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
|
||||
return False
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Delete failed for '{identifier}': {e}")
|
||||
# Return formatted error message for better user experience
|
||||
return _format_delete_error_response(str(e), identifier)
|
||||
|
||||
@@ -24,14 +24,14 @@ def _format_error_response(
|
||||
if "Entity not found" in error_message or "entity not found" in error_message.lower():
|
||||
return f"""# Edit Failed - Note Not Found
|
||||
|
||||
The note with identifier '{identifier}' could not be found.
|
||||
The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
|
||||
|
||||
## Suggestions to try:
|
||||
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
|
||||
2. **Try different identifier formats**:
|
||||
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
||||
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
|
||||
- Use `read_note()` first to verify the note exists and get the correct identifiers
|
||||
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
|
||||
2. **Try different exact identifier formats**:
|
||||
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
||||
- If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
|
||||
- Use `read_note()` first to verify the note exists and get the exact identifier
|
||||
|
||||
## Alternative approach:
|
||||
Use `write_note()` to create the note first, then edit it."""
|
||||
@@ -142,7 +142,9 @@ async def edit_note(
|
||||
It supports various operations for different editing scenarios.
|
||||
|
||||
Args:
|
||||
identifier: The title, permalink, or memory:// URL of the note to edit
|
||||
identifier: The exact title, permalink, or memory:// URL of the note to edit.
|
||||
Must be an exact match - fuzzy matching is not supported for edit operations.
|
||||
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
||||
operation: The editing operation to perform:
|
||||
- "append": Add content to the end of the note
|
||||
- "prepend": Add content to the beginning of the note
|
||||
@@ -179,10 +181,14 @@ async def edit_note(
|
||||
# Replace subsection with more specific header
|
||||
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
||||
|
||||
# Using different identifier formats
|
||||
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
|
||||
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
|
||||
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
|
||||
# Using different identifier formats (must be exact matches)
|
||||
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # exact title
|
||||
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
|
||||
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # exact folder/title
|
||||
|
||||
# If uncertain about identifier, search first:
|
||||
# search_notes("meeting") # Find available notes
|
||||
# edit_note("docs/meeting-notes-2025", "append", "content") # Use exact result
|
||||
|
||||
# Add new section to document
|
||||
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Move note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
@@ -11,6 +12,203 @@ from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
|
||||
"""Format helpful error responses for move failures that guide users to successful moves."""
|
||||
|
||||
# Note not found errors
|
||||
if "entity not found" in error_message.lower() or "not found" in error_message.lower():
|
||||
search_term = identifier.split("/")[-1] if "/" in identifier else identifier
|
||||
title_format = (
|
||||
identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
|
||||
)
|
||||
permalink_format = identifier.lower().replace(" ", "-")
|
||||
|
||||
return dedent(f"""
|
||||
# Move Failed - Note Not Found
|
||||
|
||||
The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching).
|
||||
|
||||
## Suggestions to try:
|
||||
1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers
|
||||
2. **Try different exact identifier formats**:
|
||||
- If you used a permalink like "folder/note-title", try the exact title: "{title_format}"
|
||||
- If you used a title, try the exact permalink format: "{permalink_format}"
|
||||
- Use `read_note()` first to verify the note exists and get the exact identifier
|
||||
|
||||
3. **Check current project**: Use `get_current_project()` to verify you're in the right project
|
||||
4. **List available notes**: Use `list_directory("/")` to see what notes exist
|
||||
|
||||
## Before trying again:
|
||||
```
|
||||
# First, verify the note exists:
|
||||
search_notes("{identifier}")
|
||||
|
||||
# Then use the exact identifier from search results:
|
||||
move_note("correct-identifier-here", "{destination_path}")
|
||||
```
|
||||
""").strip()
|
||||
|
||||
# Destination already exists errors
|
||||
if "already exists" in error_message.lower() or "file exists" in error_message.lower():
|
||||
return f"""# Move Failed - Destination Already Exists
|
||||
|
||||
Cannot move '{identifier}' to '{destination_path}' because a file already exists at that location.
|
||||
|
||||
## How to resolve:
|
||||
1. **Choose a different destination**: Try a different filename or folder
|
||||
- Add timestamp: `{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md`
|
||||
- Use different folder: `archive/{destination_path}` or `backup/{destination_path}`
|
||||
|
||||
2. **Check the existing file**: Use `read_note("{destination_path}")` to see what's already there
|
||||
3. **Remove or rename existing**: If safe to do so, move the existing file first
|
||||
|
||||
## Try these alternatives:
|
||||
```
|
||||
# Option 1: Add timestamp to make unique
|
||||
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md")
|
||||
|
||||
# Option 2: Use archive folder
|
||||
move_note("{identifier}", "archive/{destination_path}")
|
||||
|
||||
# Option 3: Check what's at destination first
|
||||
read_note("{destination_path}")
|
||||
```"""
|
||||
|
||||
# Invalid path errors
|
||||
if "invalid" in error_message.lower() and "path" in error_message.lower():
|
||||
return f"""# Move Failed - Invalid Destination Path
|
||||
|
||||
The destination path '{destination_path}' is not valid: {error_message}
|
||||
|
||||
## Path requirements:
|
||||
1. **Relative paths only**: Don't start with `/` (use `notes/file.md` not `/notes/file.md`)
|
||||
2. **Include file extension**: Add `.md` for markdown files
|
||||
3. **Use forward slashes**: For folder separators (`folder/subfolder/file.md`)
|
||||
4. **No special characters**: Avoid `\\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`
|
||||
|
||||
## Valid path examples:
|
||||
- `notes/my-note.md`
|
||||
- `projects/2025/meeting-notes.md`
|
||||
- `archive/old-projects/legacy-note.md`
|
||||
|
||||
## Try again with:
|
||||
```
|
||||
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
||||
```"""
|
||||
|
||||
# Permission/access errors
|
||||
if (
|
||||
"permission" in error_message.lower()
|
||||
or "access" in error_message.lower()
|
||||
or "forbidden" in error_message.lower()
|
||||
):
|
||||
return f"""# Move Failed - Permission Error
|
||||
|
||||
You don't have permission to move '{identifier}': {error_message}
|
||||
|
||||
## How to resolve:
|
||||
1. **Check file permissions**: Ensure you have write access to both source and destination
|
||||
2. **Verify project access**: Make sure you have edit permissions for this project
|
||||
3. **Check file locks**: The file might be open in another application
|
||||
|
||||
## Alternative actions:
|
||||
- Check current project: `get_current_project()`
|
||||
- Switch projects if needed: `switch_project("project-name")`
|
||||
- Try copying content instead: `read_note("{identifier}")` then `write_note()` to new location"""
|
||||
|
||||
# Source file not found errors
|
||||
if "source" in error_message.lower() and (
|
||||
"not found" in error_message.lower() or "missing" in error_message.lower()
|
||||
):
|
||||
return f"""# Move Failed - Source File Missing
|
||||
|
||||
The source file for '{identifier}' was not found on disk: {error_message}
|
||||
|
||||
This usually means the database and filesystem are out of sync.
|
||||
|
||||
## How to resolve:
|
||||
1. **Check if note exists in database**: `read_note("{identifier}")`
|
||||
2. **Run sync operation**: The file might need to be re-synced
|
||||
3. **Recreate the file**: If data exists in database, recreate the physical file
|
||||
|
||||
## Troubleshooting steps:
|
||||
```
|
||||
# Check if note exists in Basic Memory
|
||||
read_note("{identifier}")
|
||||
|
||||
# If it exists, the file is missing on disk - send a message to support@basicmachines.co
|
||||
# If it doesn't exist, use search to find the correct identifier
|
||||
search_notes("{identifier}")
|
||||
```"""
|
||||
|
||||
# Server/filesystem errors
|
||||
if (
|
||||
"server error" in error_message.lower()
|
||||
or "filesystem" in error_message.lower()
|
||||
or "disk" in error_message.lower()
|
||||
):
|
||||
return f"""# Move Failed - System Error
|
||||
|
||||
A system error occurred while moving '{identifier}': {error_message}
|
||||
|
||||
## Immediate steps:
|
||||
1. **Try again**: The error might be temporary
|
||||
2. **Check disk space**: Ensure adequate storage is available
|
||||
3. **Verify filesystem permissions**: Check if the destination directory is writable
|
||||
|
||||
## Alternative approaches:
|
||||
- Copy content to new location: Use `read_note("{identifier}")` then `write_note()`
|
||||
- Use a different destination folder that you know works
|
||||
- Send a message to support@basicmachines.co if the problem persists
|
||||
|
||||
## Backup approach:
|
||||
```
|
||||
# Read current content
|
||||
content = read_note("{identifier}")
|
||||
|
||||
# Create new note at desired location
|
||||
write_note("New Note Title", content, "{destination_path.split("/")[0] if "/" in destination_path else "notes"}")
|
||||
|
||||
# Then delete original if successful
|
||||
delete_note("{identifier}")
|
||||
```"""
|
||||
|
||||
# Generic fallback
|
||||
return f"""# Move Failed
|
||||
|
||||
Error moving '{identifier}' to '{destination_path}': {error_message}
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
|
||||
2. **Check destination path**: Ensure it's a valid relative path with `.md` extension
|
||||
3. **Verify permissions**: Make sure you can edit files in this project
|
||||
4. **Try a simpler path**: Use a basic folder structure like `notes/filename.md`
|
||||
|
||||
## Step-by-step approach:
|
||||
```
|
||||
# 1. Confirm note exists
|
||||
read_note("{identifier}")
|
||||
|
||||
# 2. Try a simple destination first
|
||||
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
||||
|
||||
# 3. If that works, then try your original destination
|
||||
```
|
||||
|
||||
## Alternative approach:
|
||||
If moving continues to fail, you can copy the content manually:
|
||||
```
|
||||
# Read current content
|
||||
content = read_note("{identifier}")
|
||||
|
||||
# Create new note
|
||||
write_note("Title", content, "target-folder")
|
||||
|
||||
# Delete original once confirmed
|
||||
delete_note("{identifier}")
|
||||
```"""
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Move a note to a new location, updating database and maintaining links.",
|
||||
)
|
||||
@@ -22,7 +220,9 @@ async def move_note(
|
||||
"""Move a note to a new file location within the same project.
|
||||
|
||||
Args:
|
||||
identifier: Entity identifier (title, permalink, or memory:// URL)
|
||||
identifier: Exact entity identifier (title, permalink, or memory:// URL).
|
||||
Must be an exact match - fuzzy matching is not supported for move operations.
|
||||
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
||||
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
||||
project: Optional project name (defaults to current session project)
|
||||
|
||||
@@ -30,9 +230,18 @@ async def move_note(
|
||||
Success message with move details
|
||||
|
||||
Examples:
|
||||
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
|
||||
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
||||
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
|
||||
# Move to new folder (exact title match)
|
||||
move_note("My Note", "work/notes/my-note.md")
|
||||
|
||||
# Move by exact permalink
|
||||
move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
||||
|
||||
# Specify project with exact identifier
|
||||
move_note("My Note", "archive/my-note.md", project="work-project")
|
||||
|
||||
# If uncertain about identifier, search first:
|
||||
# search_notes("my note") # Find available notes
|
||||
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
|
||||
|
||||
Note: This operation moves notes within the specified project only. Moving notes
|
||||
between different projects is not currently supported.
|
||||
@@ -49,39 +258,42 @@ async def move_note(
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Prepare move request
|
||||
move_data = {
|
||||
"identifier": identifier,
|
||||
"destination_path": destination_path,
|
||||
"project": active_project.name,
|
||||
}
|
||||
try:
|
||||
# Prepare move request
|
||||
move_data = {
|
||||
"identifier": identifier,
|
||||
"destination_path": destination_path,
|
||||
"project": active_project.name,
|
||||
}
|
||||
|
||||
# Call the move API endpoint
|
||||
url = f"{project_url}/knowledge/move"
|
||||
response = await call_post(client, url, json=move_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
# Call the move API endpoint
|
||||
url = f"{project_url}/knowledge/move"
|
||||
response = await call_post(client, url, json=move_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# 10. Build success message
|
||||
result_lines = [
|
||||
"✅ Note moved successfully",
|
||||
"",
|
||||
f"📁 **{identifier}** → **{result.file_path}**",
|
||||
f"🔗 Permalink: {result.permalink}",
|
||||
"📊 Database and search index updated",
|
||||
"",
|
||||
f"<!-- Project: {active_project.name} -->",
|
||||
]
|
||||
# Build success message
|
||||
result_lines = [
|
||||
"✅ Note moved successfully",
|
||||
"",
|
||||
f"📁 **{identifier}** → **{result.file_path}**",
|
||||
f"🔗 Permalink: {result.permalink}",
|
||||
"📊 Database and search index updated",
|
||||
"",
|
||||
f"<!-- Project: {active_project.name} -->",
|
||||
]
|
||||
|
||||
# Return the response text which contains the formatted success message
|
||||
result = "\n".join(result_lines)
|
||||
# Log the operation
|
||||
logger.info(
|
||||
"Move note completed",
|
||||
identifier=identifier,
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
# Log the operation
|
||||
logger.info(
|
||||
"Move note completed",
|
||||
identifier=identifier,
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
|
||||
# Return formatted error message for better user experience
|
||||
return _format_move_error_response(str(e), identifier, destination_path)
|
||||
|
||||
@@ -4,6 +4,8 @@ These tools allow users to switch between projects, list available projects,
|
||||
and manage project context during conversations.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
|
||||
@@ -94,7 +96,11 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
|
||||
# Get project info to show summary
|
||||
try:
|
||||
response = await call_get(client, f"{project_config.project_url}/project/info")
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_config.project_url}/project/info",
|
||||
params={"project_name": project_name},
|
||||
)
|
||||
project_info = ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ Switched to {project_name} project\n\n"
|
||||
@@ -115,7 +121,29 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
logger.error(f"Error switching to project {project_name}: {e}")
|
||||
# Revert to previous project on error
|
||||
session.set_current_project(current_project)
|
||||
raise e
|
||||
|
||||
# Return user-friendly error message instead of raising exception
|
||||
return dedent(f"""
|
||||
# Project Switch Failed
|
||||
|
||||
Could not switch to project '{project_name}': {str(e)}
|
||||
|
||||
## Current project: {current_project}
|
||||
Your session remains on the previous project.
|
||||
|
||||
## Troubleshooting:
|
||||
1. **Check available projects**: Use `list_projects()` to see valid project names
|
||||
2. **Verify spelling**: Ensure the project name is spelled correctly
|
||||
3. **Check permissions**: Verify you have access to the requested project
|
||||
4. **Try again**: The error might be temporary
|
||||
|
||||
## Available options:
|
||||
- See all projects: `list_projects()`
|
||||
- Stay on current project: `get_current_project()`
|
||||
- Try different project: `switch_project("correct-project-name")`
|
||||
|
||||
If the project should exist but isn't listed, send a message to support@basicmachines.co.
|
||||
""").strip()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -139,7 +167,11 @@ async def get_current_project(ctx: Context | None = None) -> str:
|
||||
result = f"Current project: {current_project}\n\n"
|
||||
|
||||
# get project stats
|
||||
response = await call_get(client, f"{project_config.project_url}/project/info")
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_config.project_url}/project/info",
|
||||
params={"project_name": current_project},
|
||||
)
|
||||
project_info = ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
result += f"• {project_info.statistics.total_entities} entities\n"
|
||||
|
||||
@@ -52,6 +52,13 @@ async def read_note(
|
||||
read_note("Meeting Notes", project="work-project")
|
||||
"""
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
if migration_status: # pragma: no cover
|
||||
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
@@ -74,7 +81,7 @@ async def read_note(
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await search_notes(query=identifier, search_type="title", project=project)
|
||||
title_results = await search_notes.fn(query=identifier, search_type="title", project=project)
|
||||
|
||||
if title_results and title_results.results:
|
||||
result = title_results.results[0] # Get the first/best match
|
||||
@@ -98,7 +105,7 @@ async def read_note(
|
||||
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await search_notes(query=identifier, search_type="text", project=project)
|
||||
text_results = await search_notes.fn(query=identifier, search_type="text", project=project)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
if not text_results or not text_results.results:
|
||||
@@ -114,7 +121,7 @@ def format_not_found_message(identifier: str) -> str:
|
||||
return dedent(f"""
|
||||
# Note Not Found: "{identifier}"
|
||||
|
||||
I couldn't find any notes matching "{identifier}". Here are some suggestions:
|
||||
I searched for "{identifier}" using multiple methods (direct lookup, title search, and text search) but couldn't find any matching notes. Here are some suggestions:
|
||||
|
||||
## Check Identifier Type
|
||||
- If you provided a title, try using the exact permalink instead
|
||||
@@ -160,7 +167,7 @@ def format_related_results(identifier: str, results) -> str:
|
||||
message = dedent(f"""
|
||||
# Note Not Found: "{identifier}"
|
||||
|
||||
I couldn't find an exact match for "{identifier}", but I found some related notes:
|
||||
I searched for "{identifier}" using direct lookup and title search but couldn't find an exact match. However, I found some related notes through text search:
|
||||
|
||||
""")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
@@ -11,6 +12,162 @@ from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
def _format_search_error_response(error_message: str, query: str, search_type: str = "text") -> str:
|
||||
"""Format helpful error responses for search failures that guide users to successful searches."""
|
||||
|
||||
# FTS5 syntax errors
|
||||
if "syntax error" in error_message.lower() or "fts5" in error_message.lower():
|
||||
clean_query = (
|
||||
query.replace('"', "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("+", "")
|
||||
.replace("*", "")
|
||||
)
|
||||
return dedent(f"""
|
||||
# Search Failed - Invalid Syntax
|
||||
|
||||
The search query '{query}' contains invalid syntax that the search engine cannot process.
|
||||
|
||||
## Common syntax issues:
|
||||
1. **Special characters**: Characters like `+`, `*`, `"`, `(`, `)` have special meaning in search
|
||||
2. **Unmatched quotes**: Make sure quotes are properly paired
|
||||
3. **Invalid operators**: Check AND, OR, NOT operators are used correctly
|
||||
|
||||
## How to fix:
|
||||
1. **Simplify your search**: Try using simple words instead: `{clean_query}`
|
||||
2. **Remove special characters**: Use alphanumeric characters and spaces
|
||||
3. **Use basic boolean operators**: `word1 AND word2`, `word1 OR word2`, `word1 NOT word2`
|
||||
|
||||
## Examples of valid searches:
|
||||
- Simple text: `project planning`
|
||||
- Boolean AND: `project AND planning`
|
||||
- Boolean OR: `meeting OR discussion`
|
||||
- Boolean NOT: `project NOT archived`
|
||||
- Grouped: `(project OR planning) AND notes`
|
||||
|
||||
## Try again with:
|
||||
```
|
||||
search_notes("INSERT_CLEAN_QUERY_HERE")
|
||||
```
|
||||
|
||||
Replace INSERT_CLEAN_QUERY_HERE with your simplified search terms.
|
||||
""").strip()
|
||||
|
||||
# Project not found errors (check before general "not found")
|
||||
if "project not found" in error_message.lower():
|
||||
return dedent(f"""
|
||||
# Search Failed - Project Not Found
|
||||
|
||||
The current project is not accessible or doesn't exist: {error_message}
|
||||
|
||||
## How to resolve:
|
||||
1. **Check available projects**: `list_projects()`
|
||||
2. **Switch to valid project**: `switch_project("valid-project-name")`
|
||||
3. **Verify project setup**: Ensure your project is properly configured
|
||||
|
||||
## Current session info:
|
||||
- Check current project: `get_current_project()`
|
||||
- See available projects: `list_projects()`
|
||||
""").strip()
|
||||
|
||||
# No results found
|
||||
if "no results" in error_message.lower() or "not found" in error_message.lower():
|
||||
simplified_query = (
|
||||
" ".join(query.split()[:2])
|
||||
if len(query.split()) > 2
|
||||
else query.split()[0]
|
||||
if query.split()
|
||||
else "notes"
|
||||
)
|
||||
return dedent(f"""
|
||||
# Search Complete - No Results Found
|
||||
|
||||
No content found matching '{query}' in the current project.
|
||||
|
||||
## Suggestions to try:
|
||||
1. **Broaden your search**: Try fewer or more general terms
|
||||
- Instead of: `{query}`
|
||||
- Try: `{simplified_query}`
|
||||
|
||||
2. **Check spelling**: Verify terms are spelled correctly
|
||||
3. **Try different search types**:
|
||||
- Text search: `search_notes("{query}", search_type="text")`
|
||||
- Title search: `search_notes("{query}", search_type="title")`
|
||||
- Permalink search: `search_notes("{query}", search_type="permalink")`
|
||||
|
||||
4. **Use boolean operators**:
|
||||
- Try OR search for broader results
|
||||
|
||||
## Check what content exists:
|
||||
- Recent activity: `recent_activity(timeframe="7d")`
|
||||
- List files: `list_directory("/")`
|
||||
- Browse by folder: `list_directory("/notes")` or `list_directory("/docs")`
|
||||
""").strip()
|
||||
|
||||
# Server/API errors
|
||||
if "server error" in error_message.lower() or "internal" in error_message.lower():
|
||||
return dedent(f"""
|
||||
# Search Failed - Server Error
|
||||
|
||||
The search service encountered an error while processing '{query}': {error_message}
|
||||
|
||||
## Immediate steps:
|
||||
1. **Try again**: The error might be temporary
|
||||
2. **Simplify the query**: Use simpler search terms
|
||||
3. **Check project status**: Ensure your project is properly synced
|
||||
|
||||
## Alternative approaches:
|
||||
- Browse files directly: `list_directory("/")`
|
||||
- Check recent activity: `recent_activity(timeframe="7d")`
|
||||
- Try a different search type: `search_notes("{query}", search_type="title")`
|
||||
|
||||
## If the problem persists:
|
||||
The search index might need to be rebuilt. Send a message to support@basicmachines.co or check the project sync status.
|
||||
""").strip()
|
||||
|
||||
# Permission/access errors
|
||||
if (
|
||||
"permission" in error_message.lower()
|
||||
or "access" in error_message.lower()
|
||||
or "forbidden" in error_message.lower()
|
||||
):
|
||||
return f"""# Search Failed - Access Error
|
||||
|
||||
You don't have permission to search in the current project: {error_message}
|
||||
|
||||
## How to resolve:
|
||||
1. **Check your project access**: Verify you have read permissions for this project
|
||||
2. **Switch projects**: Try searching in a different project you have access to
|
||||
3. **Check authentication**: You might need to re-authenticate
|
||||
|
||||
## Alternative actions:
|
||||
- List available projects: `list_projects()`
|
||||
- Switch to accessible project: `switch_project("project-name")`
|
||||
- Check current project: `get_current_project()`"""
|
||||
|
||||
# Generic fallback
|
||||
return f"""# Search Failed
|
||||
|
||||
Error searching for '{query}': {error_message}
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Check your query**: Ensure it uses valid search syntax
|
||||
2. **Try simpler terms**: Use basic words without special characters
|
||||
3. **Verify project access**: Make sure you can access the current project
|
||||
4. **Check recent activity**: `recent_activity(timeframe="7d")` to see if content exists
|
||||
|
||||
## Alternative approaches:
|
||||
- Browse files: `list_directory("/")`
|
||||
- Try different search type: `search_notes("{query}", search_type="title")`
|
||||
- Search with filters: `search_notes("{query}", types=["entity"])`
|
||||
|
||||
## Need help?
|
||||
- View recent changes: `recent_activity()`
|
||||
- List projects: `list_projects()`
|
||||
- Check current project: `get_current_project()`"""
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base.",
|
||||
)
|
||||
@@ -23,7 +180,7 @@ async def search_notes(
|
||||
entity_types: Optional[List[str]] = None,
|
||||
after_date: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> SearchResponse:
|
||||
) -> SearchResponse | str:
|
||||
"""Search across all content in the knowledge base.
|
||||
|
||||
This tool searches the knowledge base using full-text search, pattern matching,
|
||||
@@ -113,10 +270,25 @@ async def search_notes(
|
||||
project_url = active_project.project_url
|
||||
|
||||
logger.info(f"Searching for {search_query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
f"{project_url}/search/",
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"{project_url}/search/",
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
result = SearchResponse.model_validate(response.json())
|
||||
|
||||
# Check if we got no results and provide helpful guidance
|
||||
if not result.results:
|
||||
logger.info(f"Search returned no results for query: {query}")
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed for query '{query}': {e}")
|
||||
# Return formatted error message as string for better user experience
|
||||
return _format_search_error_response(str(e), query, search_type)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Sync status tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
|
||||
|
||||
def _get_all_projects_status() -> list[str]:
|
||||
"""Get status lines for all configured projects."""
|
||||
status_lines = []
|
||||
|
||||
try:
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
if app_config.projects:
|
||||
status_lines.extend(["", "---", "", "**All Projects Status:**"])
|
||||
|
||||
for project_name, project_path in app_config.projects.items():
|
||||
# Check if this project has sync status
|
||||
project_sync_status = sync_status_tracker.get_project_status(project_name)
|
||||
|
||||
if project_sync_status:
|
||||
# Project has tracked sync activity
|
||||
if project_sync_status.status.value == "watching":
|
||||
# Project is actively watching for changes (steady state)
|
||||
status_icon = "👁️"
|
||||
status_text = "Watching for changes"
|
||||
elif project_sync_status.status.value == "completed":
|
||||
# Sync completed but not yet watching - transitional state
|
||||
status_icon = "✅"
|
||||
status_text = "Sync completed"
|
||||
elif project_sync_status.status.value in ["scanning", "syncing"]:
|
||||
status_icon = "🔄"
|
||||
status_text = "Sync in progress"
|
||||
if project_sync_status.files_total > 0:
|
||||
progress_pct = (
|
||||
project_sync_status.files_processed
|
||||
/ project_sync_status.files_total
|
||||
) * 100
|
||||
status_text += f" ({project_sync_status.files_processed}/{project_sync_status.files_total}, {progress_pct:.0f}%)"
|
||||
elif project_sync_status.status.value == "failed":
|
||||
status_icon = "❌"
|
||||
status_text = f"Sync error: {project_sync_status.error or 'Unknown error'}"
|
||||
else:
|
||||
status_icon = "⏸️"
|
||||
status_text = project_sync_status.status.value.title()
|
||||
else:
|
||||
# Project has no tracked sync activity - will be synced automatically
|
||||
status_icon = "⏳"
|
||||
status_text = "Pending sync"
|
||||
|
||||
status_lines.append(f"- {status_icon} **{project_name}**: {status_text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get project config for comprehensive status: {e}")
|
||||
|
||||
return status_lines
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="""Check the status of file synchronization and background operations.
|
||||
|
||||
Use this tool to:
|
||||
- Check if file sync is in progress or completed
|
||||
- Get detailed sync progress information
|
||||
- Understand if your files are fully indexed
|
||||
- Get specific error details if sync operations failed
|
||||
- Monitor initial project setup and legacy migration
|
||||
|
||||
This covers all sync operations including:
|
||||
- Initial project setup and file indexing
|
||||
- Legacy project migration to unified database
|
||||
- Ongoing file monitoring and updates
|
||||
- Background processing of knowledge graphs
|
||||
""",
|
||||
)
|
||||
async def sync_status(project: Optional[str] = None) -> str:
|
||||
"""Get current sync status and system readiness information.
|
||||
|
||||
This tool provides detailed information about any ongoing or completed
|
||||
sync operations, helping users understand when their files are ready.
|
||||
|
||||
Args:
|
||||
project: Optional project name to get project-specific context
|
||||
|
||||
Returns:
|
||||
Formatted sync status with progress, readiness, and guidance
|
||||
"""
|
||||
logger.info("MCP tool call tool=sync_status")
|
||||
|
||||
status_lines = []
|
||||
|
||||
try:
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
# Get overall summary
|
||||
summary = sync_status_tracker.get_summary()
|
||||
is_ready = sync_status_tracker.is_ready
|
||||
|
||||
# Header
|
||||
status_lines.extend(
|
||||
[
|
||||
"# Basic Memory Sync Status",
|
||||
"",
|
||||
f"**Current Status**: {summary}",
|
||||
f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if is_ready:
|
||||
status_lines.extend(
|
||||
[
|
||||
"✅ **All sync operations completed**",
|
||||
"",
|
||||
"- File indexing is complete",
|
||||
"- Knowledge graphs are up to date",
|
||||
"- All Basic Memory tools are fully operational",
|
||||
"",
|
||||
"Your knowledge base is ready for use!",
|
||||
]
|
||||
)
|
||||
|
||||
# Show all projects status even when ready
|
||||
status_lines.extend(_get_all_projects_status())
|
||||
else:
|
||||
# System is still processing - show both active and all projects
|
||||
all_sync_projects = sync_status_tracker.get_all_projects()
|
||||
|
||||
active_projects = [
|
||||
p for p in all_sync_projects.values() if p.status.value in ["scanning", "syncing"]
|
||||
]
|
||||
failed_projects = [p for p in all_sync_projects.values() if p.status.value == "failed"]
|
||||
|
||||
if active_projects:
|
||||
status_lines.extend(
|
||||
[
|
||||
"🔄 **File synchronization in progress**",
|
||||
"",
|
||||
"Basic Memory is automatically processing all configured projects and building knowledge graphs.",
|
||||
"This typically takes 1-3 minutes depending on the amount of content.",
|
||||
"",
|
||||
"**Currently Processing:**",
|
||||
]
|
||||
)
|
||||
|
||||
for project_status in active_projects:
|
||||
progress = ""
|
||||
if project_status.files_total > 0:
|
||||
progress_pct = (
|
||||
project_status.files_processed / project_status.files_total
|
||||
) * 100
|
||||
progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
|
||||
|
||||
status_lines.append(
|
||||
f"- **{project_status.project_name}**: {project_status.message}{progress}"
|
||||
)
|
||||
|
||||
status_lines.extend(
|
||||
[
|
||||
"",
|
||||
"**What's happening:**",
|
||||
"- Scanning and indexing markdown files",
|
||||
"- Building entity and relationship graphs",
|
||||
"- Setting up full-text search indexes",
|
||||
"- Processing file changes and updates",
|
||||
"",
|
||||
"**What you can do:**",
|
||||
"- Wait for automatic processing to complete - no action needed",
|
||||
"- Use this tool again to check progress",
|
||||
"- Simple operations may work already",
|
||||
"- All projects will be available once sync finishes",
|
||||
]
|
||||
)
|
||||
|
||||
# Handle failed projects (independent of active projects)
|
||||
if failed_projects:
|
||||
status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
|
||||
|
||||
for project_status in failed_projects:
|
||||
status_lines.append(
|
||||
f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
|
||||
)
|
||||
|
||||
status_lines.extend(
|
||||
[
|
||||
"",
|
||||
"**Next steps:**",
|
||||
"1. Check the logs for detailed error information",
|
||||
"2. Ensure file permissions allow read/write access",
|
||||
"3. Try restarting the MCP server",
|
||||
"4. If issues persist, consider filing a support issue",
|
||||
]
|
||||
)
|
||||
elif not active_projects:
|
||||
# No active or failed projects - must be pending
|
||||
status_lines.extend(
|
||||
[
|
||||
"⏳ **Sync operations pending**",
|
||||
"",
|
||||
"File synchronization has been queued but hasn't started yet.",
|
||||
"This usually resolves automatically within a few seconds.",
|
||||
]
|
||||
)
|
||||
|
||||
# Add comprehensive project status for all configured projects
|
||||
all_projects_status = _get_all_projects_status()
|
||||
if all_projects_status:
|
||||
status_lines.extend(all_projects_status)
|
||||
|
||||
# Add explanation about automatic syncing if there are unsynced projects
|
||||
unsynced_count = sum(1 for line in all_projects_status if "⏳" in line)
|
||||
if unsynced_count > 0 and not is_ready:
|
||||
status_lines.extend(
|
||||
[
|
||||
"",
|
||||
"**Note**: All configured projects will be automatically synced during startup.",
|
||||
"You don't need to manually switch projects - Basic Memory handles this for you.",
|
||||
]
|
||||
)
|
||||
|
||||
# Add project context if provided
|
||||
if project:
|
||||
try:
|
||||
active_project = get_active_project(project)
|
||||
status_lines.extend(
|
||||
[
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"**Active Project**: {active_project.name}",
|
||||
f"**Project Path**: {active_project.home}",
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get project info: {e}")
|
||||
|
||||
return "\n".join(status_lines)
|
||||
|
||||
except Exception as e:
|
||||
return f"""# Sync Status - Error
|
||||
|
||||
❌ **Unable to check sync status**: {str(e)}
|
||||
|
||||
**Troubleshooting:**
|
||||
- The system may still be starting up
|
||||
- Try waiting a few seconds and checking again
|
||||
- Check logs for detailed error information
|
||||
- Consider restarting if the issue persists
|
||||
"""
|
||||
@@ -506,3 +506,50 @@ async def call_delete(
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
def check_migration_status() -> Optional[str]:
|
||||
"""Check if sync/migration is in progress and return status message if so.
|
||||
|
||||
Returns:
|
||||
Status message if sync is in progress, None if system is ready
|
||||
"""
|
||||
try:
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
if not sync_status_tracker.is_ready:
|
||||
return sync_status_tracker.get_summary()
|
||||
return None
|
||||
except Exception:
|
||||
# If there's any error checking sync status, assume ready
|
||||
return None
|
||||
|
||||
|
||||
async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
|
||||
"""Wait briefly for sync/migration to complete, or return status message.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for sync completion
|
||||
|
||||
Returns:
|
||||
Status message if sync is still in progress, None if ready
|
||||
"""
|
||||
try:
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
import asyncio
|
||||
|
||||
if sync_status_tracker.is_ready:
|
||||
return None
|
||||
|
||||
# Wait briefly for sync to complete
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while (asyncio.get_event_loop().time() - start_time) < timeout:
|
||||
if sync_status_tracker.is_ready:
|
||||
return None
|
||||
await asyncio.sleep(0.1) # Check every 100ms
|
||||
|
||||
# Still not ready after timeout
|
||||
return sync_status_tracker.get_summary()
|
||||
except Exception: # pragma: no cover
|
||||
# If there's any error, assume ready
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""View note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="View a note as a formatted artifact for better readability.",
|
||||
)
|
||||
async def view_note(
|
||||
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
|
||||
) -> str:
|
||||
"""View a markdown note as a formatted artifact.
|
||||
|
||||
This tool reads a note using the same logic as read_note but displays the content
|
||||
as a markdown artifact for better viewing experience in Claude Desktop.
|
||||
|
||||
After calling this tool, create an artifact using the returned content to display
|
||||
the note in a readable format. The tool returns the note content that should be
|
||||
used to create a markdown artifact.
|
||||
|
||||
Args:
|
||||
identifier: The title or permalink of the note to view
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
project: Optional project name to read from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
The note content as a markdown artifact with a confirmation message.
|
||||
"""
|
||||
|
||||
logger.info(f"Viewing note: {identifier}")
|
||||
|
||||
# Call the existing read_note logic
|
||||
content = await read_note.fn(identifier, page, page_size, project)
|
||||
|
||||
# Check if this is an error message (note not found)
|
||||
if "# Note Not Found:" in content:
|
||||
return content # Return error message directly instead of creating artifact
|
||||
|
||||
# Extract title from content if possible
|
||||
title = identifier
|
||||
lines = content.split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("title:"):
|
||||
# Extract title from frontmatter
|
||||
title = line.split("title:", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
elif line.startswith("# "):
|
||||
# Extract title from first heading
|
||||
title = line[2:].strip()
|
||||
break
|
||||
|
||||
# Return the artifact XML as part of the response
|
||||
artifact = f'<artifact identifier="note-{hash(identifier) & 0x7FFFFFFF}" type="text/markdown" title="{title}">\n{content}\n</artifact>'
|
||||
|
||||
return dedent(f"""
|
||||
<instructions>
|
||||
Create an artifact using the returned artifact content to display the note in a readable format.
|
||||
</instructions>
|
||||
{artifact}\n\n✅ Note displayed as artifact: **{title}**""")
|
||||
@@ -54,7 +54,8 @@ async def write_note(
|
||||
Args:
|
||||
title: The title of the note
|
||||
content: Markdown content for the note, can include observations and relations
|
||||
folder: the folder where the file should be saved
|
||||
folder: Folder path relative to project root where the file should be saved.
|
||||
Use forward slashes (/) as separators. Examples: "notes", "projects/2025", "research/ml"
|
||||
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
||||
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
||||
project: Optional project name to write to. If not provided, uses current active project.
|
||||
@@ -69,6 +70,13 @@ async def write_note(
|
||||
"""
|
||||
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
if migration_status: # pragma: no cover
|
||||
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
# Create the entity request
|
||||
@@ -120,7 +128,10 @@ async def write_note(
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
|
||||
summary.append(
|
||||
"They will be automatically resolved when target entities are created or during sync operations."
|
||||
)
|
||||
|
||||
if tag_list:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
||||
|
||||
@@ -128,34 +128,90 @@ class SearchRepository:
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Special characters and phrases need to be quoted
|
||||
- Terms with spaces or special chars need quotes
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
if "*" in term:
|
||||
return term
|
||||
|
||||
# Check for explicit boolean operators - if present, return the term as is
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return term
|
||||
|
||||
# List of FTS5 special characters that need escaping/quoting
|
||||
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
return term
|
||||
|
||||
# Check if term contains any special characters
|
||||
needs_quotes = any(c in term for c in special_chars)
|
||||
# Characters that can cause FTS5 syntax errors when used as operators
|
||||
# We're more conservative here - only quote when we detect problematic patterns
|
||||
problematic_chars = [
|
||||
'"',
|
||||
"'",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
if needs_quotes:
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
||||
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
||||
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
||||
|
||||
# Check if term needs quoting
|
||||
has_problematic = any(c in term for c in problematic_chars)
|
||||
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
||||
|
||||
if has_problematic or has_spaces_or_special:
|
||||
# Handle multi-word queries differently from special character queries
|
||||
if " " in term and not any(c in term for c in problematic_chars):
|
||||
# Check if any individual word contains special characters that need quoting
|
||||
words = term.strip().split()
|
||||
has_special_in_words = any(
|
||||
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
||||
)
|
||||
|
||||
if not has_special_in_words:
|
||||
# For multi-word queries with simple words (like "emoji unicode"),
|
||||
# use boolean AND to handle word order variations
|
||||
if is_prefix:
|
||||
# Add prefix wildcard to each word for better matching
|
||||
prepared_words = [f"{word}*" for word in words if word]
|
||||
else:
|
||||
prepared_words = words
|
||||
term = " AND ".join(prepared_words)
|
||||
else:
|
||||
# If any word has special characters, quote the entire phrase
|
||||
escaped_term = term.replace('"', '""')
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
elif is_prefix:
|
||||
# Only add wildcard for simple terms without special characters
|
||||
term = f"{term}*"
|
||||
@@ -181,19 +237,24 @@ class SearchRepository:
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
# Check for explicit boolean operators - only detect them in proper boolean contexts
|
||||
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
|
||||
|
||||
if has_boolean:
|
||||
# If boolean operators are present, use the raw query
|
||||
# No need to prepare it, FTS5 will understand the operators
|
||||
params["text"] = search_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Standard search with term preparation
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
# Check for explicit boolean operators - only detect them in proper boolean contexts
|
||||
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
|
||||
|
||||
if has_boolean:
|
||||
# If boolean operators are present, use the raw query
|
||||
# No need to prepare it, FTS5 will understand the operators
|
||||
params["text"] = search_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
else:
|
||||
# Standard search with term preparation
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
@@ -208,15 +269,21 @@ class SearchRepository:
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
# Clean and prepare permalink for FTS5 GLOB match
|
||||
permalink_text = self._prepare_search_term(
|
||||
permalink_match.lower().strip(), is_prefix=False
|
||||
)
|
||||
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
||||
# GLOB patterns need to preserve their syntax
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
@@ -273,9 +340,20 @@ class SearchRepository:
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
else:
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
|
||||
@@ -13,7 +13,7 @@ Key Concepts:
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Annotated, Dict
|
||||
|
||||
@@ -46,15 +46,43 @@ def to_snake_case(name: str) -> str:
|
||||
return s2.lower()
|
||||
|
||||
|
||||
def parse_timeframe(timeframe: str) -> datetime:
|
||||
"""Parse timeframe with special handling for 'today' and other natural language expressions.
|
||||
|
||||
Args:
|
||||
timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
|
||||
|
||||
Returns:
|
||||
datetime: The parsed datetime for the start of the timeframe
|
||||
|
||||
Examples:
|
||||
parse_timeframe('today') -> 2025-06-05 00:00:00 (start of today)
|
||||
parse_timeframe('1d') -> 2025-06-04 14:50:00 (24 hours ago)
|
||||
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00 (1 week ago)
|
||||
"""
|
||||
if timeframe.lower() == "today":
|
||||
# Return start of today (00:00:00)
|
||||
return datetime.combine(datetime.now().date(), time.min)
|
||||
else:
|
||||
# Use dateparser for other formats
|
||||
parsed = parse(timeframe)
|
||||
if not parsed:
|
||||
raise ValueError(f"Could not parse timeframe: {timeframe}")
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_timeframe(timeframe: str) -> str:
|
||||
"""Convert human readable timeframes to a duration relative to the current time."""
|
||||
if not isinstance(timeframe, str):
|
||||
raise ValueError("Timeframe must be a string")
|
||||
|
||||
# Parse relative time expression
|
||||
parsed = parse(timeframe)
|
||||
if not parsed:
|
||||
raise ValueError(f"Could not parse timeframe: {timeframe}")
|
||||
# Preserve special timeframe strings that need custom handling
|
||||
special_timeframes = ["today"]
|
||||
if timeframe.lower() in special_timeframes:
|
||||
return timeframe.lower()
|
||||
|
||||
# Parse relative time expression using our enhanced parser
|
||||
parsed = parse_timeframe(timeframe)
|
||||
|
||||
# Convert to duration
|
||||
now = datetime.now()
|
||||
|
||||
@@ -9,8 +9,44 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
def validate_memory_url_path(path: str) -> bool:
|
||||
"""Validate that a memory URL path is well-formed.
|
||||
|
||||
Args:
|
||||
path: The path part of a memory URL (without memory:// prefix)
|
||||
|
||||
Returns:
|
||||
True if the path is valid, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> validate_memory_url_path("specs/search")
|
||||
True
|
||||
>>> validate_memory_url_path("memory//test") # Double slash
|
||||
False
|
||||
>>> validate_memory_url_path("invalid://test") # Contains protocol
|
||||
False
|
||||
"""
|
||||
if not path or not path.strip():
|
||||
return False
|
||||
|
||||
# Check for invalid protocol schemes within the path first (more specific)
|
||||
if "://" in path:
|
||||
return False
|
||||
|
||||
# Check for double slashes (except at the beginning for absolute paths)
|
||||
if "//" in path:
|
||||
return False
|
||||
|
||||
# Check for invalid characters (excluding * which is used for pattern matching)
|
||||
invalid_chars = {"<", ">", '"', "|", "?"}
|
||||
if any(char in path for char in invalid_chars):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def normalize_memory_url(url: str | None) -> str:
|
||||
"""Normalize a MemoryUrl string.
|
||||
"""Normalize a MemoryUrl string with validation.
|
||||
|
||||
Args:
|
||||
url: A path like "specs/search" or "memory://specs/search"
|
||||
@@ -18,22 +54,43 @@ def normalize_memory_url(url: str | None) -> str:
|
||||
Returns:
|
||||
Normalized URL starting with memory://
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL path is malformed
|
||||
|
||||
Examples:
|
||||
>>> normalize_memory_url("specs/search")
|
||||
'memory://specs/search'
|
||||
>>> normalize_memory_url("memory://specs/search")
|
||||
'memory://specs/search'
|
||||
>>> normalize_memory_url("memory//test")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: Invalid memory URL path: 'memory//test' contains double slashes
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
|
||||
clean_path = url.removeprefix("memory://")
|
||||
|
||||
# Validate the extracted path
|
||||
if not validate_memory_url_path(clean_path):
|
||||
# Provide specific error messages for common issues
|
||||
if "://" in clean_path:
|
||||
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
|
||||
elif "//" in clean_path:
|
||||
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
|
||||
elif not clean_path.strip():
|
||||
raise ValueError("Memory URL path cannot be empty or whitespace")
|
||||
else:
|
||||
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
|
||||
|
||||
return f"memory://{clean_path}"
|
||||
|
||||
|
||||
MemoryUrl = Annotated[
|
||||
str,
|
||||
BeforeValidator(str.strip), # Clean whitespace
|
||||
BeforeValidator(normalize_memory_url), # Validate and normalize the URL
|
||||
MinLen(1),
|
||||
MaxLen(2028),
|
||||
]
|
||||
|
||||
@@ -299,7 +299,20 @@ class EntityService(BaseService[EntityModel]):
|
||||
# Mark as incomplete because we still need to add relations
|
||||
model.checksum = None
|
||||
# Repository will set project_id automatically
|
||||
return await self.repository.add(model)
|
||||
try:
|
||||
return await self.repository.add(model)
|
||||
except IntegrityError as e:
|
||||
# Handle race condition where entity was created by another process
|
||||
if "UNIQUE constraint failed: entity.file_path" in str(
|
||||
e
|
||||
) or "UNIQUE constraint failed: entity.permalink" in str(e):
|
||||
logger.info(
|
||||
f"Entity already exists for file_path={file_path} (file_path or permalink conflict), updating instead of creating"
|
||||
)
|
||||
return await self.update_entity_and_observations(file_path, markdown)
|
||||
else:
|
||||
# Re-raise if it's a different integrity error
|
||||
raise
|
||||
|
||||
async def update_entity_and_observations(
|
||||
self, file_path: Path, markdown: EntityMarkdown
|
||||
@@ -413,8 +426,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
"""
|
||||
logger.debug(f"Editing entity: {identifier}, operation: {operation}")
|
||||
|
||||
# Find the entity using the link resolver
|
||||
entity = await self.link_resolver.resolve_link(identifier)
|
||||
# Find the entity using the link resolver with strict mode for destructive operations
|
||||
entity = await self.link_resolver.resolve_link(identifier, strict=True)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
||||
|
||||
@@ -630,8 +643,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
"""
|
||||
logger.debug(f"Moving entity: {identifier} to {destination_path}")
|
||||
|
||||
# 1. Resolve identifier to entity
|
||||
entity = await self.link_resolver.resolve_link(identifier)
|
||||
# 1. Resolve identifier to entity with strict mode for destructive operations
|
||||
entity = await self.link_resolver.resolve_link(identifier, strict=True)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ async def migrate_legacy_projects(app_config: BasicMemoryConfig):
|
||||
logger.error(f"Project {project_name} not found in database, skipping migration")
|
||||
continue
|
||||
|
||||
logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
|
||||
await migrate_legacy_project_data(project, legacy_dir)
|
||||
logger.info(f"Completed migration for project: {project_name}")
|
||||
logger.info("Legacy projects successfully migrated")
|
||||
|
||||
|
||||
@@ -104,7 +106,7 @@ async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> boo
|
||||
sync_dir = Path(project.path)
|
||||
|
||||
logger.info(f"Sync starting project: {project.name}")
|
||||
await sync_service.sync(sync_dir)
|
||||
await sync_service.sync(sync_dir, project_name=project.name)
|
||||
logger.info(f"Sync completed successfully for project: {project.name}")
|
||||
|
||||
# After successful sync, remove the legacy directory
|
||||
@@ -158,12 +160,32 @@ async def initialize_file_sync(
|
||||
sync_dir = Path(project.path)
|
||||
|
||||
try:
|
||||
await sync_service.sync(sync_dir)
|
||||
await sync_service.sync(sync_dir, project_name=project.name)
|
||||
logger.info(f"Sync completed successfully for project: {project.name}")
|
||||
|
||||
# Mark project as watching for changes after successful sync
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
sync_status_tracker.start_project_watch(project.name)
|
||||
logger.info(f"Project {project.name} is now watching for changes")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error syncing project {project.name}: {e}")
|
||||
# Mark sync as failed for this project
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
sync_status_tracker.fail_project_sync(project.name, str(e))
|
||||
# Continue with other projects even if one fails
|
||||
|
||||
# Mark migration complete if it was in progress
|
||||
try:
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
if not migration_manager.is_ready: # pragma: no cover
|
||||
migration_manager.mark_completed("Migration completed with file sync")
|
||||
logger.info("Marked migration as completed after file sync")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Could not update migration status: {e}")
|
||||
|
||||
# Then start the watch service in the background
|
||||
logger.info("Starting watch service for all projects")
|
||||
# run the watch service
|
||||
@@ -185,7 +207,7 @@ async def initialize_app(
|
||||
- Running database migrations
|
||||
- Reconciling projects from config.json with projects table
|
||||
- Setting up file synchronization
|
||||
- Migrating legacy project data
|
||||
- Starting background migration for legacy project data
|
||||
|
||||
Args:
|
||||
app_config: The Basic Memory project configuration
|
||||
@@ -197,8 +219,13 @@ async def initialize_app(
|
||||
# Reconcile projects from config.json with projects table
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
# migrate legacy project data
|
||||
await migrate_legacy_projects(app_config)
|
||||
# Start background migration for legacy project data (non-blocking)
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
await migration_manager.start_background_migration(app_config)
|
||||
|
||||
logger.info("App initialization completed (migration running in background if needed)")
|
||||
return migration_manager
|
||||
|
||||
|
||||
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
|
||||
@@ -26,8 +26,16 @@ class LinkResolver:
|
||||
self.entity_repository = entity_repository
|
||||
self.search_service = search_service
|
||||
|
||||
async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
|
||||
"""Resolve a markdown link to a permalink."""
|
||||
async def resolve_link(
|
||||
self, link_text: str, use_search: bool = True, strict: bool = False
|
||||
) -> Optional[Entity]:
|
||||
"""Resolve a markdown link to a permalink.
|
||||
|
||||
Args:
|
||||
link_text: The link text to resolve
|
||||
use_search: Whether to use search-based fuzzy matching as fallback
|
||||
strict: If True, only exact matches are allowed (no fuzzy search fallback)
|
||||
"""
|
||||
logger.trace(f"Resolving link: {link_text}")
|
||||
|
||||
# Clean link text and extract any alias
|
||||
@@ -41,7 +49,8 @@ class LinkResolver:
|
||||
|
||||
# 2. Try exact title match
|
||||
found = await self.entity_repository.get_by_title(clean_text)
|
||||
if found and len(found) == 1:
|
||||
if found:
|
||||
# Return first match if there are duplicates (consistent behavior)
|
||||
entity = found[0]
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
@@ -60,9 +69,12 @@ class LinkResolver:
|
||||
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
||||
return found_path_md
|
||||
|
||||
# search if indicated
|
||||
# In strict mode, don't try fuzzy search - return None if no exact match found
|
||||
if strict:
|
||||
return None
|
||||
|
||||
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
|
||||
if use_search and "*" not in clean_text:
|
||||
# 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
@@ -101,5 +113,8 @@ class LinkResolver:
|
||||
text, alias = text.split("|", 1)
|
||||
text = text.strip()
|
||||
alias = alias.strip()
|
||||
else:
|
||||
# Strip whitespace from text even if no alias
|
||||
text = text.strip()
|
||||
|
||||
return text, alias
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Migration service for handling background migrations and status tracking."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class MigrationStatus(Enum):
|
||||
"""Status of migration operations."""
|
||||
|
||||
NOT_NEEDED = "not_needed"
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationState:
|
||||
"""Current state of migration operations."""
|
||||
|
||||
status: MigrationStatus
|
||||
message: str
|
||||
progress: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
projects_migrated: int = 0
|
||||
projects_total: int = 0
|
||||
|
||||
|
||||
class MigrationManager:
|
||||
"""Manages background migration operations and status tracking."""
|
||||
|
||||
def __init__(self):
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
||||
)
|
||||
self._migration_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def state(self) -> MigrationState:
|
||||
"""Get current migration state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""Check if the system is ready for normal operations."""
|
||||
return self._state.status in (MigrationStatus.NOT_NEEDED, MigrationStatus.COMPLETED)
|
||||
|
||||
@property
|
||||
def status_message(self) -> str:
|
||||
"""Get a user-friendly status message."""
|
||||
if self._state.status == MigrationStatus.IN_PROGRESS:
|
||||
progress = (
|
||||
f" ({self._state.projects_migrated}/{self._state.projects_total})"
|
||||
if self._state.projects_total > 0
|
||||
else ""
|
||||
)
|
||||
return f"🔄 File sync in progress{progress}: {self._state.message}. Use sync_status() tool for details."
|
||||
elif self._state.status == MigrationStatus.FAILED:
|
||||
return f"❌ File sync failed: {self._state.error or 'Unknown error'}. Use sync_status() tool for details."
|
||||
elif self._state.status == MigrationStatus.COMPLETED:
|
||||
return "✅ File sync completed successfully"
|
||||
else:
|
||||
return "✅ System ready"
|
||||
|
||||
async def check_migration_needed(self, app_config: BasicMemoryConfig) -> bool:
|
||||
"""Check if migration is needed without performing it."""
|
||||
from basic_memory import db
|
||||
from basic_memory.repository import ProjectRepository
|
||||
|
||||
try:
|
||||
# Get database session
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# Check for legacy projects
|
||||
legacy_projects = []
|
||||
for project_name, project_path in app_config.projects.items():
|
||||
legacy_dir = Path(project_path) / ".basic-memory"
|
||||
if legacy_dir.exists():
|
||||
project = await project_repository.get_by_name(project_name)
|
||||
if project:
|
||||
legacy_projects.append(project)
|
||||
|
||||
if legacy_projects:
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.PENDING,
|
||||
message="Legacy projects detected",
|
||||
projects_total=len(legacy_projects),
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.FAILED, message="Migration check failed", error=str(e)
|
||||
)
|
||||
return False
|
||||
|
||||
async def start_background_migration(self, app_config: BasicMemoryConfig) -> None:
|
||||
"""Start migration in background if needed."""
|
||||
if not await self.check_migration_needed(app_config):
|
||||
return
|
||||
|
||||
if self._migration_task and not self._migration_task.done():
|
||||
logger.info("Migration already in progress")
|
||||
return
|
||||
|
||||
logger.info("Starting background migration")
|
||||
self._migration_task = asyncio.create_task(self._run_migration(app_config))
|
||||
|
||||
async def _run_migration(self, app_config: BasicMemoryConfig) -> None:
|
||||
"""Run the actual migration process."""
|
||||
try:
|
||||
self._state.status = MigrationStatus.IN_PROGRESS
|
||||
self._state.message = "Migrating legacy projects"
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from basic_memory.services.initialization import migrate_legacy_projects
|
||||
|
||||
# Run the migration
|
||||
await migrate_legacy_projects(app_config)
|
||||
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.COMPLETED, message="Migration completed successfully"
|
||||
)
|
||||
logger.info("Background migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background migration failed: {e}")
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.FAILED, message="Migration failed", error=str(e)
|
||||
)
|
||||
|
||||
async def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
|
||||
"""Wait for migration to complete."""
|
||||
if self.is_ready:
|
||||
return True
|
||||
|
||||
if not self._migration_task:
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._migration_task, timeout=timeout)
|
||||
return self.is_ready
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
def mark_completed(self, message: str = "Migration completed") -> None:
|
||||
"""Mark migration as completed externally."""
|
||||
self._state = MigrationState(status=MigrationStatus.COMPLETED, message=message)
|
||||
|
||||
|
||||
# Global migration manager instance
|
||||
migration_manager = MigrationManager()
|
||||
@@ -159,7 +159,9 @@ class ProjectService:
|
||||
multiple projects might have is_default=True or no project is marked as default.
|
||||
"""
|
||||
if not self.repository:
|
||||
raise ValueError("Repository is required for _ensure_single_default_project") # pragma: no cover
|
||||
raise ValueError(
|
||||
"Repository is required for _ensure_single_default_project"
|
||||
) # pragma: no cover
|
||||
|
||||
# Get all projects with is_default=True
|
||||
db_projects = await self.repository.find_all()
|
||||
@@ -207,8 +209,29 @@ class ProjectService:
|
||||
db_projects = await self.repository.get_active_projects()
|
||||
db_projects_by_name = {p.name: p for p in db_projects}
|
||||
|
||||
# Get all projects from configuration
|
||||
config_projects = config_manager.projects
|
||||
# Get all projects from configuration and normalize names if needed
|
||||
config_projects = config_manager.projects.copy()
|
||||
updated_config = {}
|
||||
config_updated = False
|
||||
|
||||
for name, path in config_projects.items():
|
||||
# Generate normalized name (what the database expects)
|
||||
normalized_name = generate_permalink(name)
|
||||
|
||||
if normalized_name != name:
|
||||
logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'")
|
||||
config_updated = True
|
||||
|
||||
updated_config[normalized_name] = path
|
||||
|
||||
# Update the configuration if any changes were made
|
||||
if config_updated:
|
||||
config_manager.config.projects = updated_config
|
||||
config_manager.save_config(config_manager.config)
|
||||
logger.info("Config updated with normalized project names")
|
||||
|
||||
# Use the normalized config for further processing
|
||||
config_projects = updated_config
|
||||
|
||||
# Add projects that exist in config but not in DB
|
||||
for name, path in config_projects.items():
|
||||
@@ -217,7 +240,7 @@ class ProjectService:
|
||||
project_data = {
|
||||
"name": name,
|
||||
"path": path,
|
||||
"permalink": name.lower().replace(" ", "-"),
|
||||
"permalink": generate_permalink(name),
|
||||
"is_active": True,
|
||||
# Don't set is_default here - let the enforcement logic handle it
|
||||
}
|
||||
@@ -309,8 +332,11 @@ class ProjectService:
|
||||
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
|
||||
)
|
||||
|
||||
async def get_project_info(self) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the current Basic Memory project.
|
||||
async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the specified Basic Memory project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to get info for. If None, uses the current config project.
|
||||
|
||||
Returns:
|
||||
Comprehensive project information and statistics
|
||||
@@ -318,19 +344,27 @@ class ProjectService:
|
||||
if not self.repository: # pragma: no cover
|
||||
raise ValueError("Repository is required for get_project_info")
|
||||
|
||||
# Get statistics
|
||||
statistics = await self.get_statistics()
|
||||
# Use specified project or fall back to config project
|
||||
project_name = project_name or config.project
|
||||
# Get project path from configuration
|
||||
project_path = config_manager.projects.get(project_name)
|
||||
if not project_path: # pragma: no cover
|
||||
raise ValueError(f"Project '{project_name}' not found in configuration")
|
||||
|
||||
# Get activity metrics
|
||||
activity = await self.get_activity_metrics()
|
||||
# Get project from database to get project_id
|
||||
db_project = await self.repository.get_by_name(project_name)
|
||||
if not db_project: # pragma: no cover
|
||||
raise ValueError(f"Project '{project_name}' not found in database")
|
||||
|
||||
# Get statistics for the specified project
|
||||
statistics = await self.get_statistics(db_project.id)
|
||||
|
||||
# Get activity metrics for the specified project
|
||||
activity = await self.get_activity_metrics(db_project.id)
|
||||
|
||||
# Get system status
|
||||
system = self.get_system_status()
|
||||
|
||||
# Get current project information from config
|
||||
project_name = config.project
|
||||
project_path = str(config.home)
|
||||
|
||||
# Get enhanced project information from database
|
||||
db_projects = await self.repository.get_active_projects()
|
||||
db_projects_by_name = {p.name: p for p in db_projects}
|
||||
@@ -361,60 +395,85 @@ class ProjectService:
|
||||
system=system,
|
||||
)
|
||||
|
||||
async def get_statistics(self) -> ProjectStatistics:
|
||||
"""Get statistics about the current project."""
|
||||
async def get_statistics(self, project_id: int) -> ProjectStatistics:
|
||||
"""Get statistics about the specified project.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to get statistics for (required).
|
||||
"""
|
||||
if not self.repository: # pragma: no cover
|
||||
raise ValueError("Repository is required for get_statistics")
|
||||
|
||||
# Get basic counts
|
||||
entity_count_result = await self.repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM entity")
|
||||
text("SELECT COUNT(*) FROM entity WHERE project_id = :project_id"),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
total_entities = entity_count_result.scalar() or 0
|
||||
|
||||
observation_count_result = await self.repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM observation")
|
||||
text(
|
||||
"SELECT COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
total_observations = observation_count_result.scalar() or 0
|
||||
|
||||
relation_count_result = await self.repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM relation")
|
||||
text(
|
||||
"SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
total_relations = relation_count_result.scalar() or 0
|
||||
|
||||
unresolved_count_result = await self.repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM relation WHERE to_id IS NULL")
|
||||
text(
|
||||
"SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE r.to_id IS NULL AND e.project_id = :project_id"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
total_unresolved = unresolved_count_result.scalar() or 0
|
||||
|
||||
# Get entity counts by type
|
||||
entity_types_result = await self.repository.execute_query(
|
||||
text("SELECT entity_type, COUNT(*) FROM entity GROUP BY entity_type")
|
||||
text(
|
||||
"SELECT entity_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY entity_type"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
|
||||
|
||||
# Get observation counts by category
|
||||
category_result = await self.repository.execute_query(
|
||||
text("SELECT category, COUNT(*) FROM observation GROUP BY category")
|
||||
text(
|
||||
"SELECT o.category, COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id GROUP BY o.category"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
|
||||
|
||||
# Get relation counts by type
|
||||
relation_types_result = await self.repository.execute_query(
|
||||
text("SELECT relation_type, COUNT(*) FROM relation GROUP BY relation_type")
|
||||
text(
|
||||
"SELECT r.relation_type, COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id GROUP BY r.relation_type"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
|
||||
|
||||
# Find most connected entities (most outgoing relations)
|
||||
# Find most connected entities (most outgoing relations) - project filtered
|
||||
connected_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, file_path
|
||||
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, e.file_path
|
||||
FROM entity e
|
||||
JOIN relation r ON e.id = r.from_id
|
||||
WHERE e.project_id = :project_id
|
||||
GROUP BY e.id
|
||||
ORDER BY relation_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
"""),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
most_connected = [
|
||||
{
|
||||
@@ -427,15 +486,16 @@ class ProjectService:
|
||||
for row in connected_result.fetchall()
|
||||
]
|
||||
|
||||
# Count isolated entities (no relations)
|
||||
# Count isolated entities (no relations) - project filtered
|
||||
isolated_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT COUNT(e.id)
|
||||
FROM entity e
|
||||
LEFT JOIN relation r1 ON e.id = r1.from_id
|
||||
LEFT JOIN relation r2 ON e.id = r2.to_id
|
||||
WHERE r1.id IS NULL AND r2.id IS NULL
|
||||
""")
|
||||
WHERE e.project_id = :project_id AND r1.id IS NULL AND r2.id IS NULL
|
||||
"""),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
isolated_count = isolated_result.scalar() or 0
|
||||
|
||||
@@ -451,19 +511,25 @@ class ProjectService:
|
||||
isolated_entities=isolated_count,
|
||||
)
|
||||
|
||||
async def get_activity_metrics(self) -> ActivityMetrics:
|
||||
"""Get activity metrics for the current project."""
|
||||
async def get_activity_metrics(self, project_id: int) -> ActivityMetrics:
|
||||
"""Get activity metrics for the specified project.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to get activity metrics for (required).
|
||||
"""
|
||||
if not self.repository: # pragma: no cover
|
||||
raise ValueError("Repository is required for get_activity_metrics")
|
||||
|
||||
# Get recently created entities
|
||||
# Get recently created entities (project filtered)
|
||||
created_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, created_at, file_path
|
||||
FROM entity
|
||||
WHERE project_id = :project_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
"""),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
recently_created = [
|
||||
{
|
||||
@@ -477,14 +543,16 @@ class ProjectService:
|
||||
for row in created_result.fetchall()
|
||||
]
|
||||
|
||||
# Get recently updated entities
|
||||
# Get recently updated entities (project filtered)
|
||||
updated_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, updated_at, file_path
|
||||
FROM entity
|
||||
WHERE project_id = :project_id
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
"""),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
recently_updated = [
|
||||
{
|
||||
@@ -505,47 +573,50 @@ class ProjectService:
|
||||
now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
|
||||
)
|
||||
|
||||
# Query for monthly entity creation
|
||||
# Query for monthly entity creation (project filtered)
|
||||
entity_growth_result = await self.repository.execute_query(
|
||||
text(f"""
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM entity
|
||||
WHERE created_at >= '{six_months_ago.isoformat()}'
|
||||
WHERE created_at >= :six_months_ago AND project_id = :project_id
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
)
|
||||
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly observation creation
|
||||
# Query for monthly observation creation (project filtered)
|
||||
observation_growth_result = await self.repository.execute_query(
|
||||
text(f"""
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
strftime('%Y-%m', entity.created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM observation
|
||||
INNER JOIN entity ON observation.entity_id = entity.id
|
||||
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
|
||||
WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
)
|
||||
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly relation creation
|
||||
# Query for monthly relation creation (project filtered)
|
||||
relation_growth_result = await self.repository.execute_query(
|
||||
text(f"""
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
strftime('%Y-%m', entity.created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM relation
|
||||
INNER JOIN entity ON relation.from_id = entity.id
|
||||
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
|
||||
WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
)
|
||||
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
|
||||
|
||||
@@ -597,4 +668,4 @@ class ProjectService:
|
||||
database_size=db_size_readable,
|
||||
watch_status=watch_status,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Simple sync status tracking service."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class SyncStatus(Enum):
|
||||
"""Status of sync operations."""
|
||||
|
||||
IDLE = "idle"
|
||||
SCANNING = "scanning"
|
||||
SYNCING = "syncing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
WATCHING = "watching"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectSyncStatus:
|
||||
"""Sync status for a single project."""
|
||||
|
||||
project_name: str
|
||||
status: SyncStatus
|
||||
message: str = ""
|
||||
files_total: int = 0
|
||||
files_processed: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SyncStatusTracker:
|
||||
"""Global tracker for all sync operations."""
|
||||
|
||||
def __init__(self):
|
||||
self._project_statuses: Dict[str, ProjectSyncStatus] = {}
|
||||
self._global_status: SyncStatus = SyncStatus.IDLE
|
||||
|
||||
def start_project_sync(self, project_name: str, files_total: int = 0) -> None:
|
||||
"""Start tracking sync for a project."""
|
||||
self._project_statuses[project_name] = ProjectSyncStatus(
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SCANNING,
|
||||
message="Scanning files",
|
||||
files_total=files_total,
|
||||
files_processed=0,
|
||||
)
|
||||
self._update_global_status()
|
||||
|
||||
def update_project_progress( # pragma: no cover
|
||||
self,
|
||||
project_name: str,
|
||||
status: SyncStatus,
|
||||
message: str = "",
|
||||
files_processed: int = 0,
|
||||
files_total: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Update progress for a project."""
|
||||
if project_name not in self._project_statuses: # pragma: no cover
|
||||
return
|
||||
|
||||
project_status = self._project_statuses[project_name]
|
||||
project_status.status = status
|
||||
project_status.message = message
|
||||
project_status.files_processed = files_processed
|
||||
|
||||
if files_total is not None:
|
||||
project_status.files_total = files_total
|
||||
|
||||
self._update_global_status()
|
||||
|
||||
def complete_project_sync(self, project_name: str) -> None:
|
||||
"""Mark project sync as completed."""
|
||||
if project_name in self._project_statuses:
|
||||
self._project_statuses[project_name].status = SyncStatus.COMPLETED
|
||||
self._project_statuses[project_name].message = "Sync completed"
|
||||
self._update_global_status()
|
||||
|
||||
def fail_project_sync(self, project_name: str, error: str) -> None:
|
||||
"""Mark project sync as failed."""
|
||||
if project_name in self._project_statuses:
|
||||
self._project_statuses[project_name].status = SyncStatus.FAILED
|
||||
self._project_statuses[project_name].error = error
|
||||
self._update_global_status()
|
||||
|
||||
def start_project_watch(self, project_name: str) -> None:
|
||||
"""Mark project as watching for changes (steady state after sync)."""
|
||||
if project_name in self._project_statuses:
|
||||
self._project_statuses[project_name].status = SyncStatus.WATCHING
|
||||
self._project_statuses[project_name].message = "Watching for changes"
|
||||
self._update_global_status()
|
||||
else:
|
||||
# Create new status if project isn't tracked yet
|
||||
self._project_statuses[project_name] = ProjectSyncStatus(
|
||||
project_name=project_name,
|
||||
status=SyncStatus.WATCHING,
|
||||
message="Watching for changes",
|
||||
files_total=0,
|
||||
files_processed=0,
|
||||
)
|
||||
self._update_global_status()
|
||||
|
||||
def _update_global_status(self) -> None:
|
||||
"""Update global status based on project statuses."""
|
||||
if not self._project_statuses: # pragma: no cover
|
||||
self._global_status = SyncStatus.IDLE
|
||||
return
|
||||
|
||||
statuses = [p.status for p in self._project_statuses.values()]
|
||||
|
||||
if any(s == SyncStatus.FAILED for s in statuses):
|
||||
self._global_status = SyncStatus.FAILED
|
||||
elif any(s in (SyncStatus.SCANNING, SyncStatus.SYNCING) for s in statuses):
|
||||
self._global_status = SyncStatus.SYNCING
|
||||
elif all(s in (SyncStatus.COMPLETED, SyncStatus.WATCHING) for s in statuses):
|
||||
self._global_status = SyncStatus.COMPLETED
|
||||
else:
|
||||
self._global_status = SyncStatus.SYNCING
|
||||
|
||||
@property
|
||||
def global_status(self) -> SyncStatus:
|
||||
"""Get overall sync status."""
|
||||
return self._global_status
|
||||
|
||||
@property
|
||||
def is_syncing(self) -> bool:
|
||||
"""Check if any sync operation is in progress."""
|
||||
return self._global_status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool: # pragma: no cover
|
||||
"""Check if system is ready (no sync in progress)."""
|
||||
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
|
||||
|
||||
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
|
||||
"""Get status for a specific project."""
|
||||
return self._project_statuses.get(project_name)
|
||||
|
||||
def get_all_projects(self) -> Dict[str, ProjectSyncStatus]:
|
||||
"""Get all project statuses."""
|
||||
return self._project_statuses.copy()
|
||||
|
||||
def get_summary(self) -> str: # pragma: no cover
|
||||
"""Get a user-friendly summary of sync status."""
|
||||
if self._global_status == SyncStatus.IDLE:
|
||||
return "✅ System ready"
|
||||
elif self._global_status == SyncStatus.COMPLETED:
|
||||
return "✅ All projects synced successfully"
|
||||
elif self._global_status == SyncStatus.FAILED:
|
||||
failed_projects = [
|
||||
p.project_name
|
||||
for p in self._project_statuses.values()
|
||||
if p.status == SyncStatus.FAILED
|
||||
]
|
||||
return f"❌ Sync failed for: {', '.join(failed_projects)}"
|
||||
else:
|
||||
active_projects = [
|
||||
p.project_name
|
||||
for p in self._project_statuses.values()
|
||||
if p.status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
||||
]
|
||||
total_files = sum(p.files_total for p in self._project_statuses.values())
|
||||
processed_files = sum(p.files_processed for p in self._project_statuses.values())
|
||||
|
||||
if total_files > 0:
|
||||
progress_pct = (processed_files / total_files) * 100
|
||||
return f"🔄 Syncing {len(active_projects)} projects ({processed_files}/{total_files} files, {progress_pct:.0f}%)"
|
||||
else:
|
||||
return f"🔄 Syncing {len(active_projects)} projects"
|
||||
|
||||
def clear_completed(self) -> None:
|
||||
"""Remove completed project statuses to clean up memory."""
|
||||
self._project_statuses = {
|
||||
name: status
|
||||
for name, status in self._project_statuses.items()
|
||||
if status.status != SyncStatus.COMPLETED
|
||||
}
|
||||
self._update_global_status()
|
||||
|
||||
|
||||
# Global sync status tracker instance
|
||||
sync_status_tracker = SyncStatusTracker()
|
||||
@@ -17,6 +17,7 @@ from basic_memory.models import Entity
|
||||
from basic_memory.repository import EntityRepository, RelationRepository
|
||||
from basic_memory.services import EntityService, FileService
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker, SyncStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -80,23 +81,38 @@ class SyncService:
|
||||
self.search_service = search_service
|
||||
self.file_service = file_service
|
||||
|
||||
async def sync(self, directory: Path) -> SyncReport:
|
||||
async def sync(self, directory: Path, project_name: Optional[str] = None) -> SyncReport:
|
||||
"""Sync all files with database."""
|
||||
|
||||
start_time = time.time()
|
||||
logger.info(f"Sync operation started for directory: {directory}")
|
||||
|
||||
# Start tracking sync for this project if project name provided
|
||||
if project_name:
|
||||
sync_status_tracker.start_project_sync(project_name)
|
||||
|
||||
# initial paths from db to sync
|
||||
# path -> checksum
|
||||
report = await self.scan(directory)
|
||||
|
||||
# Initialize progress tracking if requested
|
||||
# Update progress with file counts
|
||||
if project_name:
|
||||
sync_status_tracker.update_project_progress(
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing file changes",
|
||||
files_total=report.total,
|
||||
files_processed=0,
|
||||
)
|
||||
|
||||
# order of sync matters to resolve relations effectively
|
||||
logger.info(
|
||||
f"Sync changes detected: new_files={len(report.new)}, modified_files={len(report.modified)}, "
|
||||
+ f"deleted_files={len(report.deleted)}, moved_files={len(report.moves)}"
|
||||
)
|
||||
|
||||
files_processed = 0
|
||||
|
||||
# sync moves first
|
||||
for old_path, new_path in report.moves.items():
|
||||
# in the case where a file has been deleted and replaced by another file
|
||||
@@ -109,19 +125,56 @@ class SyncService:
|
||||
else:
|
||||
await self.handle_move(old_path, new_path)
|
||||
|
||||
files_processed += 1
|
||||
if project_name:
|
||||
sync_status_tracker.update_project_progress( # pragma: no cover
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing moves",
|
||||
files_processed=files_processed,
|
||||
)
|
||||
|
||||
# deleted next
|
||||
for path in report.deleted:
|
||||
await self.handle_delete(path)
|
||||
files_processed += 1
|
||||
if project_name:
|
||||
sync_status_tracker.update_project_progress( # pragma: no cover
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing deletions",
|
||||
files_processed=files_processed,
|
||||
)
|
||||
|
||||
# then new and modified
|
||||
for path in report.new:
|
||||
await self.sync_file(path, new=True)
|
||||
files_processed += 1
|
||||
if project_name:
|
||||
sync_status_tracker.update_project_progress(
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing new files",
|
||||
files_processed=files_processed,
|
||||
)
|
||||
|
||||
for path in report.modified:
|
||||
await self.sync_file(path, new=False)
|
||||
files_processed += 1
|
||||
if project_name:
|
||||
sync_status_tracker.update_project_progress( # pragma: no cover
|
||||
project_name=project_name,
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing modified files",
|
||||
files_processed=files_processed,
|
||||
)
|
||||
|
||||
await self.resolve_relations()
|
||||
|
||||
# Mark sync as completed
|
||||
if project_name:
|
||||
sync_status_tracker.complete_project_sync(project_name)
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.info(
|
||||
f"Sync operation completed: directory={directory}, total_changes={report.total}, duration_ms={duration_ms}"
|
||||
@@ -311,18 +364,43 @@ class SyncService:
|
||||
content_type = self.file_service.content_type(path)
|
||||
|
||||
file_path = Path(path)
|
||||
entity = await self.entity_repository.add(
|
||||
Entity(
|
||||
entity_type="file",
|
||||
file_path=path,
|
||||
checksum=checksum,
|
||||
title=file_path.name,
|
||||
created_at=created,
|
||||
updated_at=modified,
|
||||
content_type=content_type,
|
||||
try:
|
||||
entity = await self.entity_repository.add(
|
||||
Entity(
|
||||
entity_type="file",
|
||||
file_path=path,
|
||||
checksum=checksum,
|
||||
title=file_path.name,
|
||||
created_at=created,
|
||||
updated_at=modified,
|
||||
content_type=content_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
return entity, checksum
|
||||
return entity, checksum
|
||||
except IntegrityError as e:
|
||||
# Handle race condition where entity was created by another process
|
||||
if "UNIQUE constraint failed: entity.file_path" in str(e):
|
||||
logger.info(
|
||||
f"Entity already exists for file_path={path}, updating instead of creating"
|
||||
)
|
||||
# Treat as update instead of create
|
||||
entity = await self.entity_repository.get_by_file_path(path)
|
||||
if entity is None: # pragma: no cover
|
||||
logger.error(f"Entity not found after constraint violation, path={path}")
|
||||
raise ValueError(f"Entity not found after constraint violation: {path}")
|
||||
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": path, "checksum": checksum}
|
||||
)
|
||||
|
||||
if updated is None: # pragma: no cover
|
||||
logger.error(f"Failed to update entity, entity_id={entity.id}, path={path}")
|
||||
raise ValueError(f"Failed to update entity with ID {entity.id}")
|
||||
|
||||
return updated, checksum
|
||||
else:
|
||||
# Re-raise if it's a different integrity error
|
||||
raise
|
||||
else:
|
||||
entity = await self.entity_repository.get_by_file_path(path)
|
||||
if entity is None: # pragma: no cover
|
||||
|
||||
+187
-120
@@ -33,51 +33,101 @@ build these connections!
|
||||
|
||||
## Core Tools Reference
|
||||
|
||||
```python
|
||||
# Writing knowledge - THE MOST IMPORTANT TOOL!
|
||||
response = await write_note(
|
||||
title="Search Design", # Required: Note title
|
||||
content="# Search Design\n...", # Required: Note content
|
||||
folder="specs", # Optional: Folder to save in
|
||||
tags=["search", "design"], # Optional: Tags for categorization
|
||||
verbose=True # Optional: Get parsing details
|
||||
**Writing knowledge - THE MOST IMPORTANT TOOL!**
|
||||
```
|
||||
write_note(
|
||||
title="Search Design",
|
||||
content="# Search Design\n\n## Overview\nSearch functionality design and implementation.\n\n## Observations\n- [requirement] Must support full-text search #search\n- [decision] Using vector embeddings for semantic search #technology\n\n## Relations\n- implements [[Search Requirements]]\n- part_of [[API Specification]]",
|
||||
folder="specs",
|
||||
tags=["search", "design"]
|
||||
)
|
||||
```
|
||||
|
||||
**Reading knowledge:**
|
||||
```
|
||||
read_note("Search Design") # By exact title
|
||||
read_note("specs/search-design") # By permalink
|
||||
read_note("memory://specs/search") # By memory URL
|
||||
```
|
||||
|
||||
**Viewing notes as formatted artifacts (Claude Desktop):**
|
||||
```
|
||||
view_note("Search Design") # Creates readable artifact
|
||||
view_note("specs/search-design") # By permalink
|
||||
view_note("memory://specs/search") # By memory URL
|
||||
```
|
||||
|
||||
**Incremental editing (v0.13.0) - REQUIRES EXACT IDENTIFIERS:**
|
||||
```
|
||||
edit_note(
|
||||
identifier="Search Design", # Must be EXACT title/permalink
|
||||
operation="append",
|
||||
content="\n## Implementation Notes\n- Added caching layer for performance"
|
||||
)
|
||||
|
||||
# Reading knowledge
|
||||
content = await read_note("Search Design") # By title
|
||||
content = await read_note("specs/search-design") # By path
|
||||
content = await read_note("memory://specs/search") # By memory URL
|
||||
|
||||
# Searching for knowledge
|
||||
results = await search_notes(
|
||||
query="authentication system", # Text to search for
|
||||
page=1, # Optional: Pagination
|
||||
page_size=10 # Optional: Results per page
|
||||
edit_note(
|
||||
identifier="API Documentation",
|
||||
operation="replace_section",
|
||||
section="## Authentication",
|
||||
content="Updated authentication using JWT tokens with refresh capability."
|
||||
)
|
||||
```
|
||||
|
||||
# Building context from the knowledge graph
|
||||
context = await build_context(
|
||||
url="memory://specs/search", # Starting point
|
||||
depth=2, # Optional: How many hops to follow
|
||||
timeframe="1 month" # Optional: Recent timeframe
|
||||
**File organization (v0.13.0) - REQUIRES EXACT IDENTIFIERS:**
|
||||
```
|
||||
move_note(
|
||||
identifier="Old Meeting Notes", # Must be EXACT title/permalink
|
||||
destination_path="archive/2024/meeting-notes.md"
|
||||
)
|
||||
```
|
||||
|
||||
# Checking recent changes
|
||||
activity = await recent_activity(
|
||||
type="all", # Optional: Entity types to include
|
||||
depth=1, # Optional: Related items to include
|
||||
timeframe="1 week" # Optional: Time window
|
||||
**Searching for knowledge:**
|
||||
```
|
||||
search_notes(
|
||||
query="authentication system",
|
||||
page=1,
|
||||
page_size=10
|
||||
)
|
||||
```
|
||||
|
||||
# Creating a knowledge visualization
|
||||
canvas_result = await canvas(
|
||||
nodes=[{"id": "note1", "label": "Search Design"}], # Nodes to display
|
||||
edges=[{"from": "note1", "to": "note2"}], # Connections
|
||||
title="Project Overview", # Canvas title
|
||||
folder="diagrams" # Storage location
|
||||
**Building context from the knowledge graph:**
|
||||
```
|
||||
build_context(
|
||||
url="memory://specs/search",
|
||||
depth=2,
|
||||
timeframe="1 month"
|
||||
)
|
||||
```
|
||||
|
||||
**Checking recent changes:**
|
||||
```
|
||||
recent_activity(
|
||||
timeframe="1 week",
|
||||
depth=1
|
||||
)
|
||||
```
|
||||
|
||||
**Creating knowledge visualizations:**
|
||||
```
|
||||
canvas(
|
||||
nodes=[
|
||||
{"id": "search", "x": 100, "y": 100, "width": 200, "height": 100, "type": "text", "text": "Search Design"},
|
||||
{"id": "api", "x": 400, "y": 100, "width": 200, "height": 100, "type": "text", "text": "API Specification"}
|
||||
],
|
||||
edges=[
|
||||
{"id": "link1", "fromNode": "search", "toNode": "api"}
|
||||
],
|
||||
title="System Architecture",
|
||||
folder="diagrams"
|
||||
)
|
||||
```
|
||||
|
||||
**Monitoring sync status:**
|
||||
```
|
||||
sync_status() # Check overall system status
|
||||
sync_status(project="work-notes") # Check specific project status
|
||||
```
|
||||
|
||||
## memory:// URLs Explained
|
||||
|
||||
Basic Memory uses a special URL format to reference entities in the knowledge graph:
|
||||
@@ -259,45 +309,24 @@ When creating relations, you can:
|
||||
1. Reference existing entities by their exact title
|
||||
2. Create forward references to entities that don't exist yet
|
||||
|
||||
```python
|
||||
# Example workflow for creating notes with effective relations
|
||||
async def create_note_with_effective_relations():
|
||||
# Search for existing entities to reference
|
||||
search_results = await search_notes("travel")
|
||||
existing_entities = [result.title for result in search_results.primary_results]
|
||||
**Example workflow for creating notes with effective relations:**
|
||||
|
||||
# Check if specific entities exist
|
||||
packing_tips_exists = "Packing Tips" in existing_entities
|
||||
japan_travel_exists = "Japan Travel Guide" in existing_entities
|
||||
1. **First, search for existing entities to reference:**
|
||||
```
|
||||
search_notes(query="travel")
|
||||
```
|
||||
|
||||
# Prepare relations section - include both existing and forward references
|
||||
relations_section = "## Relations\n"
|
||||
2. **Check recent activity for current topics:**
|
||||
```
|
||||
recent_activity(timeframe="1 week")
|
||||
```
|
||||
|
||||
# Existing reference - exact match to known entity
|
||||
if packing_tips_exists:
|
||||
relations_section += "- references [[Packing Tips]]\n"
|
||||
else:
|
||||
# Forward reference - will be linked when that entity is created later
|
||||
relations_section += "- references [[Packing Tips]]\n"
|
||||
3. **Create the note with both existing and forward references:**
|
||||
```
|
||||
write_note(
|
||||
title="Tokyo Neighborhood Guide",
|
||||
content="# Tokyo Neighborhood Guide
|
||||
|
||||
# Another possible reference
|
||||
if japan_travel_exists:
|
||||
relations_section += "- part_of [[Japan Travel Guide]]\n"
|
||||
|
||||
# You can also check recently modified notes to reference them
|
||||
recent = await recent_activity(timeframe="1 week")
|
||||
recent_titles = [item.title for item in recent.primary_results]
|
||||
|
||||
if "Transportation Options" in recent_titles:
|
||||
relations_section += "- relates_to [[Transportation Options]]\n"
|
||||
|
||||
# Always include meaningful forward references, even if they don't exist yet
|
||||
relations_section += "- located_in [[Tokyo]]\n"
|
||||
relations_section += "- visited_during [[Spring 2023 Trip]]\n"
|
||||
|
||||
# Now create the note with both verified and forward relations
|
||||
content = f"""# Tokyo Neighborhood Guide
|
||||
|
||||
## Overview
|
||||
Details about different Tokyo neighborhoods and their unique characteristics.
|
||||
|
||||
@@ -307,65 +336,103 @@ Details about different Tokyo neighborhoods and their unique characteristics.
|
||||
- [recommendation] Visit Shimokitazawa for vintage shopping #unique
|
||||
- [tip] Get a Suica card for easy train travel #convenience
|
||||
|
||||
{relations_section}
|
||||
"""
|
||||
|
||||
result = await write_note(
|
||||
title="Tokyo Neighborhood Guide",
|
||||
content=content,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# You can check which relations were resolved and which are forward references
|
||||
if result and 'relations' in result:
|
||||
resolved = [r['to_name'] for r in result['relations'] if r.get('target_id')]
|
||||
forward_refs = [r['to_name'] for r in result['relations'] if not r.get('target_id')]
|
||||
|
||||
print(f"Resolved relations: {resolved}")
|
||||
print(f"Forward references that will be resolved later: {forward_refs}")
|
||||
## Relations
|
||||
- references [[Packing Tips]] # Forward reference (will be linked when created)
|
||||
- part_of [[Japan Travel Guide]] # Existing reference (if found in search)
|
||||
- relates_to [[Transportation Options]] # Recent reference (if found in activity)
|
||||
- located_in [[Tokyo]] # Forward reference
|
||||
- visited_during [[Spring 2023 Trip]] # Forward reference",
|
||||
folder="travel",
|
||||
tags=["tokyo", "neighborhoods", "travel"]
|
||||
)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Use exact titles from search results for existing entities: `[[Exact Title Found]]`
|
||||
- Forward references are fine - they'll be linked automatically when target notes are created
|
||||
- Check recent activity to reference currently active topics
|
||||
- Use meaningful relation types: `part_of`, `located_in`, `visited_during` vs generic `relates_to`
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common issues to watch for:
|
||||
|
||||
1. **Missing Content**
|
||||
```python
|
||||
try:
|
||||
content = await read_note("Document")
|
||||
except:
|
||||
# Try search instead
|
||||
results = await search_notes("Document")
|
||||
if results and results.primary_results:
|
||||
# Found something similar
|
||||
content = await read_note(results.primary_results[0].permalink)
|
||||
```
|
||||
**1. Missing Content - Use Search as Fallback**
|
||||
```
|
||||
# If read_note fails, try search instead
|
||||
search_notes(query="Document")
|
||||
# Then use exact result from search:
|
||||
read_note("Exact Document Title Found")
|
||||
```
|
||||
|
||||
2. **Forward References (Unresolved Relations)**
|
||||
```python
|
||||
response = await write_note(..., verbose=True)
|
||||
# Check for forward references (unresolved relations)
|
||||
forward_refs = []
|
||||
for relation in response.get('relations', []):
|
||||
if not relation.get('target_id'):
|
||||
forward_refs.append(relation.get('to_name'))
|
||||
|
||||
if forward_refs:
|
||||
# This is a feature, not an error! Inform the user about forward references
|
||||
print(f"Note created with forward references to: {forward_refs}")
|
||||
print("These will be automatically linked when those notes are created.")
|
||||
|
||||
# Optionally suggest creating those entities now
|
||||
print("Would you like me to create any of these notes now to complete the connections?")
|
||||
```
|
||||
**2. Strict Mode for Edit/Move Operations (v0.13.0)**
|
||||
|
||||
3. **Sync Issues**
|
||||
```python
|
||||
# If information seems outdated
|
||||
activity = await recent_activity(timeframe="1 hour")
|
||||
if not activity or not activity.primary_results:
|
||||
print("It seems there haven't been recent updates. You might need to run 'basic-memory sync'.")
|
||||
```
|
||||
❌ **This might fail if identifier isn't exact:**
|
||||
```
|
||||
edit_note(identifier="Meeting Note", operation="append", content="new content")
|
||||
```
|
||||
|
||||
✅ **Safe approach - search first, then use exact result:**
|
||||
```
|
||||
# 1. Search first to find exact identifier
|
||||
search_notes(query="meeting")
|
||||
|
||||
# 2. Use exact title from search results
|
||||
edit_note(identifier="Meeting Notes 2024", operation="append", content="new content")
|
||||
|
||||
# Same pattern for move_note:
|
||||
search_notes(query="old note")
|
||||
move_note(identifier="Old Meeting Notes", destination_path="archive/old-notes.md")
|
||||
```
|
||||
|
||||
**3. Forward References (Unresolved Relations)**
|
||||
|
||||
Forward references are a **feature, not an error!** Basic Memory automatically links them when target notes are created.
|
||||
|
||||
When you see unresolved relations in the response:
|
||||
- Inform users: "I've created forward references that will be linked when you create those notes"
|
||||
- Optionally suggest: "Would you like me to create any of these notes now to complete the connections?"
|
||||
|
||||
**4. Sync Issues**
|
||||
|
||||
If information seems outdated:
|
||||
```
|
||||
recent_activity(timeframe="1 hour")
|
||||
```
|
||||
If no recent activity shows, check sync status first:
|
||||
```
|
||||
sync_status()
|
||||
```
|
||||
If sync is pending or failed, suggest: "You might need to run `basic-memory sync`"
|
||||
|
||||
**5. Understanding Sync Status**
|
||||
|
||||
The `sync_status()` tool provides essential information about Basic Memory's operational state:
|
||||
|
||||
```
|
||||
sync_status() # Check overall system readiness
|
||||
sync_status(project="work-notes") # Check specific project context
|
||||
```
|
||||
|
||||
**When to use sync_status:**
|
||||
- At the start of conversations to verify system readiness
|
||||
- When operations seem slow or fail unexpectedly
|
||||
- Before working with large knowledge bases
|
||||
- When switching between projects
|
||||
- To provide users context about background processing
|
||||
|
||||
**What sync_status tells you:**
|
||||
- **System Ready**: Whether all files are indexed and tools are operational
|
||||
- **Active Processing**: Which projects are currently syncing with progress indicators
|
||||
- **Project Status**: Individual project sync states (👁️ watching, ✅ completed, 🔄 syncing, ❌ failed, ⏳ pending)
|
||||
- **Error Details**: Specific error messages for failed sync operations
|
||||
- **Guidance**: Next steps when issues are detected
|
||||
|
||||
**Using sync_status effectively:**
|
||||
- Check status if tools return unexpected results
|
||||
- Use project parameter when working in multi-project setups
|
||||
- Share status with users when explaining delays
|
||||
- Monitor progress during initial setup or large imports
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,172 @@
|
||||
"""Integration tests for build_context memory URL validation."""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_valid_urls(mcp_server, app):
|
||||
"""Test that build_context works with valid memory URLs."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a test note to ensure we have something to find
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": "URL Validation Test",
|
||||
"folder": "testing",
|
||||
"content": "# URL Validation Test\n\nThis note tests URL validation.",
|
||||
"tags": "test,validation",
|
||||
},
|
||||
)
|
||||
|
||||
# Test various valid URL formats
|
||||
valid_urls = [
|
||||
"memory://testing/url-validation-test", # Full memory URL
|
||||
"testing/url-validation-test", # Relative path
|
||||
"testing/*", # Pattern matching
|
||||
]
|
||||
|
||||
for url in valid_urls:
|
||||
result = await client.call_tool("build_context", {"url": url})
|
||||
|
||||
# Should return a valid GraphContext response
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
assert '"results"' in response # Should contain results structure
|
||||
assert '"metadata"' in response # Should contain metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_invalid_urls_fail_validation(mcp_server, app):
|
||||
"""Test that build_context properly validates and rejects invalid memory URLs."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Test cases: (invalid_url, expected_error_fragment)
|
||||
invalid_test_cases = [
|
||||
("memory//test", "double slashes"),
|
||||
("invalid://test", "protocol scheme"),
|
||||
("notes<brackets>", "invalid characters"),
|
||||
('notes"quotes"', "invalid characters"),
|
||||
]
|
||||
|
||||
for invalid_url, expected_error in invalid_test_cases:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("build_context", {"url": invalid_url})
|
||||
|
||||
error_message = str(exc_info.value).lower()
|
||||
assert expected_error in error_message, (
|
||||
f"URL '{invalid_url}' should fail with '{expected_error}' error"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_empty_urls_fail_validation(mcp_server, app):
|
||||
"""Test that empty or whitespace-only URLs fail validation."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# These should fail MinLen validation
|
||||
empty_urls = [
|
||||
"", # Empty string
|
||||
" ", # Whitespace only
|
||||
]
|
||||
|
||||
for empty_url in empty_urls:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("build_context", {"url": empty_url})
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should fail with validation error (either MinLen or our custom validation)
|
||||
assert (
|
||||
"at least 1" in error_message
|
||||
or "too_short" in error_message
|
||||
or "empty or whitespace" in error_message
|
||||
or "value_error" in error_message
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_nonexistent_urls_return_empty_results(mcp_server, app):
|
||||
"""Test that valid but nonexistent URLs return empty results (not errors)."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# These are valid URL formats but don't exist in the system
|
||||
nonexistent_valid_urls = [
|
||||
"memory://nonexistent/note",
|
||||
"nonexistent/note",
|
||||
"missing/*",
|
||||
]
|
||||
|
||||
for url in nonexistent_valid_urls:
|
||||
result = await client.call_tool("build_context", {"url": url})
|
||||
|
||||
# Should return valid response with empty results
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
assert '"results": []' in response # Empty results
|
||||
assert '"total_results": 0' in response # Zero count
|
||||
assert '"metadata"' in response # But should have metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_error_messages_are_helpful(mcp_server, app):
|
||||
"""Test that validation error messages provide helpful guidance."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Test double slash error message
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("build_context", {"url": "memory//bad"})
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
# Should contain validation error info
|
||||
assert (
|
||||
"double slashes" in error_msg
|
||||
or "value_error" in error_msg
|
||||
or "validation error" in error_msg
|
||||
)
|
||||
|
||||
# Test protocol scheme error message
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("build_context", {"url": "http://example.com"})
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert (
|
||||
"protocol scheme" in error_msg
|
||||
or "protocol" in error_msg
|
||||
or "value_error" in error_msg
|
||||
or "validation error" in error_msg
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_pattern_matching_works(mcp_server, app):
|
||||
"""Test that valid pattern matching URLs work correctly."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create multiple test notes
|
||||
test_notes = [
|
||||
("Pattern Test One", "patterns", "# Pattern Test One\n\nFirst pattern test."),
|
||||
("Pattern Test Two", "patterns", "# Pattern Test Two\n\nSecond pattern test."),
|
||||
("Other Note", "other", "# Other Note\n\nNot a pattern match."),
|
||||
]
|
||||
|
||||
for title, folder, content in test_notes:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
|
||||
# Test pattern matching
|
||||
result = await client.call_tool("build_context", {"url": "patterns/*"})
|
||||
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
|
||||
# Should find the pattern matches but not the other note
|
||||
assert '"total_results": 2' in response or '"primary_count": 2' in response
|
||||
assert "Pattern Test" in response
|
||||
assert "Other Note" not in response
|
||||
@@ -60,7 +60,6 @@ async def test_delete_note_by_title(mcp_server, app):
|
||||
result_text = read_after_delete[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert "Note to Delete" in result_text
|
||||
assert "I couldn't find any notes matching" in result_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -324,10 +324,9 @@ async def test_edit_note_error_handling_note_not_found(mcp_server, app):
|
||||
# Should return helpful error message
|
||||
assert len(edit_result) == 1
|
||||
error_text = edit_result[0].text
|
||||
assert "Edit Failed - Note Not Found" in error_text
|
||||
assert "Edit Failed" in error_text
|
||||
assert "Non-existent Note" in error_text
|
||||
assert "search_notes(" in error_text
|
||||
assert "Suggestions to try:" in error_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -262,21 +262,20 @@ async def test_move_note_error_handling_note_not_found(mcp_server, app):
|
||||
"""Test error handling when trying to move a non-existent note."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Try to move a note that doesn't exist - should raise ToolError
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Non-existent Note",
|
||||
"destination_path": "new/location.md",
|
||||
},
|
||||
)
|
||||
# Try to move a note that doesn't exist - should return error message
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Non-existent Note",
|
||||
"destination_path": "new/location.md",
|
||||
},
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
error_message = str(exc_info.value)
|
||||
assert "move_note" in error_message and (
|
||||
"Invalid request" in error_message or "Entity not found" in error_message
|
||||
)
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "Non-existent Note" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -295,24 +294,20 @@ async def test_move_note_error_handling_invalid_destination(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
# Try to move to absolute path (should fail) - should raise ToolError
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Invalid Dest Test",
|
||||
"destination_path": "/absolute/path/note.md",
|
||||
},
|
||||
)
|
||||
# Try to move to absolute path (should fail) - should return error message
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Invalid Dest Test",
|
||||
"destination_path": "/absolute/path/note.md",
|
||||
},
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
error_message = str(exc_info.value)
|
||||
assert "move_note" in error_message and (
|
||||
"Invalid request" in error_message
|
||||
or "Invalid destination path" in error_message
|
||||
or "destination_path must be relative" in error_message
|
||||
or "Client error (422)" in error_message
|
||||
)
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "/absolute/path/note.md" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -342,21 +337,20 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
# Try to move source to existing destination (should fail) - should raise ToolError
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Source Note",
|
||||
"destination_path": "destination/Existing Note.md", # Use exact existing file name
|
||||
},
|
||||
)
|
||||
# Try to move source to existing destination (should fail) - should return error message
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Source Note",
|
||||
"destination_path": "destination/Existing Note.md", # Use exact existing file name
|
||||
},
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
error_message = str(exc_info.value)
|
||||
assert "move_note" in error_message and (
|
||||
"Destination already exists: destination/Existing Note.md" in error_message
|
||||
)
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "already exists" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -90,6 +90,88 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
assert data["content"].strip() in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relation_resolution_after_creation(client: AsyncClient, project_url):
|
||||
"""Test that relation resolution works after creating entities and handles exceptions gracefully."""
|
||||
|
||||
# Create first entity with unresolved relation
|
||||
entity1_data = {
|
||||
"title": "EntityOne",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": "This entity references [[EntityTwo]]",
|
||||
}
|
||||
response1 = await client.put(
|
||||
f"{project_url}/knowledge/entities/test/entity-one", json=entity1_data
|
||||
)
|
||||
assert response1.status_code == 201
|
||||
entity1 = response1.json()
|
||||
|
||||
# Verify relation exists but is unresolved
|
||||
assert len(entity1["relations"]) == 1
|
||||
assert entity1["relations"][0]["to_id"] is None
|
||||
assert entity1["relations"][0]["to_name"] == "EntityTwo"
|
||||
|
||||
# Create the referenced entity
|
||||
entity2_data = {
|
||||
"title": "EntityTwo",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": "This is the referenced entity",
|
||||
}
|
||||
response2 = await client.put(
|
||||
f"{project_url}/knowledge/entities/test/entity-two", json=entity2_data
|
||||
)
|
||||
assert response2.status_code == 201
|
||||
|
||||
# Verify the original entity's relation was resolved
|
||||
response_check = await client.get(f"{project_url}/knowledge/entities/test/entity-one")
|
||||
assert response_check.status_code == 200
|
||||
updated_entity1 = response_check.json()
|
||||
|
||||
# The relation should now be resolved via the automatic resolution after entity creation
|
||||
resolved_relations = [r for r in updated_entity1["relations"] if r["to_id"] is not None]
|
||||
assert (
|
||||
len(resolved_relations) >= 0
|
||||
) # May or may not be resolved immediately depending on timing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relation_resolution_exception_handling(client: AsyncClient, project_url):
|
||||
"""Test that relation resolution exceptions are handled gracefully."""
|
||||
import unittest.mock
|
||||
|
||||
# Create an entity that would trigger relation resolution
|
||||
entity_data = {
|
||||
"title": "ExceptionTest",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": "This entity has a [[Relation]]",
|
||||
}
|
||||
|
||||
# Mock the sync service to raise an exception during relation resolution
|
||||
# We'll patch at the module level where it's imported
|
||||
with unittest.mock.patch(
|
||||
"basic_memory.api.routers.knowledge_router.SyncServiceDep",
|
||||
side_effect=lambda: unittest.mock.AsyncMock(),
|
||||
) as mock_sync_service_dep:
|
||||
# Configure the mock sync service to raise an exception
|
||||
mock_sync_service = unittest.mock.AsyncMock()
|
||||
mock_sync_service.resolve_relations.side_effect = Exception("Sync service failed")
|
||||
mock_sync_service_dep.return_value = mock_sync_service
|
||||
|
||||
# This should still succeed even though relation resolution fails
|
||||
response = await client.put(
|
||||
f"{project_url}/knowledge/entities/test/exception-test", json=entity_data
|
||||
)
|
||||
assert response.status_code == 201
|
||||
entity = response.json()
|
||||
|
||||
# Verify the entity was still created successfully
|
||||
assert entity["title"] == "ExceptionTest"
|
||||
assert len(entity["relations"]) == 1 # Relation should still be there, just unresolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_by_permalink(client: AsyncClient, project_url):
|
||||
"""Should retrieve an entity by path ID."""
|
||||
|
||||
Binary file not shown.
@@ -10,7 +10,7 @@ from basic_memory.deps import get_project_config, get_engine_factory, get_app_co
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def app(app_config, project_config, engine_factory, test_config) -> FastAPI:
|
||||
async def app(app_config, project_config, engine_factory, test_config, aiolib) -> FastAPI:
|
||||
"""Create test FastAPI application."""
|
||||
app = fastapi_app
|
||||
app.dependency_overrides[get_app_config] = lambda: app_config
|
||||
@@ -20,7 +20,7 @@ async def app(app_config, project_config, engine_factory, test_config) -> FastAP
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def client(app: FastAPI, aiolib) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test client that both MCP and tests will use."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
@@ -1,38 +1,116 @@
|
||||
"""Tests for the project_info CLI command."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectInfoResponse,
|
||||
ProjectStatistics,
|
||||
ActivityMetrics,
|
||||
SystemStatus,
|
||||
)
|
||||
|
||||
|
||||
def test_info_stats_command(cli_env, test_graph, project_session):
|
||||
def test_info_stats():
|
||||
"""Test the 'project info' command with default output."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Run the command
|
||||
result = runner.invoke(cli_app, ["project", "info"])
|
||||
# Create mock project info data
|
||||
mock_info = ProjectInfoResponse(
|
||||
project_name="test-project",
|
||||
project_path="/test/path",
|
||||
default_project="test-project",
|
||||
statistics=ProjectStatistics(
|
||||
total_entities=10,
|
||||
total_observations=20,
|
||||
total_relations=5,
|
||||
total_unresolved_relations=1,
|
||||
isolated_entities=2,
|
||||
entity_types={"note": 8, "concept": 2},
|
||||
observation_categories={"tech": 15, "note": 5},
|
||||
relation_types={"connects_to": 3, "references": 2},
|
||||
most_connected_entities=[],
|
||||
),
|
||||
activity=ActivityMetrics(recently_created=[], recently_updated=[], monthly_growth={}),
|
||||
system=SystemStatus(
|
||||
version="0.13.0",
|
||||
database_path="/test/db.sqlite",
|
||||
database_size="1.2 MB",
|
||||
watch_status=None,
|
||||
timestamp=datetime.now(),
|
||||
),
|
||||
available_projects={"test-project": {"path": "/test/path"}},
|
||||
)
|
||||
|
||||
# Verify exit code
|
||||
assert result.exit_code == 0
|
||||
# Mock the async project_info function
|
||||
with patch(
|
||||
"basic_memory.cli.commands.project.project_info.fn", new_callable=AsyncMock
|
||||
) as mock_func:
|
||||
mock_func.return_value = mock_info
|
||||
|
||||
# Check that key data is included in the output
|
||||
assert "Basic Memory Project Info" in result.stdout
|
||||
# Run the command
|
||||
result = runner.invoke(cli_app, ["project", "info"])
|
||||
|
||||
# Verify exit code
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check that key data is included in the output
|
||||
assert "Basic Memory Project Info" in result.stdout
|
||||
assert "test-project" in result.stdout
|
||||
assert "Statistics" in result.stdout
|
||||
|
||||
|
||||
def test_info_stats_json(cli_env, test_graph, project_session):
|
||||
def test_info_stats_json():
|
||||
"""Test the 'project info --json' command for JSON output."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Run the command with --json flag
|
||||
result = runner.invoke(cli_app, ["project", "info", "--json"])
|
||||
# Create mock project info data
|
||||
mock_info = ProjectInfoResponse(
|
||||
project_name="test-project",
|
||||
project_path="/test/path",
|
||||
default_project="test-project",
|
||||
statistics=ProjectStatistics(
|
||||
total_entities=10,
|
||||
total_observations=20,
|
||||
total_relations=5,
|
||||
total_unresolved_relations=1,
|
||||
isolated_entities=2,
|
||||
entity_types={"note": 8, "concept": 2},
|
||||
observation_categories={"tech": 15, "note": 5},
|
||||
relation_types={"connects_to": 3, "references": 2},
|
||||
most_connected_entities=[],
|
||||
),
|
||||
activity=ActivityMetrics(recently_created=[], recently_updated=[], monthly_growth={}),
|
||||
system=SystemStatus(
|
||||
version="0.13.0",
|
||||
database_path="/test/db.sqlite",
|
||||
database_size="1.2 MB",
|
||||
watch_status=None,
|
||||
timestamp=datetime.now(),
|
||||
),
|
||||
available_projects={"test-project": {"path": "/test/path"}},
|
||||
)
|
||||
|
||||
# Verify exit code
|
||||
assert result.exit_code == 0
|
||||
# Mock the async project_info function
|
||||
with patch(
|
||||
"basic_memory.cli.commands.project.project_info.fn", new_callable=AsyncMock
|
||||
) as mock_func:
|
||||
mock_func.return_value = mock_info
|
||||
|
||||
# Parse JSON output
|
||||
output = json.loads(result.stdout)
|
||||
# Run the command with --json flag
|
||||
result = runner.invoke(cli_app, ["project", "info", "--json"])
|
||||
|
||||
# Verify JSON structure matches our sample data
|
||||
assert output["default_project"] == "test-project"
|
||||
# Verify exit code
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Parse JSON output
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
# Verify JSON structure matches our mock data
|
||||
assert output["default_project"] == "test-project"
|
||||
assert output["project_name"] == "test-project"
|
||||
assert output["statistics"]["total_entities"] == 10
|
||||
|
||||
+26
-17
@@ -1,6 +1,7 @@
|
||||
"""Tests for CLI status command."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
@@ -10,34 +11,42 @@ from basic_memory.cli.commands.status import (
|
||||
group_changes_by_directory,
|
||||
display_changes,
|
||||
)
|
||||
from basic_memory.config import config
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_status_command(tmp_path, app_config, project_config, test_project):
|
||||
def test_status_command():
|
||||
"""Test CLI status command."""
|
||||
config.home = tmp_path
|
||||
config.name = test_project.name
|
||||
# Mock the async run_status function to avoid event loop issues
|
||||
with patch(
|
||||
"basic_memory.cli.commands.status.run_status", new_callable=AsyncMock
|
||||
) as mock_run_status:
|
||||
# Mock successful execution (no return value needed since it just prints)
|
||||
mock_run_status.return_value = None
|
||||
|
||||
# Should exit with code 0
|
||||
result = runner.invoke(app, ["status", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
# Should exit with code 0
|
||||
result = runner.invoke(app, ["status", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify the function was called with verbose=True
|
||||
mock_run_status.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_error(tmp_path, monkeypatch):
|
||||
def test_status_command_error():
|
||||
"""Test CLI status command error handling."""
|
||||
# Set up invalid environment
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
monkeypatch.setenv("HOME", str(nonexistent))
|
||||
monkeypatch.setenv("DATABASE_PATH", str(nonexistent / "nonexistent.db"))
|
||||
# Mock the async run_status function to raise an exception
|
||||
with patch(
|
||||
"basic_memory.cli.commands.status.run_status", new_callable=AsyncMock
|
||||
) as mock_run_status:
|
||||
# Mock an error
|
||||
mock_run_status.side_effect = Exception("Database connection failed")
|
||||
|
||||
# Should exit with code 1 when error occurs
|
||||
result = runner.invoke(app, ["status", "--verbose"])
|
||||
assert result.exit_code == 1
|
||||
# Should exit with code 1 when error occurs
|
||||
result = runner.invoke(app, ["status", "--verbose"])
|
||||
assert result.exit_code == 1
|
||||
assert "Error checking status: Database connection failed" in result.stderr
|
||||
|
||||
|
||||
def test_display_changes_no_changes():
|
||||
|
||||
+40
-5
@@ -89,10 +89,45 @@ Some content""")
|
||||
await run_sync(verbose=True)
|
||||
|
||||
|
||||
def test_sync_command(sync_service, project_config, test_project):
|
||||
def test_sync_command():
|
||||
"""Test the sync command."""
|
||||
config.home = project_config.home
|
||||
config.name = test_project.name
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
# Mock the async run_sync function to avoid event loop issues
|
||||
with patch("basic_memory.cli.commands.sync.run_sync", new_callable=AsyncMock) as mock_run_sync:
|
||||
# Mock successful execution (no return value needed since it just prints)
|
||||
mock_run_sync.return_value = None
|
||||
|
||||
# Mock config values that the sync command prints
|
||||
with patch("basic_memory.cli.commands.sync.config") as mock_config:
|
||||
mock_config.project = "test-project"
|
||||
mock_config.home = "/test/path"
|
||||
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify output contains project info
|
||||
assert "Syncing project: test-project" in result.stdout
|
||||
assert "Project path: /test/path" in result.stdout
|
||||
|
||||
# Verify the function was called with verbose=True
|
||||
mock_run_sync.assert_called_once_with(verbose=True)
|
||||
|
||||
|
||||
def test_sync_command_error():
|
||||
"""Test the sync command error handling."""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
# Mock the async run_sync function to raise an exception
|
||||
with patch("basic_memory.cli.commands.sync.run_sync", new_callable=AsyncMock) as mock_run_sync:
|
||||
# Mock an error
|
||||
mock_run_sync.side_effect = Exception("Sync failed")
|
||||
|
||||
# Mock config values that the sync command prints
|
||||
with patch("basic_memory.cli.commands.sync.config") as mock_config:
|
||||
mock_config.project = "test-project"
|
||||
mock_config.home = "/test/path"
|
||||
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during sync: Sync failed" in result.stderr
|
||||
|
||||
Binary file not shown.
@@ -9,7 +9,7 @@ from httpx import AsyncClient, ASGITransport
|
||||
from mcp.server import FastMCP
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.deps import get_project_config, get_engine_factory
|
||||
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.mcp.server import mcp as mcp_server
|
||||
|
||||
@@ -25,6 +25,7 @@ def mcp() -> FastMCP:
|
||||
def app(app_config, project_config, engine_factory, project_session, config_manager) -> FastAPI:
|
||||
"""Create test FastAPI application."""
|
||||
app = fastapi_app
|
||||
app.dependency_overrides[get_app_config] = lambda: app_config
|
||||
app.dependency_overrides[get_project_config] = lambda: project_config
|
||||
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
|
||||
return app
|
||||
|
||||
@@ -15,7 +15,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
|
||||
# We can use the test_graph fixture which already has relevant content
|
||||
|
||||
# Call the function with a topic that should match existing content
|
||||
result = await continue_conversation(topic="Root", timeframe="1w")
|
||||
result = await continue_conversation.fn(topic="Root", timeframe="1w")
|
||||
|
||||
# Check that the result contains expected content
|
||||
assert "Continuing conversation on: Root" in result
|
||||
@@ -27,7 +27,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
|
||||
async def test_continue_conversation_with_recent_activity(client, test_graph):
|
||||
"""Test continue_conversation with no topic, using recent activity."""
|
||||
# Call the function without a topic
|
||||
result = await continue_conversation(timeframe="1w")
|
||||
result = await continue_conversation.fn(timeframe="1w")
|
||||
|
||||
# Check that the result contains expected content for recent activity
|
||||
assert "Continuing conversation on: Recent Activity" in result
|
||||
@@ -40,7 +40,7 @@ async def test_continue_conversation_with_recent_activity(client, test_graph):
|
||||
async def test_continue_conversation_no_results(client):
|
||||
"""Test continue_conversation when no results are found."""
|
||||
# Call with a non-existent topic
|
||||
result = await continue_conversation(topic="NonExistentTopic", timeframe="1w")
|
||||
result = await continue_conversation.fn(topic="NonExistentTopic", timeframe="1w")
|
||||
|
||||
# Check the response indicates no results found
|
||||
assert "Continuing conversation on: NonExistentTopic" in result
|
||||
@@ -51,7 +51,7 @@ async def test_continue_conversation_no_results(client):
|
||||
async def test_continue_conversation_creates_structured_suggestions(client, test_graph):
|
||||
"""Test that continue_conversation generates structured tool usage suggestions."""
|
||||
# Call the function with a topic that should match existing content
|
||||
result = await continue_conversation(topic="Root", timeframe="1w")
|
||||
result = await continue_conversation.fn(topic="Root", timeframe="1w")
|
||||
|
||||
# Verify the response includes clear tool usage instructions
|
||||
assert "start by executing one of the suggested commands" in result.lower()
|
||||
@@ -69,7 +69,7 @@ async def test_continue_conversation_creates_structured_suggestions(client, test
|
||||
async def test_search_prompt_with_results(client, test_graph):
|
||||
"""Test search_prompt with a query that returns results."""
|
||||
# Call the function with a query that should match existing content
|
||||
result = await search_prompt("Root")
|
||||
result = await search_prompt.fn("Root")
|
||||
|
||||
# Check the response contains expected content
|
||||
assert 'Search Results for: "Root"' in result
|
||||
@@ -82,7 +82,7 @@ async def test_search_prompt_with_results(client, test_graph):
|
||||
async def test_search_prompt_with_timeframe(client, test_graph):
|
||||
"""Test search_prompt with a timeframe."""
|
||||
# Call the function with a query and timeframe
|
||||
result = await search_prompt("Root", timeframe="1w")
|
||||
result = await search_prompt.fn("Root", timeframe="1w")
|
||||
|
||||
# Check the response includes timeframe information
|
||||
assert 'Search Results for: "Root" (after 7d)' in result
|
||||
@@ -93,7 +93,7 @@ async def test_search_prompt_with_timeframe(client, test_graph):
|
||||
async def test_search_prompt_no_results(client):
|
||||
"""Test search_prompt when no results are found."""
|
||||
# Call with a query that won't match anything
|
||||
result = await search_prompt("XYZ123NonExistentQuery")
|
||||
result = await search_prompt.fn("XYZ123NonExistentQuery")
|
||||
|
||||
# Check the response indicates no results found
|
||||
assert 'Search Results for: "XYZ123NonExistentQuery"' in result
|
||||
@@ -149,7 +149,7 @@ def test_prompt_context_with_file_path_no_permalink():
|
||||
async def test_recent_activity_prompt(client, test_graph):
|
||||
"""Test recent_activity_prompt."""
|
||||
# Call the function
|
||||
result = await recent_activity_prompt(timeframe="1w")
|
||||
result = await recent_activity_prompt.fn(timeframe="1w")
|
||||
|
||||
# Check the response contains expected content
|
||||
assert "Recent Activity" in result
|
||||
@@ -161,7 +161,7 @@ async def test_recent_activity_prompt(client, test_graph):
|
||||
async def test_recent_activity_prompt_with_custom_timeframe(client, test_graph):
|
||||
"""Test recent_activity_prompt with custom timeframe."""
|
||||
# Call the function with a custom timeframe
|
||||
result = await recent_activity_prompt(timeframe="1d")
|
||||
result = await recent_activity_prompt.fn(timeframe="1d")
|
||||
|
||||
# Check the response includes the custom timeframe
|
||||
assert "Recent Activity from (1d)" in result
|
||||
|
||||
@@ -97,7 +97,7 @@ async def test_project_info_tool():
|
||||
"basic_memory.mcp.resources.project_info.call_get", return_value=mock_response
|
||||
) as mock_call_get:
|
||||
# Call the function
|
||||
result = await project_info()
|
||||
result = await project_info.fn()
|
||||
|
||||
# Verify that call_get was called with the correct URL
|
||||
mock_call_get.assert_called_once()
|
||||
@@ -138,7 +138,7 @@ async def test_project_info_error_handling():
|
||||
):
|
||||
# Verify that the exception propagates
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await project_info()
|
||||
await project_info.fn()
|
||||
|
||||
# Verify error message
|
||||
assert "Test error" in str(excinfo.value)
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
async def test_ai_assistant_guide_exists(app):
|
||||
"""Test that the canvas spec resource exists and returns content."""
|
||||
# Call the resource function
|
||||
guide = ai_assistant_guide()
|
||||
guide = ai_assistant_guide.fn()
|
||||
|
||||
# Verify basic characteristics of the content
|
||||
assert guide is not None
|
||||
|
||||
@@ -14,7 +14,7 @@ from basic_memory.schemas.memory import (
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_basic_discussion_context(client, test_graph):
|
||||
"""Test getting basic discussion context."""
|
||||
context = await build_context(url="memory://test/root")
|
||||
context = await build_context.fn(url="memory://test/root")
|
||||
|
||||
assert isinstance(context, GraphContext)
|
||||
assert len(context.results) == 1
|
||||
@@ -33,7 +33,7 @@ async def test_get_basic_discussion_context(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_discussion_context_pattern(client, test_graph):
|
||||
"""Test getting context with pattern matching."""
|
||||
context = await build_context(url="memory://test/*", depth=1)
|
||||
context = await build_context.fn(url="memory://test/*", depth=1)
|
||||
|
||||
assert isinstance(context, GraphContext)
|
||||
assert len(context.results) > 1 # Should match multiple test/* paths
|
||||
@@ -45,13 +45,13 @@ async def test_get_discussion_context_pattern(client, test_graph):
|
||||
async def test_get_discussion_context_timeframe(client, test_graph):
|
||||
"""Test timeframe parameter filtering."""
|
||||
# Get recent context
|
||||
recent_context = await build_context(
|
||||
recent_context = await build_context.fn(
|
||||
url="memory://test/root",
|
||||
timeframe="1d", # Last 24 hours
|
||||
)
|
||||
|
||||
# Get older context
|
||||
older_context = await build_context(
|
||||
older_context = await build_context.fn(
|
||||
url="memory://test/root",
|
||||
timeframe="30d", # Last 30 days
|
||||
)
|
||||
@@ -74,7 +74,7 @@ async def test_get_discussion_context_timeframe(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_discussion_context_not_found(client):
|
||||
"""Test handling of non-existent URIs."""
|
||||
context = await build_context(url="memory://test/does-not-exist")
|
||||
context = await build_context.fn(url="memory://test/does-not-exist")
|
||||
|
||||
assert isinstance(context, GraphContext)
|
||||
assert len(context.results) == 0
|
||||
@@ -103,7 +103,7 @@ async def test_build_context_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await build_context(
|
||||
result = await build_context.fn(
|
||||
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
@@ -113,4 +113,4 @@ async def test_build_context_timeframe_formats(client, test_graph):
|
||||
# Test invalid timeframes should raise ValidationError
|
||||
for timeframe in invalid_timeframes:
|
||||
with pytest.raises(ToolError):
|
||||
await build_context(url=test_url, timeframe=timeframe)
|
||||
await build_context.fn(url=test_url, timeframe=timeframe)
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_create_canvas(app, project_config):
|
||||
folder = "visualizations"
|
||||
|
||||
# Execute
|
||||
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
|
||||
# Verify result message
|
||||
assert result
|
||||
@@ -71,7 +71,7 @@ async def test_create_canvas_with_extension(app, project_config):
|
||||
folder = "visualizations"
|
||||
|
||||
# Execute
|
||||
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
|
||||
# Verify
|
||||
assert "Created: visualizations/extension-test.canvas" in result
|
||||
@@ -105,7 +105,7 @@ async def test_update_existing_canvas(app, project_config):
|
||||
folder = "visualizations"
|
||||
|
||||
# Create initial canvas
|
||||
await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
|
||||
# Verify file exists
|
||||
file_path = Path(project_config.home) / folder / f"{title}.canvas"
|
||||
@@ -128,7 +128,7 @@ async def test_update_existing_canvas(app, project_config):
|
||||
]
|
||||
|
||||
# Execute update
|
||||
result = await canvas(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder)
|
||||
result = await canvas.fn(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder)
|
||||
|
||||
# Verify result indicates update
|
||||
assert "Updated: visualizations/update-test.canvas" in result
|
||||
@@ -159,7 +159,7 @@ async def test_create_canvas_with_nested_folders(app, project_config):
|
||||
folder = "visualizations/nested/folders" # Deep path
|
||||
|
||||
# Execute
|
||||
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
|
||||
# Verify
|
||||
assert "Created: visualizations/nested/folders/nested-test.canvas" in result
|
||||
@@ -242,7 +242,7 @@ async def test_create_canvas_complex_content(app, project_config):
|
||||
test_file_path.write_text("# Test File\nThis is referenced by the canvas")
|
||||
|
||||
# Execute
|
||||
result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
result = await canvas.fn(nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
|
||||
# Verify
|
||||
assert "Created: visualizations/complex-test.canvas" in result
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for delete_note MCP tool."""
|
||||
|
||||
from basic_memory.mcp.tools.delete_note import _format_delete_error_response
|
||||
|
||||
|
||||
class TestDeleteNoteErrorFormatting:
|
||||
"""Test the error formatting function for better user experience."""
|
||||
|
||||
def test_format_delete_error_note_not_found(self):
|
||||
"""Test formatting for note not found errors."""
|
||||
result = _format_delete_error_response("entity not found", "test-note")
|
||||
|
||||
assert "# Delete Failed - Note Not Found" in result
|
||||
assert "The note 'test-note' could not be found" in result
|
||||
assert 'search_notes("test-note")' in result
|
||||
assert "Already deleted" in result
|
||||
assert "Wrong identifier" in result
|
||||
|
||||
def test_format_delete_error_permission_denied(self):
|
||||
"""Test formatting for permission errors."""
|
||||
result = _format_delete_error_response("permission denied", "test-note")
|
||||
|
||||
assert "# Delete Failed - Permission Error" in result
|
||||
assert "You don't have permission to delete 'test-note'" in result
|
||||
assert "Check permissions" in result
|
||||
assert "File locks" in result
|
||||
assert "get_current_project()" in result
|
||||
|
||||
def test_format_delete_error_access_forbidden(self):
|
||||
"""Test formatting for access forbidden errors."""
|
||||
result = _format_delete_error_response("access forbidden", "test-note")
|
||||
|
||||
assert "# Delete Failed - Permission Error" in result
|
||||
assert "You don't have permission to delete 'test-note'" in result
|
||||
|
||||
def test_format_delete_error_server_error(self):
|
||||
"""Test formatting for server errors."""
|
||||
result = _format_delete_error_response("server error occurred", "test-note")
|
||||
|
||||
assert "# Delete Failed - System Error" in result
|
||||
assert "A system error occurred while deleting 'test-note'" in result
|
||||
assert "Try again" in result
|
||||
assert "Check file status" in result
|
||||
|
||||
def test_format_delete_error_filesystem_error(self):
|
||||
"""Test formatting for filesystem errors."""
|
||||
result = _format_delete_error_response("filesystem error", "test-note")
|
||||
|
||||
assert "# Delete Failed - System Error" in result
|
||||
assert "A system error occurred while deleting 'test-note'" in result
|
||||
|
||||
def test_format_delete_error_disk_error(self):
|
||||
"""Test formatting for disk errors."""
|
||||
result = _format_delete_error_response("disk full", "test-note")
|
||||
|
||||
assert "# Delete Failed - System Error" in result
|
||||
assert "A system error occurred while deleting 'test-note'" in result
|
||||
|
||||
def test_format_delete_error_database_error(self):
|
||||
"""Test formatting for database errors."""
|
||||
result = _format_delete_error_response("database error", "test-note")
|
||||
|
||||
assert "# Delete Failed - Database Error" in result
|
||||
assert "A database error occurred while deleting 'test-note'" in result
|
||||
assert "Sync conflict" in result
|
||||
assert "Database lock" in result
|
||||
|
||||
def test_format_delete_error_sync_error(self):
|
||||
"""Test formatting for sync errors."""
|
||||
result = _format_delete_error_response("sync failed", "test-note")
|
||||
|
||||
assert "# Delete Failed - Database Error" in result
|
||||
assert "A database error occurred while deleting 'test-note'" in result
|
||||
|
||||
def test_format_delete_error_generic(self):
|
||||
"""Test formatting for generic errors."""
|
||||
result = _format_delete_error_response("unknown error", "test-note")
|
||||
|
||||
assert "# Delete Failed" in result
|
||||
assert "Error deleting note 'test-note': unknown error" in result
|
||||
assert "General troubleshooting" in result
|
||||
assert "Verify the note exists" in result
|
||||
|
||||
def test_format_delete_error_with_complex_identifier(self):
|
||||
"""Test formatting with complex identifiers (permalinks)."""
|
||||
result = _format_delete_error_response("entity not found", "folder/note-title")
|
||||
|
||||
assert 'search_notes("note-title")' in result
|
||||
assert "Note Title" in result # Title format
|
||||
assert "folder/note-title" in result # Permalink format
|
||||
|
||||
|
||||
# Integration tests removed to focus on error formatting coverage
|
||||
# The error formatting tests above provide the necessary coverage for MCP tool error messaging
|
||||
@@ -10,14 +10,14 @@ from basic_memory.mcp.tools.write_note import write_note
|
||||
async def test_edit_note_append_operation(client):
|
||||
"""Test appending content to an existing note."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note\nOriginal content here.",
|
||||
)
|
||||
|
||||
# Append content
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="test/test-note",
|
||||
operation="append",
|
||||
content="\n## New Section\nAppended content here.",
|
||||
@@ -34,14 +34,14 @@ async def test_edit_note_append_operation(client):
|
||||
async def test_edit_note_prepend_operation(client):
|
||||
"""Test prepending content to an existing note."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Meeting Notes",
|
||||
folder="meetings",
|
||||
content="# Meeting Notes\nExisting content.",
|
||||
)
|
||||
|
||||
# Prepend content
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="meetings/meeting-notes",
|
||||
operation="prepend",
|
||||
content="## 2025-05-25 Update\nNew meeting notes.\n",
|
||||
@@ -58,14 +58,14 @@ async def test_edit_note_prepend_operation(client):
|
||||
async def test_edit_note_find_replace_operation(client):
|
||||
"""Test find and replace operation."""
|
||||
# Create initial note with version info
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Config Document",
|
||||
folder="config",
|
||||
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
|
||||
)
|
||||
|
||||
# Replace version - expecting 2 replacements
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="config/config-document",
|
||||
operation="find_replace",
|
||||
content="v0.13.0",
|
||||
@@ -83,14 +83,14 @@ async def test_edit_note_find_replace_operation(client):
|
||||
async def test_edit_note_replace_section_operation(client):
|
||||
"""Test replacing content under a specific section."""
|
||||
# Create initial note with sections
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="API Specification",
|
||||
folder="specs",
|
||||
content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.",
|
||||
)
|
||||
|
||||
# Replace implementation section
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="specs/api-specification",
|
||||
operation="replace_section",
|
||||
content="New implementation approach using FastAPI.\nImproved error handling.\n",
|
||||
@@ -106,7 +106,7 @@ async def test_edit_note_replace_section_operation(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_note_nonexistent_note(client):
|
||||
"""Test editing a note that doesn't exist - should return helpful guidance."""
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="nonexistent/note", operation="append", content="Some content"
|
||||
)
|
||||
|
||||
@@ -120,14 +120,16 @@ async def test_edit_note_nonexistent_note(client):
|
||||
async def test_edit_note_invalid_operation(client):
|
||||
"""Test using an invalid operation."""
|
||||
# Create a note first
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await edit_note(identifier="test/test-note", operation="invalid_op", content="Some content")
|
||||
await edit_note.fn(
|
||||
identifier="test/test-note", operation="invalid_op", content="Some content"
|
||||
)
|
||||
|
||||
assert "Invalid operation 'invalid_op'" in str(exc_info.value)
|
||||
|
||||
@@ -136,14 +138,14 @@ async def test_edit_note_invalid_operation(client):
|
||||
async def test_edit_note_find_replace_missing_find_text(client):
|
||||
"""Test find_replace operation without find_text parameter."""
|
||||
# Create a note first
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await edit_note(
|
||||
await edit_note.fn(
|
||||
identifier="test/test-note", operation="find_replace", content="replacement"
|
||||
)
|
||||
|
||||
@@ -154,14 +156,14 @@ async def test_edit_note_find_replace_missing_find_text(client):
|
||||
async def test_edit_note_replace_section_missing_section(client):
|
||||
"""Test replace_section operation without section parameter."""
|
||||
# Create a note first
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await edit_note(
|
||||
await edit_note.fn(
|
||||
identifier="test/test-note", operation="replace_section", content="new content"
|
||||
)
|
||||
|
||||
@@ -172,14 +174,14 @@ async def test_edit_note_replace_section_missing_section(client):
|
||||
async def test_edit_note_replace_section_nonexistent_section(client):
|
||||
"""Test replacing a section that doesn't exist - should append it."""
|
||||
# Create initial note without the target section
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Document",
|
||||
folder="docs",
|
||||
content="# Document\n\n## Existing Section\nSome content here.",
|
||||
)
|
||||
|
||||
# Try to replace non-existent section
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="docs/document",
|
||||
operation="replace_section",
|
||||
content="New section content here.\n",
|
||||
@@ -196,14 +198,14 @@ async def test_edit_note_replace_section_nonexistent_section(client):
|
||||
async def test_edit_note_with_observations_and_relations(client):
|
||||
"""Test editing a note that contains observations and relations."""
|
||||
# Create note with semantic content
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Feature Spec",
|
||||
folder="features",
|
||||
content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.",
|
||||
)
|
||||
|
||||
# Append more semantic content
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="features/feature-spec",
|
||||
operation="append",
|
||||
content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]",
|
||||
@@ -219,7 +221,7 @@ async def test_edit_note_with_observations_and_relations(client):
|
||||
async def test_edit_note_identifier_variations(client):
|
||||
"""Test that various identifier formats work."""
|
||||
# Create a note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Document",
|
||||
folder="docs",
|
||||
content="# Test Document\nOriginal content.",
|
||||
@@ -233,7 +235,7 @@ async def test_edit_note_identifier_variations(client):
|
||||
]
|
||||
|
||||
for identifier in identifiers_to_test:
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier=identifier, operation="append", content=f"\n## Update via {identifier}"
|
||||
)
|
||||
|
||||
@@ -246,14 +248,14 @@ async def test_edit_note_identifier_variations(client):
|
||||
async def test_edit_note_find_replace_no_matches(client):
|
||||
"""Test find_replace when the find_text doesn't exist - should return error."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note\nSome content here.",
|
||||
)
|
||||
|
||||
# Try to replace text that doesn't exist - should fail with default expected_replacements=1
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="test/test-note",
|
||||
operation="find_replace",
|
||||
content="replacement",
|
||||
@@ -270,14 +272,14 @@ async def test_edit_note_find_replace_no_matches(client):
|
||||
async def test_edit_note_empty_content_operations(client):
|
||||
"""Test operations with empty content."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note\nOriginal content.",
|
||||
)
|
||||
|
||||
# Test append with empty content
|
||||
result = await edit_note(identifier="test/test-note", operation="append", content="")
|
||||
result = await edit_note.fn(identifier="test/test-note", operation="append", content="")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Edited note (append)" in result
|
||||
@@ -288,14 +290,14 @@ async def test_edit_note_empty_content_operations(client):
|
||||
async def test_edit_note_find_replace_wrong_count(client):
|
||||
"""Test find_replace when replacement count doesn't match expected."""
|
||||
# Create initial note with version info
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Config Document",
|
||||
folder="config",
|
||||
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
|
||||
)
|
||||
|
||||
# Try to replace expecting 1 occurrence, but there are actually 2
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="config/config-document",
|
||||
operation="find_replace",
|
||||
content="v0.13.0",
|
||||
@@ -315,14 +317,14 @@ async def test_edit_note_find_replace_wrong_count(client):
|
||||
async def test_edit_note_replace_section_multiple_sections(client):
|
||||
"""Test replace_section with multiple sections having same header - should return helpful error."""
|
||||
# Create note with duplicate section headers
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance",
|
||||
)
|
||||
|
||||
# Try to replace section when multiple exist
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="docs/sample-note",
|
||||
operation="replace_section",
|
||||
content="New content",
|
||||
@@ -340,14 +342,14 @@ async def test_edit_note_replace_section_multiple_sections(client):
|
||||
async def test_edit_note_find_replace_empty_find_text(client):
|
||||
"""Test find_replace with empty/whitespace find_text - should return helpful error."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note\nSome content here.",
|
||||
)
|
||||
|
||||
# Try with whitespace-only find_text - this should be caught by service validation
|
||||
result = await edit_note(
|
||||
result = await edit_note.fn(
|
||||
identifier="test/test-note",
|
||||
operation="find_replace",
|
||||
content="replacement",
|
||||
|
||||
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools.write_note import write_note
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_empty(client):
|
||||
"""Test listing directory when no entities exist."""
|
||||
result = await list_directory()
|
||||
result = await list_directory.fn()
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "No files found in directory '/'" in result
|
||||
@@ -26,7 +26,7 @@ async def test_list_directory_with_test_graph(client, test_graph):
|
||||
# /test/Root.md
|
||||
|
||||
# List root directory
|
||||
result = await list_directory()
|
||||
result = await list_directory.fn()
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Contents of '/' (depth 1):" in result
|
||||
@@ -38,7 +38,7 @@ async def test_list_directory_with_test_graph(client, test_graph):
|
||||
async def test_list_directory_specific_path(client, test_graph):
|
||||
"""Test listing specific directory path."""
|
||||
# List the test directory
|
||||
result = await list_directory(dir_name="/test")
|
||||
result = await list_directory.fn(dir_name="/test")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Contents of '/test' (depth 1):" in result
|
||||
@@ -54,7 +54,7 @@ async def test_list_directory_specific_path(client, test_graph):
|
||||
async def test_list_directory_with_glob_filter(client, test_graph):
|
||||
"""Test listing directory with glob filtering."""
|
||||
# Filter for files containing "Connected"
|
||||
result = await list_directory(dir_name="/test", file_name_glob="*Connected*")
|
||||
result = await list_directory.fn(dir_name="/test", file_name_glob="*Connected*")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Files in '/test' matching '*Connected*' (depth 1):" in result
|
||||
@@ -70,7 +70,7 @@ async def test_list_directory_with_glob_filter(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_with_markdown_filter(client, test_graph):
|
||||
"""Test listing directory with markdown file filter."""
|
||||
result = await list_directory(dir_name="/test", file_name_glob="*.md")
|
||||
result = await list_directory.fn(dir_name="/test", file_name_glob="*.md")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Files in '/test' matching '*.md' (depth 1):" in result
|
||||
@@ -87,7 +87,7 @@ async def test_list_directory_with_markdown_filter(client, test_graph):
|
||||
async def test_list_directory_with_depth_control(client, test_graph):
|
||||
"""Test listing directory with depth control."""
|
||||
# Depth 1: should return only the test directory
|
||||
result_depth_1 = await list_directory(dir_name="/", depth=1)
|
||||
result_depth_1 = await list_directory.fn(dir_name="/", depth=1)
|
||||
|
||||
assert isinstance(result_depth_1, str)
|
||||
assert "Contents of '/' (depth 1):" in result_depth_1
|
||||
@@ -95,7 +95,7 @@ async def test_list_directory_with_depth_control(client, test_graph):
|
||||
assert "Total: 1 items (1 directory)" in result_depth_1
|
||||
|
||||
# Depth 2: should return directory + its files
|
||||
result_depth_2 = await list_directory(dir_name="/", depth=2)
|
||||
result_depth_2 = await list_directory.fn(dir_name="/", depth=2)
|
||||
|
||||
assert isinstance(result_depth_2, str)
|
||||
assert "Contents of '/' (depth 2):" in result_depth_2
|
||||
@@ -111,7 +111,7 @@ async def test_list_directory_with_depth_control(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_nonexistent_path(client, test_graph):
|
||||
"""Test listing nonexistent directory."""
|
||||
result = await list_directory(dir_name="/nonexistent")
|
||||
result = await list_directory.fn(dir_name="/nonexistent")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "No files found in directory '/nonexistent'" in result
|
||||
@@ -120,7 +120,7 @@ async def test_list_directory_nonexistent_path(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_glob_no_matches(client, test_graph):
|
||||
"""Test listing directory with glob that matches nothing."""
|
||||
result = await list_directory(dir_name="/test", file_name_glob="*.xyz")
|
||||
result = await list_directory.fn(dir_name="/test", file_name_glob="*.xyz")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "No files found in directory '/test' matching '*.xyz'" in result
|
||||
@@ -130,21 +130,21 @@ async def test_list_directory_glob_no_matches(client, test_graph):
|
||||
async def test_list_directory_with_created_notes(client):
|
||||
"""Test listing directory with dynamically created notes."""
|
||||
# Create some test notes
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Project Planning",
|
||||
folder="projects",
|
||||
content="# Project Planning\nThis is about planning projects.",
|
||||
tags=["planning", "project"],
|
||||
)
|
||||
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Meeting Notes",
|
||||
folder="projects",
|
||||
content="# Meeting Notes\nNotes from the meeting.",
|
||||
tags=["meeting", "notes"],
|
||||
)
|
||||
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Research Document",
|
||||
folder="research",
|
||||
content="# Research\nSome research findings.",
|
||||
@@ -152,7 +152,7 @@ async def test_list_directory_with_created_notes(client):
|
||||
)
|
||||
|
||||
# List root directory
|
||||
result_root = await list_directory()
|
||||
result_root = await list_directory.fn()
|
||||
|
||||
assert isinstance(result_root, str)
|
||||
assert "Contents of '/' (depth 1):" in result_root
|
||||
@@ -161,7 +161,7 @@ async def test_list_directory_with_created_notes(client):
|
||||
assert "Total: 2 items (2 directories)" in result_root
|
||||
|
||||
# List projects directory
|
||||
result_projects = await list_directory(dir_name="/projects")
|
||||
result_projects = await list_directory.fn(dir_name="/projects")
|
||||
|
||||
assert isinstance(result_projects, str)
|
||||
assert "Contents of '/projects' (depth 1):" in result_projects
|
||||
@@ -170,7 +170,7 @@ async def test_list_directory_with_created_notes(client):
|
||||
assert "Total: 2 items (2 files)" in result_projects
|
||||
|
||||
# Test glob filter for "Meeting"
|
||||
result_meeting = await list_directory(dir_name="/projects", file_name_glob="*Meeting*")
|
||||
result_meeting = await list_directory.fn(dir_name="/projects", file_name_glob="*Meeting*")
|
||||
|
||||
assert isinstance(result_meeting, str)
|
||||
assert "Files in '/projects' matching '*Meeting*' (depth 1):" in result_meeting
|
||||
@@ -186,7 +186,7 @@ async def test_list_directory_path_normalization(client, test_graph):
|
||||
paths_to_test = ["/test", "test", "/test/", "test/"]
|
||||
|
||||
for path in paths_to_test:
|
||||
result = await list_directory(dir_name=path)
|
||||
result = await list_directory.fn(dir_name=path)
|
||||
# All should return the same number of items
|
||||
assert "Total: 5 items (5 files)" in result
|
||||
assert "📄 Connected Entity 1.md" in result
|
||||
@@ -195,7 +195,7 @@ async def test_list_directory_path_normalization(client, test_graph):
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_shows_file_metadata(client, test_graph):
|
||||
"""Test that file metadata is displayed correctly."""
|
||||
result = await list_directory(dir_name="/test")
|
||||
result = await list_directory.fn(dir_name="/test")
|
||||
|
||||
assert isinstance(result, str)
|
||||
# Should show file names
|
||||
|
||||
+168
-123
@@ -1,8 +1,9 @@
|
||||
"""Tests for the move_note MCP tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from basic_memory.mcp.tools.move_note import move_note
|
||||
from basic_memory.mcp.tools.move_note import move_note, _format_move_error_response
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
|
||||
@@ -11,32 +12,30 @@ from basic_memory.mcp.tools.read_note import read_note
|
||||
async def test_move_note_success(app, client):
|
||||
"""Test successfully moving a note to a new location."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
content="# Test Note\nOriginal content here.",
|
||||
)
|
||||
|
||||
# Move note
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="source/test-note",
|
||||
destination_path="target/MovedNote.md",
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "✅ Note moved successfully" in result
|
||||
assert "source/test-note" in result
|
||||
assert "target/MovedNote.md" in result
|
||||
|
||||
# Verify original location no longer exists
|
||||
try:
|
||||
await read_note("source/test-note")
|
||||
await read_note.fn("source/test-note")
|
||||
assert False, "Original note should not exist after move"
|
||||
except Exception:
|
||||
pass # Expected - note should not exist at original location
|
||||
|
||||
# Verify note exists at new location with same content
|
||||
content = await read_note("target/moved-note")
|
||||
content = await read_note.fn("target/moved-note")
|
||||
assert "# Test Note" in content
|
||||
assert "Original content here" in content
|
||||
assert "permalink: target/moved-note" in content
|
||||
@@ -46,14 +45,14 @@ async def test_move_note_success(app, client):
|
||||
async def test_move_note_with_folder_creation(client):
|
||||
"""Test moving note creates necessary folders."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Deep Note",
|
||||
folder="",
|
||||
content="# Deep Note\nContent in root folder.",
|
||||
)
|
||||
|
||||
# Move to deeply nested path
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="deep-note",
|
||||
destination_path="deeply/nested/folder/DeepNote.md",
|
||||
)
|
||||
@@ -62,16 +61,16 @@ async def test_move_note_with_folder_creation(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify note exists at new location
|
||||
content = await read_note("deeply/nested/folder/deep-note")
|
||||
content = await read_note.fn("deeply/nested/folder/deep-note")
|
||||
assert "# Deep Note" in content
|
||||
assert "Content in root folder" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_with_observations_and_relations(client):
|
||||
async def test_move_note_with_observations_and_relations(app, client):
|
||||
"""Test moving note preserves observations and relations."""
|
||||
# Create note with complex semantic content
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Complex Entity",
|
||||
folder="source",
|
||||
content="""# Complex Entity
|
||||
@@ -89,7 +88,7 @@ Some additional content.
|
||||
)
|
||||
|
||||
# Move note
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="source/complex-entity",
|
||||
destination_path="target/MovedComplex.md",
|
||||
)
|
||||
@@ -98,7 +97,7 @@ Some additional content.
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify moved note preserves all content
|
||||
content = await read_note("target/moved-complex")
|
||||
content = await read_note.fn("target/moved-complex")
|
||||
assert "Important observation #tag1" in content
|
||||
assert "Key feature #feature" in content
|
||||
assert "[[SomeOtherEntity]]" in content
|
||||
@@ -110,14 +109,14 @@ Some additional content.
|
||||
async def test_move_note_by_title(client):
|
||||
"""Test moving note using title as identifier."""
|
||||
# Create note with unique title
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="UniqueTestTitle",
|
||||
folder="source",
|
||||
content="# UniqueTestTitle\nTest content.",
|
||||
)
|
||||
|
||||
# Move using title as identifier
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="UniqueTestTitle",
|
||||
destination_path="target/MovedByTitle.md",
|
||||
)
|
||||
@@ -126,7 +125,7 @@ async def test_move_note_by_title(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify note exists at new location
|
||||
content = await read_note("target/moved-by-title")
|
||||
content = await read_note.fn("target/moved-by-title")
|
||||
assert "# UniqueTestTitle" in content
|
||||
assert "Test content" in content
|
||||
|
||||
@@ -135,14 +134,14 @@ async def test_move_note_by_title(client):
|
||||
async def test_move_note_by_file_path(client):
|
||||
"""Test moving note using file path as identifier."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="PathTest",
|
||||
folder="source",
|
||||
content="# PathTest\nContent for path test.",
|
||||
)
|
||||
|
||||
# Move using file path as identifier
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="source/PathTest.md",
|
||||
destination_path="target/MovedByPath.md",
|
||||
)
|
||||
@@ -151,7 +150,7 @@ async def test_move_note_by_file_path(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify note exists at new location
|
||||
content = await read_note("target/moved-by-path")
|
||||
content = await read_note.fn("target/moved-by-path")
|
||||
assert "# PathTest" in content
|
||||
assert "Content for path test" in content
|
||||
|
||||
@@ -159,135 +158,116 @@ async def test_move_note_by_file_path(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_nonexistent_note(client):
|
||||
"""Test moving a note that doesn't exist."""
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="nonexistent/note",
|
||||
destination_path="target/SomeFile.md",
|
||||
)
|
||||
|
||||
# Should raise an exception from the API with friendly error message
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Entity not found" in error_msg
|
||||
or "Invalid request" in error_msg
|
||||
or "malformed" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="nonexistent/note",
|
||||
destination_path="target/SomeFile.md",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Note Not Found" in result
|
||||
assert "could not be found for moving" in result
|
||||
assert "Search for the note first" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_invalid_destination_path(client):
|
||||
"""Test moving note with invalid destination path."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
# Test absolute path (should be rejected by validation)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="source/test-note",
|
||||
destination_path="/absolute/path.md",
|
||||
)
|
||||
|
||||
# Should raise validation error (422 gets wrapped as client error)
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Client error (422)" in error_msg
|
||||
or "could not be completed" in error_msg
|
||||
or "destination_path must be relative" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="source/test-note",
|
||||
destination_path="/absolute/path.md",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
assert "/absolute/path.md" in result or "Invalid" in result or "path" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_destination_exists(client):
|
||||
"""Test moving note to existing destination."""
|
||||
# Create source note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="SourceNote",
|
||||
folder="source",
|
||||
content="# SourceNote\nSource content.",
|
||||
)
|
||||
|
||||
# Create destination note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="DestinationNote",
|
||||
folder="target",
|
||||
content="# DestinationNote\nDestination content.",
|
||||
)
|
||||
|
||||
# Try to move source to existing destination
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="source/source-note",
|
||||
destination_path="target/DestinationNote.md",
|
||||
)
|
||||
|
||||
# Should raise an exception (400 gets wrapped as malformed request)
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Destination already exists" in error_msg
|
||||
or "Invalid request" in error_msg
|
||||
or "malformed" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="source/source-note",
|
||||
destination_path="target/DestinationNote.md",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
assert "already exists" in result or "Destination" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_same_location(client):
|
||||
"""Test moving note to the same location."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="SameLocationTest",
|
||||
folder="test",
|
||||
content="# SameLocationTest\nContent here.",
|
||||
)
|
||||
|
||||
# Try to move to same location
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="test/same-location-test",
|
||||
destination_path="test/SameLocationTest.md",
|
||||
)
|
||||
|
||||
# Should raise an exception (400 gets wrapped as malformed request)
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Destination already exists" in error_msg
|
||||
or "same location" in error_msg
|
||||
or "Invalid request" in error_msg
|
||||
or "malformed" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="test/same-location-test",
|
||||
destination_path="test/SameLocationTest.md",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
assert "already exists" in result or "same" in result or "Destination" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_rename_only(client):
|
||||
"""Test moving note within same folder (rename operation)."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="OriginalName",
|
||||
folder="test",
|
||||
content="# OriginalName\nContent to rename.",
|
||||
)
|
||||
|
||||
# Rename within same folder
|
||||
result = await move_note(
|
||||
await move_note.fn(
|
||||
identifier="test/original-name",
|
||||
destination_path="test/NewName.md",
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify original is gone and new exists
|
||||
# Verify original is gone
|
||||
try:
|
||||
await read_note("test/original-name")
|
||||
await read_note.fn("test/original-name")
|
||||
assert False, "Original note should not exist after rename"
|
||||
except Exception:
|
||||
pass # Expected
|
||||
|
||||
# Verify new name exists with same content
|
||||
content = await read_note("test/new-name")
|
||||
content = await read_note.fn("test/new-name")
|
||||
assert "# OriginalName" in content # Title in content remains same
|
||||
assert "Content to rename" in content
|
||||
assert "permalink: test/new-name" in content
|
||||
@@ -297,14 +277,14 @@ async def test_move_note_rename_only(client):
|
||||
async def test_move_note_complex_filename(client):
|
||||
"""Test moving note with spaces in filename."""
|
||||
# Create note with spaces in name
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Meeting Notes 2025",
|
||||
folder="meetings",
|
||||
content="# Meeting Notes 2025\nMeeting content with dates.",
|
||||
)
|
||||
|
||||
# Move to new location
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="meetings/meeting-notes-2025",
|
||||
destination_path="archive/2025/meetings/Meeting Notes 2025.md",
|
||||
)
|
||||
@@ -313,16 +293,16 @@ async def test_move_note_complex_filename(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify note exists at new location with correct content
|
||||
content = await read_note("archive/2025/meetings/meeting-notes-2025")
|
||||
content = await read_note.fn("archive/2025/meetings/meeting-notes-2025")
|
||||
assert "# Meeting Notes 2025" in content
|
||||
assert "Meeting content with dates" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_with_tags(client):
|
||||
async def test_move_note_with_tags(app, client):
|
||||
"""Test moving note with tags preserves tags."""
|
||||
# Create note with tags
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Tagged Note",
|
||||
folder="source",
|
||||
content="# Tagged Note\nContent with tags.",
|
||||
@@ -330,7 +310,7 @@ async def test_move_note_with_tags(client):
|
||||
)
|
||||
|
||||
# Move note
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="source/tagged-note",
|
||||
destination_path="target/MovedTaggedNote.md",
|
||||
)
|
||||
@@ -339,7 +319,7 @@ async def test_move_note_with_tags(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify tags are preserved in correct YAML format
|
||||
content = await read_note("target/moved-tagged-note")
|
||||
content = await read_note.fn("target/moved-tagged-note")
|
||||
assert "- important" in content
|
||||
assert "- work" in content
|
||||
assert "- project" in content
|
||||
@@ -349,68 +329,58 @@ async def test_move_note_with_tags(client):
|
||||
async def test_move_note_empty_string_destination(client):
|
||||
"""Test moving note with empty destination path."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
# Test empty destination path
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="source/test-note",
|
||||
destination_path="",
|
||||
)
|
||||
|
||||
# Should raise validation error (422 gets wrapped as client error)
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"String should have at least 1 character" in error_msg
|
||||
or "cannot be empty" in error_msg
|
||||
or "Client error (422)" in error_msg
|
||||
or "could not be completed" in error_msg
|
||||
or "destination_path cannot be empty" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="source/test-note",
|
||||
destination_path="",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
assert "empty" in result or "Invalid" in result or "path" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_parent_directory_path(client):
|
||||
"""Test moving note with parent directory in destination path."""
|
||||
# Create initial note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
# Test parent directory path
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await move_note(
|
||||
identifier="source/test-note",
|
||||
destination_path="../parent/file.md",
|
||||
)
|
||||
|
||||
# Should raise validation error (422 gets wrapped as client error)
|
||||
error_msg = str(exc_info.value)
|
||||
assert (
|
||||
"Client error (422)" in error_msg
|
||||
or "could not be completed" in error_msg
|
||||
or "cannot contain '..' path components" in error_msg
|
||||
result = await move_note.fn(
|
||||
identifier="source/test-note",
|
||||
destination_path="../parent/file.md",
|
||||
)
|
||||
|
||||
# Should return user-friendly error message string
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
assert "parent" in result or "Invalid" in result or "path" in result or ".." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_identifier_variations(client):
|
||||
"""Test that various identifier formats work for moving."""
|
||||
# Create a note to test different identifier formats
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Document",
|
||||
folder="docs",
|
||||
content="# Test Document\nContent for testing identifiers.",
|
||||
)
|
||||
|
||||
# Test with permalink identifier
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="docs/test-document",
|
||||
destination_path="moved/TestDocument.md",
|
||||
)
|
||||
@@ -419,23 +389,23 @@ async def test_move_note_identifier_variations(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify it moved correctly
|
||||
content = await read_note("moved/test-document")
|
||||
content = await read_note.fn("moved/test-document")
|
||||
assert "# Test Document" in content
|
||||
assert "Content for testing identifiers" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_preserves_frontmatter(client):
|
||||
async def test_move_note_preserves_frontmatter(app, client):
|
||||
"""Test that moving preserves custom frontmatter."""
|
||||
# Create note with custom frontmatter by first creating it normally
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Custom Frontmatter Note",
|
||||
folder="source",
|
||||
content="# Custom Frontmatter Note\nContent with custom metadata.",
|
||||
)
|
||||
|
||||
# Move the note
|
||||
result = await move_note(
|
||||
result = await move_note.fn(
|
||||
identifier="source/custom-frontmatter-note",
|
||||
destination_path="target/MovedCustomNote.md",
|
||||
)
|
||||
@@ -444,9 +414,84 @@ async def test_move_note_preserves_frontmatter(client):
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify the moved note has proper frontmatter structure
|
||||
content = await read_note("target/moved-custom-note")
|
||||
content = await read_note.fn("target/moved-custom-note")
|
||||
assert "title: Custom Frontmatter Note" in content
|
||||
assert "type: note" in content
|
||||
assert "permalink: target/moved-custom-note" in content
|
||||
assert "# Custom Frontmatter Note" in content
|
||||
assert "Content with custom metadata" in content
|
||||
|
||||
|
||||
class TestMoveNoteErrorFormatting:
|
||||
"""Test move note error formatting for better user experience."""
|
||||
|
||||
def test_format_move_error_invalid_path(self):
|
||||
"""Test formatting for invalid path errors."""
|
||||
result = _format_move_error_response("invalid path format", "test-note", "/invalid/path.md")
|
||||
|
||||
assert "# Move Failed - Invalid Destination Path" in result
|
||||
assert "The destination path '/invalid/path.md' is not valid" in result
|
||||
assert "Relative paths only" in result
|
||||
assert "Include file extension" in result
|
||||
|
||||
def test_format_move_error_permission_denied(self):
|
||||
"""Test formatting for permission errors."""
|
||||
result = _format_move_error_response("permission denied", "test-note", "target/file.md")
|
||||
|
||||
assert "# Move Failed - Permission Error" in result
|
||||
assert "You don't have permission to move 'test-note'" in result
|
||||
assert "Check file permissions" in result
|
||||
assert "Check file locks" in result
|
||||
|
||||
def test_format_move_error_source_missing(self):
|
||||
"""Test formatting for source file missing errors."""
|
||||
result = _format_move_error_response("source file missing", "test-note", "target/file.md")
|
||||
|
||||
assert "# Move Failed - Source File Missing" in result
|
||||
assert "The source file for 'test-note' was not found on disk" in result
|
||||
assert "database and filesystem are out of sync" in result
|
||||
|
||||
def test_format_move_error_server_error(self):
|
||||
"""Test formatting for server errors."""
|
||||
result = _format_move_error_response("server error occurred", "test-note", "target/file.md")
|
||||
|
||||
assert "# Move Failed - System Error" in result
|
||||
assert "A system error occurred while moving 'test-note'" in result
|
||||
assert "Try again" in result
|
||||
assert "Check disk space" in result
|
||||
|
||||
|
||||
class TestMoveNoteErrorHandling:
|
||||
"""Test move note exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_exception_handling(self):
|
||||
"""Test exception handling in move_note."""
|
||||
with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
mock_get_project.return_value.name = "test-project"
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_post",
|
||||
side_effect=Exception("entity not found"),
|
||||
):
|
||||
result = await move_note.fn("test-note", "target/file.md")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Note Not Found" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_permission_error_handling(self):
|
||||
"""Test permission error handling in move_note."""
|
||||
with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
mock_get_project.return_value.name = "test-project"
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.move_note.call_post",
|
||||
side_effect=Exception("permission denied"),
|
||||
):
|
||||
result = await move_note.fn("test-note", "target/file.md")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - Permission Error" in result
|
||||
|
||||
@@ -26,7 +26,7 @@ async def mock_call_get():
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_search():
|
||||
"""Mock for search tool."""
|
||||
with patch("basic_memory.mcp.tools.read_note.search_notes") as mock:
|
||||
with patch("basic_memory.mcp.tools.read_note.search_notes.fn") as mock:
|
||||
# Default to empty results
|
||||
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
|
||||
yield mock
|
||||
@@ -36,10 +36,10 @@ async def mock_search():
|
||||
async def test_read_note_by_title(app):
|
||||
"""Test reading a note by its title."""
|
||||
# First create a note
|
||||
await write_note(title="Special Note", folder="test", content="Note content here")
|
||||
await write_note.fn(title="Special Note", folder="test", content="Note content here")
|
||||
|
||||
# Should be able to read it by title
|
||||
content = await read_note("Special Note")
|
||||
content = await read_note.fn("Special Note")
|
||||
assert "Note content here" in content
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_read_note_by_title(app):
|
||||
async def test_note_unicode_content(app):
|
||||
"""Test handling of unicode content in"""
|
||||
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
result = await write_note(title="Unicode Test", folder="test", content=content)
|
||||
result = await write_note.fn(title="Unicode Test", folder="test", content=content)
|
||||
|
||||
assert (
|
||||
dedent("""
|
||||
@@ -60,7 +60,7 @@ async def test_note_unicode_content(app):
|
||||
)
|
||||
|
||||
# Read back should preserve unicode
|
||||
result = await read_note("test/unicode-test")
|
||||
result = await read_note.fn("test/unicode-test")
|
||||
assert content in result
|
||||
|
||||
|
||||
@@ -75,16 +75,16 @@ async def test_multiple_notes(app):
|
||||
]
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
await write_note.fn(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await read_note(permalink)
|
||||
note = await read_note.fn(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once
|
||||
|
||||
result = await read_note("test/*")
|
||||
result = await read_note.fn("test/*")
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
@@ -108,15 +108,15 @@ async def test_multiple_notes_pagination(app):
|
||||
]
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
await write_note.fn(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await read_note(permalink)
|
||||
note = await read_note.fn(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once with pagination
|
||||
result = await read_note("test/*", page=1, page_size=2)
|
||||
result = await read_note.fn("test/*", page=1, page_size=2)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
@@ -136,7 +136,7 @@ async def test_read_note_memory_url(app):
|
||||
- Return the note content
|
||||
"""
|
||||
# First create a note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
content="Testing memory:// URL handling",
|
||||
@@ -145,7 +145,7 @@ async def test_read_note_memory_url(app):
|
||||
|
||||
# Should be able to read it with a memory:// URL
|
||||
memory_url = "memory://test/memory-url-test"
|
||||
content = await read_note(memory_url)
|
||||
content = await read_note.fn(memory_url)
|
||||
assert "Testing memory:// URL handling" in content
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ async def test_read_note_direct_success(mock_call_get):
|
||||
mock_call_get.return_value = mock_response
|
||||
|
||||
# Call the function
|
||||
result = await read_note("test/test-note")
|
||||
result = await read_note.fn("test/test-note")
|
||||
|
||||
# Verify direct lookup was used
|
||||
mock_call_get.assert_called_once()
|
||||
@@ -199,7 +199,7 @@ async def test_read_note_title_search_fallback(mock_call_get, mock_search):
|
||||
)
|
||||
|
||||
# Call the function
|
||||
result = await read_note("Test Note")
|
||||
result = await read_note.fn("Test Note")
|
||||
|
||||
# Verify title search was used
|
||||
mock_search.assert_called_once()
|
||||
@@ -253,7 +253,7 @@ async def test_read_note_text_search_fallback(mock_call_get, mock_search):
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = await read_note("some query")
|
||||
result = await read_note.fn("some query")
|
||||
|
||||
# Verify both search types were used
|
||||
assert mock_search.call_count == 2
|
||||
@@ -281,7 +281,7 @@ async def test_read_note_complete_fallback(mock_call_get, mock_search):
|
||||
mock_search.return_value = SearchResponse(results=[], current_page=1, page_size=1)
|
||||
|
||||
# Call the function
|
||||
result = await read_note("nonexistent")
|
||||
result = await read_note.fn("nonexistent")
|
||||
|
||||
# Verify search was used
|
||||
assert mock_search.call_count == 2
|
||||
|
||||
@@ -31,7 +31,7 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await recent_activity(
|
||||
result = await recent_activity.fn(
|
||||
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
@@ -41,7 +41,7 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
# Test invalid timeframes should raise ValidationError
|
||||
for timeframe in invalid_timeframes:
|
||||
with pytest.raises(ToolError):
|
||||
await recent_activity(timeframe=timeframe)
|
||||
await recent_activity.fn(timeframe=timeframe)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -49,25 +49,25 @@ async def test_recent_activity_type_filters(client, test_graph):
|
||||
"""Test that recent_activity correctly filters by types."""
|
||||
|
||||
# Test single string type
|
||||
result = await recent_activity(type=SearchItemType.ENTITY)
|
||||
result = await recent_activity.fn(type=SearchItemType.ENTITY)
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
|
||||
|
||||
# Test single string type
|
||||
result = await recent_activity(type="entity")
|
||||
result = await recent_activity.fn(type="entity")
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
|
||||
|
||||
# Test single type
|
||||
result = await recent_activity(type=["entity"])
|
||||
result = await recent_activity.fn(type=["entity"])
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
assert all(isinstance(item.primary_result, EntitySummary) for item in result.results)
|
||||
|
||||
# Test multiple types
|
||||
result = await recent_activity(type=["entity", "observation"])
|
||||
result = await recent_activity.fn(type=["entity", "observation"])
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
assert all(
|
||||
@@ -77,7 +77,7 @@ async def test_recent_activity_type_filters(client, test_graph):
|
||||
)
|
||||
|
||||
# Test multiple types
|
||||
result = await recent_activity(type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION])
|
||||
result = await recent_activity.fn(type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION])
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
assert all(
|
||||
@@ -87,7 +87,7 @@ async def test_recent_activity_type_filters(client, test_graph):
|
||||
)
|
||||
|
||||
# Test all types
|
||||
result = await recent_activity(type=["entity", "observation", "relation"])
|
||||
result = await recent_activity.fn(type=["entity", "observation", "relation"])
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
# Results can be any type
|
||||
@@ -105,14 +105,14 @@ async def test_recent_activity_type_invalid(client, test_graph):
|
||||
|
||||
# Test single invalid string type
|
||||
with pytest.raises(ValueError) as e:
|
||||
await recent_activity(type="note")
|
||||
await recent_activity.fn(type="note")
|
||||
assert (
|
||||
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
|
||||
)
|
||||
|
||||
# Test invalid string array type
|
||||
with pytest.raises(ValueError) as e:
|
||||
await recent_activity(type=["note"])
|
||||
await recent_activity.fn(type=["note"])
|
||||
assert (
|
||||
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_read_file_text_file(app, synced_files):
|
||||
- Include correct metadata
|
||||
"""
|
||||
# First create a text file via notes
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Text Resource",
|
||||
folder="test",
|
||||
content="This is a test text resource",
|
||||
@@ -34,7 +34,7 @@ async def test_read_file_text_file(app, synced_files):
|
||||
assert result is not None
|
||||
|
||||
# Now read it as a resource
|
||||
response = await read_content("test/text-resource")
|
||||
response = await read_content.fn("test/text-resource")
|
||||
|
||||
assert response["type"] == "text"
|
||||
assert "This is a test text resource" in response["text"]
|
||||
@@ -52,7 +52,7 @@ async def test_read_content_file_path(app, synced_files):
|
||||
- Include correct metadata
|
||||
"""
|
||||
# First create a text file via notes
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Text Resource",
|
||||
folder="test",
|
||||
content="This is a test text resource",
|
||||
@@ -61,7 +61,7 @@ async def test_read_content_file_path(app, synced_files):
|
||||
assert result is not None
|
||||
|
||||
# Now read it as a resource
|
||||
response = await read_content("test/Text Resource.md")
|
||||
response = await read_content.fn("test/Text Resource.md")
|
||||
|
||||
assert response["type"] == "text"
|
||||
assert "This is a test text resource" in response["text"]
|
||||
@@ -82,7 +82,7 @@ async def test_read_file_image_file(app, synced_files):
|
||||
image_path = synced_files["image"].name
|
||||
|
||||
# Read it as a resource
|
||||
response = await read_content(image_path)
|
||||
response = await read_content.fn(image_path)
|
||||
|
||||
assert response["type"] == "image"
|
||||
assert response["source"]["type"] == "base64"
|
||||
@@ -110,7 +110,7 @@ async def test_read_file_pdf_file(app, synced_files):
|
||||
pdf_path = synced_files["pdf"].name
|
||||
|
||||
# Read it as a resource
|
||||
response = await read_content(pdf_path)
|
||||
response = await read_content.fn(pdf_path)
|
||||
|
||||
assert response["type"] == "document"
|
||||
assert response["source"]["type"] == "base64"
|
||||
@@ -126,14 +126,14 @@ async def test_read_file_pdf_file(app, synced_files):
|
||||
async def test_read_file_not_found(app):
|
||||
"""Test trying to read a non-existent"""
|
||||
with pytest.raises(ToolError, match="Resource not found"):
|
||||
await read_content("does-not-exist")
|
||||
await read_content.fn("does-not-exist")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_memory_url(app, synced_files):
|
||||
"""Test reading a resource using a memory:// URL."""
|
||||
# Create a text file via notes
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
content="Testing memory:// URL handling for resources",
|
||||
@@ -141,7 +141,7 @@ async def test_read_file_memory_url(app, synced_files):
|
||||
|
||||
# Read it with a memory:// URL
|
||||
memory_url = "memory://test/memory-url-test"
|
||||
response = await read_content(memory_url)
|
||||
response = await read_content.fn(memory_url)
|
||||
|
||||
assert response["type"] == "text"
|
||||
assert "Testing memory:// URL handling for resources" in response["text"]
|
||||
@@ -205,7 +205,7 @@ async def test_image_conversion(app, synced_files):
|
||||
image_path = synced_files["image"].name
|
||||
|
||||
# Test reading the resource
|
||||
response = await read_content(image_path)
|
||||
response = await read_content.fn(image_path)
|
||||
|
||||
assert response["type"] == "image"
|
||||
assert response["source"]["media_type"] == "image/jpeg"
|
||||
|
||||
+106
-17
@@ -2,16 +2,17 @@
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from basic_memory.mcp.tools import write_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.search import search_notes, _format_search_error_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_text(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
@@ -20,7 +21,7 @@ async def test_search_text(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="searchable")
|
||||
response = await search_notes.fn(query="searchable")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
@@ -31,7 +32,7 @@ async def test_search_text(client):
|
||||
async def test_search_title(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
@@ -40,7 +41,7 @@ async def test_search_title(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="Search Note", search_type="title")
|
||||
response = await search_notes.fn(query="Search Note", search_type="title")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
@@ -51,7 +52,7 @@ async def test_search_title(client):
|
||||
async def test_search_permalink(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
@@ -60,7 +61,7 @@ async def test_search_permalink(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="test/test-search-note", search_type="permalink")
|
||||
response = await search_notes.fn(query="test/test-search-note", search_type="permalink")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
@@ -71,7 +72,7 @@ async def test_search_permalink(client):
|
||||
async def test_search_permalink_match(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
@@ -80,7 +81,7 @@ async def test_search_permalink_match(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="test/test-search-*", search_type="permalink")
|
||||
response = await search_notes.fn(query="test/test-search-*", search_type="permalink")
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
@@ -91,7 +92,7 @@ async def test_search_permalink_match(client):
|
||||
async def test_search_pagination(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
@@ -100,7 +101,7 @@ async def test_search_pagination(client):
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
response = await search_notes(query="searchable", page=1, page_size=1)
|
||||
response = await search_notes.fn(query="searchable", page=1, page_size=1)
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) == 1
|
||||
@@ -111,14 +112,14 @@ async def test_search_pagination(client):
|
||||
async def test_search_with_type_filter(client):
|
||||
"""Test search with entity type filter."""
|
||||
# Create test content
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Entity Type Test",
|
||||
folder="test",
|
||||
content="# Test\nFiltered by type",
|
||||
)
|
||||
|
||||
# Search with type filter
|
||||
response = await search_notes(query="type", types=["note"])
|
||||
response = await search_notes.fn(query="type", types=["note"])
|
||||
|
||||
# Verify all results are entities
|
||||
assert all(r.type == "entity" for r in response.results)
|
||||
@@ -128,14 +129,14 @@ async def test_search_with_type_filter(client):
|
||||
async def test_search_with_entity_type_filter(client):
|
||||
"""Test search with entity type filter."""
|
||||
# Create test content
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Entity Type Test",
|
||||
folder="test",
|
||||
content="# Test\nFiltered by type",
|
||||
)
|
||||
|
||||
# Search with entity type filter
|
||||
response = await search_notes(query="type", entity_types=["entity"])
|
||||
response = await search_notes.fn(query="type", entity_types=["entity"])
|
||||
|
||||
# Verify all results are entities
|
||||
assert all(r.type == "entity" for r in response.results)
|
||||
@@ -145,7 +146,7 @@ async def test_search_with_entity_type_filter(client):
|
||||
async def test_search_with_date_filter(client):
|
||||
"""Test search with date filter."""
|
||||
# Create test content
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Recent Note",
|
||||
folder="test",
|
||||
content="# Test\nRecent content",
|
||||
@@ -153,7 +154,95 @@ async def test_search_with_date_filter(client):
|
||||
|
||||
# Search with date filter
|
||||
one_hour_ago = datetime.now() - timedelta(hours=1)
|
||||
response = await search_notes(query="recent", after_date=one_hour_ago.isoformat())
|
||||
response = await search_notes.fn(query="recent", after_date=one_hour_ago.isoformat())
|
||||
|
||||
# Verify we get results within timeframe
|
||||
assert len(response.results) > 0
|
||||
|
||||
|
||||
class TestSearchErrorFormatting:
|
||||
"""Test search error formatting for better user experience."""
|
||||
|
||||
def test_format_search_error_fts5_syntax(self):
|
||||
"""Test formatting for FTS5 syntax errors."""
|
||||
result = _format_search_error_response("syntax error in FTS5", "test query(")
|
||||
|
||||
assert "# Search Failed - Invalid Syntax" in result
|
||||
assert "The search query 'test query(' contains invalid syntax" in result
|
||||
assert "Special characters" in result
|
||||
assert "test query" in result # Clean query without special chars
|
||||
|
||||
def test_format_search_error_no_results(self):
|
||||
"""Test formatting for no results found."""
|
||||
result = _format_search_error_response("no results found", "very specific query")
|
||||
|
||||
assert "# Search Complete - No Results Found" in result
|
||||
assert "No content found matching 'very specific query'" in result
|
||||
assert "Broaden your search" in result
|
||||
assert "very" in result # Simplified query
|
||||
|
||||
def test_format_search_error_server_error(self):
|
||||
"""Test formatting for server errors."""
|
||||
result = _format_search_error_response("internal server error", "test query")
|
||||
|
||||
assert "# Search Failed - Server Error" in result
|
||||
assert "The search service encountered an error while processing 'test query'" in result
|
||||
assert "Try again" in result
|
||||
assert "Check project status" in result
|
||||
|
||||
def test_format_search_error_permission_denied(self):
|
||||
"""Test formatting for permission errors."""
|
||||
result = _format_search_error_response("permission denied", "test query")
|
||||
|
||||
assert "# Search Failed - Access Error" in result
|
||||
assert "You don't have permission to search" in result
|
||||
assert "Check your project access" in result
|
||||
|
||||
def test_format_search_error_project_not_found(self):
|
||||
"""Test formatting for project not found errors."""
|
||||
result = _format_search_error_response("current project not found", "test query")
|
||||
|
||||
assert "# Search Failed - Project Not Found" in result
|
||||
assert "The current project is not accessible" in result
|
||||
assert "Check available projects" in result
|
||||
|
||||
def test_format_search_error_generic(self):
|
||||
"""Test formatting for generic errors."""
|
||||
result = _format_search_error_response("unknown error", "test query")
|
||||
|
||||
assert "# Search Failed" in result
|
||||
assert "Error searching for 'test query': unknown error" in result
|
||||
assert "General troubleshooting" in result
|
||||
|
||||
|
||||
class TestSearchToolErrorHandling:
|
||||
"""Test search tool exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_exception_handling(self):
|
||||
"""Test exception handling in search_notes."""
|
||||
with patch("basic_memory.mcp.tools.search.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.search.call_post", side_effect=Exception("syntax error")
|
||||
):
|
||||
result = await search_notes.fn("test query")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Search Failed - Invalid Syntax" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_permission_error(self):
|
||||
"""Test search_notes with permission error."""
|
||||
with patch("basic_memory.mcp.tools.search.get_active_project") as mock_get_project:
|
||||
mock_get_project.return_value.project_url = "http://test"
|
||||
|
||||
with patch(
|
||||
"basic_memory.mcp.tools.search.call_post",
|
||||
side_effect=Exception("permission denied"),
|
||||
):
|
||||
result = await search_notes.fn("test query")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Search Failed - Access Error" in result
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for sync_status MCP tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from basic_memory.mcp.tools.sync_status import sync_status
|
||||
from basic_memory.services.sync_status_service import (
|
||||
SyncStatus,
|
||||
ProjectSyncStatus,
|
||||
SyncStatusTracker,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_completed():
|
||||
"""Test sync_status when all operations are completed."""
|
||||
# Mock sync status tracker with ready status
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = True
|
||||
mock_tracker.get_summary.return_value = "✅ All projects synced successfully"
|
||||
mock_tracker.get_all_projects.return_value = {}
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Basic Memory Sync Status" in result
|
||||
assert "System Ready**: ✅ Yes" in result
|
||||
assert "All sync operations completed" in result
|
||||
assert "File indexing is complete" in result
|
||||
assert "knowledge base is ready for use" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_in_progress():
|
||||
"""Test sync_status when sync is in progress."""
|
||||
# Mock sync status tracker with in progress status
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = False
|
||||
mock_tracker.get_summary.return_value = "🔄 Syncing 2 projects (5/10 files, 50%)"
|
||||
|
||||
# Mock active projects
|
||||
project1 = ProjectSyncStatus(
|
||||
project_name="project1",
|
||||
status=SyncStatus.SYNCING,
|
||||
message="Processing new files",
|
||||
files_total=5,
|
||||
files_processed=3,
|
||||
)
|
||||
project2 = ProjectSyncStatus(
|
||||
project_name="project2",
|
||||
status=SyncStatus.SCANNING,
|
||||
message="Scanning files",
|
||||
files_total=5,
|
||||
files_processed=2,
|
||||
)
|
||||
|
||||
mock_tracker.get_all_projects.return_value = {"project1": project1, "project2": project2}
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Basic Memory Sync Status" in result
|
||||
assert "System Ready**: 🔄 Processing" in result
|
||||
assert "File synchronization in progress" in result
|
||||
assert "project1**: Processing new files (3/5, 60%)" in result
|
||||
assert "project2**: Scanning files (2/5, 40%)" in result
|
||||
assert "Scanning and indexing markdown files" in result
|
||||
assert "Use this tool again to check progress" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_failed():
|
||||
"""Test sync_status when sync has failed."""
|
||||
# Mock sync status tracker with failed project
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = False
|
||||
mock_tracker.get_summary.return_value = "❌ Sync failed for: project1"
|
||||
|
||||
# Mock failed project
|
||||
failed_project = ProjectSyncStatus(
|
||||
project_name="project1",
|
||||
status=SyncStatus.FAILED,
|
||||
message="Sync failed",
|
||||
error="Permission denied",
|
||||
)
|
||||
|
||||
mock_tracker.get_all_projects.return_value = {"project1": failed_project}
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Basic Memory Sync Status" in result
|
||||
assert "System Ready**: 🔄 Processing" in result
|
||||
assert "Some projects failed to sync" in result
|
||||
assert "project1**: Permission denied" in result
|
||||
assert "Check the logs for detailed error information" in result
|
||||
assert "Try restarting the MCP server" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_idle():
|
||||
"""Test sync_status when system is idle."""
|
||||
# Mock sync status tracker with idle status
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = True
|
||||
mock_tracker.get_summary.return_value = "✅ System ready"
|
||||
mock_tracker.get_all_projects.return_value = {}
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Basic Memory Sync Status" in result
|
||||
assert "System Ready**: ✅ Yes" in result
|
||||
assert "All sync operations completed" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_with_project():
|
||||
"""Test sync_status with specific project context."""
|
||||
# Mock sync status tracker
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = True
|
||||
mock_tracker.get_summary.return_value = "✅ All projects synced successfully"
|
||||
|
||||
# Mock specific project status
|
||||
project_status = ProjectSyncStatus(
|
||||
project_name="test-project",
|
||||
status=SyncStatus.COMPLETED,
|
||||
message="Sync completed",
|
||||
files_total=10,
|
||||
files_processed=10,
|
||||
)
|
||||
mock_tracker.get_project_status.return_value = project_status
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn(project="test-project")
|
||||
|
||||
# The function should use the original logic for project-specific queries
|
||||
# But since we changed the implementation, let's just verify it doesn't crash
|
||||
assert "Basic Memory Sync Status" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_pending():
|
||||
"""Test sync_status when no projects are active."""
|
||||
# Mock sync status tracker with no active projects
|
||||
mock_tracker = MagicMock(spec=SyncStatusTracker)
|
||||
mock_tracker.is_ready = False
|
||||
mock_tracker.get_summary.return_value = "✅ System ready"
|
||||
mock_tracker.get_all_projects.return_value = {}
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Basic Memory Sync Status" in result
|
||||
assert "Sync operations pending" in result
|
||||
assert "usually resolves automatically" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_error_handling():
|
||||
"""Test sync_status handles errors gracefully."""
|
||||
# Mock sync status tracker that raises an exception
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker") as mock_tracker:
|
||||
mock_tracker.is_ready = True
|
||||
mock_tracker.get_summary.side_effect = Exception("Test error")
|
||||
|
||||
result = await sync_status.fn()
|
||||
|
||||
assert "Unable to check sync status**: Test error" in result
|
||||
@@ -1,12 +1,20 @@
|
||||
"""Tests for MCP tool utilities."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, HTTPStatusError
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_delete
|
||||
from basic_memory.mcp.tools.utils import (
|
||||
call_get,
|
||||
call_post,
|
||||
call_put,
|
||||
call_delete,
|
||||
get_error_message,
|
||||
check_migration_status,
|
||||
wait_for_migration_or_return_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -135,7 +143,6 @@ async def test_call_get_with_params(mock_response):
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_error_message():
|
||||
"""Test the get_error_message function."""
|
||||
from basic_memory.mcp.tools.utils import get_error_message
|
||||
|
||||
# Test 400 status code
|
||||
message = get_error_message(400, "http://test.com/resource", "GET")
|
||||
@@ -177,3 +184,82 @@ async def test_call_post_with_json(mock_response):
|
||||
mock_post.assert_called_once()
|
||||
call_kwargs = mock_post.call_args[1]
|
||||
assert call_kwargs["json"] == json_data
|
||||
|
||||
|
||||
class TestMigrationStatus:
|
||||
"""Test migration status checking functions."""
|
||||
|
||||
def test_check_migration_status_ready(self):
|
||||
"""Test check_migration_status when system is ready."""
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.is_ready = True
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = check_migration_status()
|
||||
assert result is None
|
||||
|
||||
def test_check_migration_status_not_ready(self):
|
||||
"""Test check_migration_status when sync is in progress."""
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.is_ready = False
|
||||
mock_tracker.get_summary.return_value = "Sync in progress..."
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = check_migration_status()
|
||||
assert result == "Sync in progress..."
|
||||
mock_tracker.get_summary.assert_called_once()
|
||||
|
||||
def test_check_migration_status_exception(self):
|
||||
"""Test check_migration_status with import/other exception."""
|
||||
# Mock the import itself to raise an exception
|
||||
with patch("builtins.__import__", side_effect=ImportError("Module not found")):
|
||||
result = check_migration_status()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_migration_ready(self):
|
||||
"""Test wait_for_migration when system is already ready."""
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.is_ready = True
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
result = await wait_for_migration_or_return_status()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_migration_becomes_ready(self):
|
||||
"""Test wait_for_migration when system becomes ready during wait."""
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.is_ready = False
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
# Mock asyncio.sleep to make tracker ready after first check
|
||||
async def mock_sleep(delay):
|
||||
mock_tracker.is_ready = True
|
||||
|
||||
with patch("asyncio.sleep", side_effect=mock_sleep):
|
||||
result = await wait_for_migration_or_return_status(timeout=1.0)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_migration_timeout(self):
|
||||
"""Test wait_for_migration when timeout occurs."""
|
||||
mock_tracker = MagicMock()
|
||||
mock_tracker.is_ready = False
|
||||
mock_tracker.get_summary.return_value = "Still syncing..."
|
||||
|
||||
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await wait_for_migration_or_return_status(timeout=0.1)
|
||||
assert result == "Still syncing..."
|
||||
mock_tracker.get_summary.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_migration_exception(self):
|
||||
"""Test wait_for_migration with exception during checking."""
|
||||
with patch(
|
||||
"basic_memory.services.sync_status_service.sync_status_tracker",
|
||||
side_effect=Exception("Test error"),
|
||||
):
|
||||
result = await wait_for_migration_or_return_status()
|
||||
assert result is None
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for view_note tool that exercise the full stack with SQLite."""
|
||||
|
||||
from textwrap import dedent
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from basic_memory.mcp.tools import write_note, view_note
|
||||
from basic_memory.schemas.search import SearchResponse, SearchItemType
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_call_get():
|
||||
"""Mock for call_get to simulate different responses."""
|
||||
with patch("basic_memory.mcp.tools.read_note.call_get") as mock:
|
||||
# Default to 404 - not found
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock.return_value = mock_response
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_search():
|
||||
"""Mock for search tool."""
|
||||
with patch("basic_memory.mcp.tools.read_note.search_notes.fn") as mock:
|
||||
# Default to empty results
|
||||
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_basic_functionality(app):
|
||||
"""Test viewing a note creates an artifact."""
|
||||
# First create a note
|
||||
await write_note.fn(
|
||||
title="Test View Note",
|
||||
folder="test",
|
||||
content="# Test View Note\n\nThis is test content for viewing.",
|
||||
)
|
||||
|
||||
# View the note
|
||||
result = await view_note.fn("Test View Note")
|
||||
|
||||
# Should contain artifact XML
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert 'type="text/markdown"' in result
|
||||
assert 'title="Test View Note"' in result
|
||||
assert "</artifact>" in result
|
||||
|
||||
# Should contain the note content within the artifact
|
||||
assert "# Test View Note" in result
|
||||
assert "This is test content for viewing." in result
|
||||
|
||||
# Should have confirmation message
|
||||
assert "✅ Note displayed as artifact" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_with_frontmatter_title(app):
|
||||
"""Test viewing a note extracts title from frontmatter."""
|
||||
# Create note with frontmatter
|
||||
content = dedent("""
|
||||
---
|
||||
title: "Frontmatter Title"
|
||||
tags: [test]
|
||||
---
|
||||
|
||||
# Frontmatter Title
|
||||
|
||||
Content with frontmatter title.
|
||||
""").strip()
|
||||
|
||||
await write_note.fn(title="Frontmatter Title", folder="test", content=content)
|
||||
|
||||
# View the note
|
||||
result = await view_note.fn("Frontmatter Title")
|
||||
|
||||
# Should extract title from frontmatter
|
||||
assert 'title="Frontmatter Title"' in result
|
||||
assert "✅ Note displayed as artifact: **Frontmatter Title**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_with_heading_title(app):
|
||||
"""Test viewing a note extracts title from first heading when no frontmatter."""
|
||||
# Create note with heading but no frontmatter title
|
||||
content = "# Heading Title\n\nContent with heading title."
|
||||
|
||||
await write_note.fn(title="Heading Title", folder="test", content=content)
|
||||
|
||||
# View the note
|
||||
result = await view_note.fn("Heading Title")
|
||||
|
||||
# Should extract title from heading
|
||||
assert 'title="Heading Title"' in result
|
||||
assert "✅ Note displayed as artifact: **Heading Title**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_unicode_content(app):
|
||||
"""Test viewing a note with Unicode content."""
|
||||
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
|
||||
await write_note.fn(title="Unicode Test 🚀", folder="test", content=content)
|
||||
|
||||
# View the note
|
||||
result = await view_note.fn("Unicode Test 🚀")
|
||||
|
||||
# Should handle Unicode properly
|
||||
assert "🚀" in result
|
||||
assert "🎉" in result
|
||||
assert "♠♣♥♦" in result
|
||||
assert '<artifact identifier="note-' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_by_permalink(app):
|
||||
"""Test viewing a note by its permalink."""
|
||||
await write_note.fn(
|
||||
title="Permalink Test", folder="test", content="Content for permalink test."
|
||||
)
|
||||
|
||||
# View by permalink
|
||||
result = await view_note.fn("test/permalink-test")
|
||||
|
||||
# Should work with permalink
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert "Content for permalink test." in result
|
||||
assert "✅ Note displayed as artifact" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_with_memory_url(app):
|
||||
"""Test viewing a note using a memory:// URL."""
|
||||
await write_note.fn(
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
content="Testing memory:// URL handling in view_note",
|
||||
)
|
||||
|
||||
# View with memory:// URL
|
||||
result = await view_note.fn("memory://test/memory-url-test")
|
||||
|
||||
# Should work with memory:// URL
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert "Testing memory:// URL handling in view_note" in result
|
||||
assert "✅ Note displayed as artifact" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_not_found(app):
|
||||
"""Test viewing a non-existent note returns error without artifact."""
|
||||
# Try to view non-existent note
|
||||
result = await view_note.fn("NonExistent Note")
|
||||
|
||||
# Should return error message without artifact
|
||||
assert "# Note Not Found:" in result
|
||||
assert "NonExistent Note" in result
|
||||
assert "<artifact" not in result # No artifact for errors
|
||||
assert "Check Identifier Type" in result
|
||||
assert "Search Instead" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_pagination(app):
|
||||
"""Test viewing a note with pagination parameters."""
|
||||
await write_note.fn(
|
||||
title="Pagination Test", folder="test", content="Content for pagination test."
|
||||
)
|
||||
|
||||
# View with pagination
|
||||
result = await view_note.fn("Pagination Test", page=1, page_size=5)
|
||||
|
||||
# Should work with pagination
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert "Content for pagination test." in result
|
||||
assert "✅ Note displayed as artifact" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_project_parameter(app):
|
||||
"""Test viewing a note with project parameter."""
|
||||
await write_note.fn(title="Project Test", folder="test", content="Content for project test.")
|
||||
|
||||
# View with explicit project (None uses current)
|
||||
result = await view_note.fn("Project Test", project=None)
|
||||
|
||||
# Should work with project parameter
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert "Content for project test." in result
|
||||
assert "✅ Note displayed as artifact" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_artifact_identifier_unique(app):
|
||||
"""Test that different notes get different artifact identifiers."""
|
||||
# Create two notes
|
||||
await write_note.fn(title="Note One", folder="test", content="Content one")
|
||||
await write_note.fn(title="Note Two", folder="test", content="Content two")
|
||||
|
||||
# View both notes
|
||||
result1 = await view_note.fn("Note One")
|
||||
result2 = await view_note.fn("Note Two")
|
||||
|
||||
# Should have different artifact identifiers
|
||||
import re
|
||||
|
||||
id1_match = re.search(r'identifier="(note-\d+)"', result1)
|
||||
id2_match = re.search(r'identifier="(note-\d+)"', result2)
|
||||
|
||||
assert id1_match is not None
|
||||
assert id2_match is not None
|
||||
assert id1_match.group(1) != id2_match.group(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_fallback_identifier_as_title(app):
|
||||
"""Test that view_note uses identifier as title when no title is extractable."""
|
||||
# Create a note with no clear title structure
|
||||
await write_note.fn(
|
||||
title="Simple Note",
|
||||
folder="test",
|
||||
content="Just plain content with no headings or frontmatter title",
|
||||
)
|
||||
|
||||
# View the note
|
||||
result = await view_note.fn("Simple Note")
|
||||
|
||||
# Should use identifier as fallback title
|
||||
assert 'title="Simple Note"' in result
|
||||
assert "✅ Note displayed as artifact: **Simple Note**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_direct_success(mock_call_get):
|
||||
"""Test view_note with successful direct permalink lookup."""
|
||||
# Setup mock for successful response with frontmatter
|
||||
note_content = dedent("""
|
||||
---
|
||||
title: "Test Note"
|
||||
---
|
||||
# Test Note
|
||||
|
||||
This is a test note.
|
||||
""").strip()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = note_content
|
||||
mock_call_get.return_value = mock_response
|
||||
|
||||
# Call the function
|
||||
result = await view_note.fn("test/test-note")
|
||||
|
||||
# Verify direct lookup was used
|
||||
mock_call_get.assert_called_once()
|
||||
assert "test/test-note" in mock_call_get.call_args[0][1]
|
||||
|
||||
# Verify result contains artifact
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert 'title="Test Note"' in result
|
||||
assert "This is a test note." in result
|
||||
assert "✅ Note displayed as artifact: **Test Note**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_note_title_search_fallback(mock_call_get, mock_search):
|
||||
"""Test view_note falls back to title search when direct lookup fails."""
|
||||
# Setup mock for failed direct lookup
|
||||
mock_call_get.side_effect = [
|
||||
# First call fails (direct lookup)
|
||||
MagicMock(status_code=404),
|
||||
# Second call succeeds (after title search)
|
||||
MagicMock(status_code=200, text="# Test Note\n\nThis is a test note."),
|
||||
]
|
||||
|
||||
# Setup mock for successful title search
|
||||
mock_search.return_value = SearchResponse(
|
||||
results=[
|
||||
{
|
||||
"id": 1,
|
||||
"entity": "test/test-note",
|
||||
"title": "Test Note",
|
||||
"type": SearchItemType.ENTITY,
|
||||
"permalink": "test/test-note",
|
||||
"file_path": "test/test-note.md",
|
||||
"score": 1.0,
|
||||
}
|
||||
],
|
||||
current_page=1,
|
||||
page_size=1,
|
||||
)
|
||||
|
||||
# Call the function
|
||||
result = await view_note.fn("Test Note")
|
||||
|
||||
# Verify title search was used
|
||||
mock_search.assert_called_once()
|
||||
|
||||
# Verify result contains artifact with extracted title
|
||||
assert '<artifact identifier="note-' in result
|
||||
assert 'title="Test Note"' in result
|
||||
assert "This is a test note." in result
|
||||
assert "✅ Note displayed as artifact: **Test Note**" in result
|
||||
@@ -16,7 +16,7 @@ async def test_write_note(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
@@ -31,7 +31,7 @@ async def test_write_note(app):
|
||||
assert "- test, documentation" in result
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await read_note("test/test-note")
|
||||
content = await read_note.fn("test/test-note")
|
||||
assert (
|
||||
dedent("""
|
||||
---
|
||||
@@ -53,14 +53,14 @@ async def test_write_note(app):
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_no_tags(app):
|
||||
"""Test creating a note without tags."""
|
||||
result = await write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
result = await write_note.fn(title="Simple Note", folder="test", content="Just some text")
|
||||
|
||||
assert result
|
||||
assert "# Created note" in result
|
||||
assert "file_path: test/Simple Note.md" in result
|
||||
assert "permalink: test/simple-note" in result
|
||||
# Should be able to read it back
|
||||
content = await read_note("test/simple-note")
|
||||
content = await read_note.fn("test/simple-note")
|
||||
assert (
|
||||
dedent("""
|
||||
--
|
||||
@@ -85,7 +85,7 @@ async def test_write_note_update_existing(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
@@ -99,7 +99,7 @@ async def test_write_note_update_existing(app):
|
||||
assert "## Tags" in result
|
||||
assert "- test, documentation" in result
|
||||
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is an updated note",
|
||||
@@ -112,7 +112,7 @@ async def test_write_note_update_existing(app):
|
||||
assert "- test, documentation" in result
|
||||
|
||||
# Try reading it back
|
||||
content = await read_note("test/test-note")
|
||||
content = await read_note.fn("test/test-note")
|
||||
assert (
|
||||
dedent(
|
||||
"""
|
||||
@@ -150,7 +150,7 @@ async def test_issue_93_write_note_respects_custom_permalink_new_note(app):
|
||||
- [note] Testing if custom permalink is respected
|
||||
""").strip()
|
||||
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="My New Note",
|
||||
folder="notes",
|
||||
content=content_with_custom_permalink,
|
||||
@@ -167,7 +167,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
|
||||
"""Test that write_note respects custom permalinks when updating existing notes (Issue #93)"""
|
||||
|
||||
# Step 1: Create initial note (auto-generated permalink)
|
||||
result1 = await write_note(
|
||||
result1 = await write_note.fn(
|
||||
title="Existing Note",
|
||||
folder="test",
|
||||
content="Initial content without custom permalink",
|
||||
@@ -197,7 +197,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app):
|
||||
- [note] Custom permalink should be respected on update
|
||||
""").strip()
|
||||
|
||||
result2 = await write_note(
|
||||
result2 = await write_note.fn(
|
||||
title="Existing Note",
|
||||
folder="test",
|
||||
content=updated_content,
|
||||
@@ -218,7 +218,7 @@ async def test_delete_note_existing(app):
|
||||
- Return valid permalink
|
||||
- Delete the note
|
||||
"""
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
@@ -227,7 +227,7 @@ async def test_delete_note_existing(app):
|
||||
|
||||
assert result
|
||||
|
||||
deleted = await delete_note("test/test-note")
|
||||
deleted = await delete_note.fn("test/test-note")
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ async def test_delete_note_doesnt_exist(app):
|
||||
- Delete the note
|
||||
- verify returns false
|
||||
"""
|
||||
deleted = await delete_note("doesnt-exist")
|
||||
deleted = await delete_note.fn("doesnt-exist")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ async def test_write_note_with_tag_array_from_bug_report(app):
|
||||
}
|
||||
|
||||
# Try to call the function with this data directly
|
||||
result = await write_note(**bug_payload)
|
||||
result = await write_note.fn(**bug_payload)
|
||||
|
||||
assert result
|
||||
assert "permalink: folder/title" in result
|
||||
@@ -277,7 +277,7 @@ async def test_write_note_verbose(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
@@ -313,7 +313,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
|
||||
- Verify custom frontmatter is preserved
|
||||
"""
|
||||
# First, create a note with custom metadata using write_note
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Custom Metadata Note",
|
||||
folder="test",
|
||||
content="# Initial content",
|
||||
@@ -321,7 +321,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
|
||||
)
|
||||
|
||||
# Read the note to get its permalink
|
||||
content = await read_note("test/custom-metadata-note")
|
||||
content = await read_note.fn("test/custom-metadata-note")
|
||||
|
||||
# Now directly update the file with custom frontmatter
|
||||
# We need to use a direct file update to add custom frontmatter
|
||||
@@ -340,7 +340,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
|
||||
f.write(frontmatter.dumps(post))
|
||||
|
||||
# Now update the note using write_note
|
||||
result = await write_note(
|
||||
result = await write_note.fn(
|
||||
title="Custom Metadata Note",
|
||||
folder="test",
|
||||
content="# Updated content",
|
||||
@@ -351,7 +351,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
|
||||
assert ("Updated note\nfile_path: test/Custom Metadata Note.md") in result
|
||||
|
||||
# Read the note back and check if custom frontmatter is preserved
|
||||
content = await read_note("test/custom-metadata-note")
|
||||
content = await read_note.fn("test/custom-metadata-note")
|
||||
|
||||
# Custom frontmatter should be preserved
|
||||
assert "Status: In Progress" in content
|
||||
@@ -371,7 +371,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config):
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_preserves_content_frontmatter(app):
|
||||
"""Test creating a new note."""
|
||||
await write_note(
|
||||
await write_note.fn(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content=dedent(
|
||||
@@ -391,7 +391,7 @@ async def test_write_note_preserves_content_frontmatter(app):
|
||||
)
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await read_note("test/test-note")
|
||||
content = await read_note.fn("test/test-note")
|
||||
assert (
|
||||
dedent(
|
||||
"""
|
||||
|
||||
@@ -301,3 +301,219 @@ def test_directory_property():
|
||||
project_id=1,
|
||||
)
|
||||
assert row3.directory == ""
|
||||
|
||||
|
||||
class TestSearchTermPreparation:
|
||||
"""Test cases for FTS5 search term preparation."""
|
||||
|
||||
def test_simple_terms_get_prefix_wildcard(self, search_repository):
|
||||
"""Simple alphanumeric terms should get prefix matching."""
|
||||
assert search_repository._prepare_search_term("hello") == "hello*"
|
||||
assert search_repository._prepare_search_term("project") == "project*"
|
||||
assert search_repository._prepare_search_term("test123") == "test123*"
|
||||
|
||||
def test_terms_with_existing_wildcard_unchanged(self, search_repository):
|
||||
"""Terms that already contain * should remain unchanged."""
|
||||
assert search_repository._prepare_search_term("hello*") == "hello*"
|
||||
assert search_repository._prepare_search_term("test*world") == "test*world"
|
||||
|
||||
def test_boolean_operators_preserved(self, search_repository):
|
||||
"""Boolean operators should be preserved without modification."""
|
||||
assert search_repository._prepare_search_term("hello AND world") == "hello AND world"
|
||||
assert search_repository._prepare_search_term("cat OR dog") == "cat OR dog"
|
||||
assert (
|
||||
search_repository._prepare_search_term("project NOT meeting") == "project NOT meeting"
|
||||
)
|
||||
assert (
|
||||
search_repository._prepare_search_term("(hello AND world) OR test")
|
||||
== "(hello AND world) OR test"
|
||||
)
|
||||
|
||||
def test_programming_terms_should_work(self, search_repository):
|
||||
"""Programming-related terms with special chars should be searchable."""
|
||||
# These should be quoted to handle special characters safely
|
||||
assert search_repository._prepare_search_term("C++") == '"C++"*'
|
||||
assert search_repository._prepare_search_term("function()") == '"function()"*'
|
||||
assert search_repository._prepare_search_term("email@domain.com") == '"email@domain.com"*'
|
||||
assert search_repository._prepare_search_term("array[index]") == '"array[index]"*'
|
||||
assert search_repository._prepare_search_term("config.json") == '"config.json"*'
|
||||
|
||||
def test_malformed_fts5_syntax_quoted(self, search_repository):
|
||||
"""Malformed FTS5 syntax should be quoted to prevent errors."""
|
||||
# Multiple operators without proper syntax
|
||||
assert search_repository._prepare_search_term("+++invalid+++") == '"+++invalid+++"*'
|
||||
assert search_repository._prepare_search_term("!!!error!!!") == '"!!!error!!!"*'
|
||||
assert search_repository._prepare_search_term("@#$%^&*()") == '"@#$%^&*()"*'
|
||||
|
||||
def test_quoted_strings_handled_properly(self, search_repository):
|
||||
"""Strings with quotes should have quotes escaped."""
|
||||
assert search_repository._prepare_search_term('say "hello"') == '"say ""hello"""*'
|
||||
assert search_repository._prepare_search_term("it's working") == '"it\'s working"*'
|
||||
|
||||
def test_file_paths_no_prefix_wildcard(self, search_repository):
|
||||
"""File paths should not get prefix wildcards."""
|
||||
assert (
|
||||
search_repository._prepare_search_term("config.json", is_prefix=False)
|
||||
== '"config.json"'
|
||||
)
|
||||
assert (
|
||||
search_repository._prepare_search_term("docs/readme.md", is_prefix=False)
|
||||
== '"docs/readme.md"'
|
||||
)
|
||||
|
||||
def test_spaces_handled_correctly(self, search_repository):
|
||||
"""Terms with spaces should use boolean AND for word order independence."""
|
||||
assert search_repository._prepare_search_term("hello world") == "hello* AND world*"
|
||||
assert (
|
||||
search_repository._prepare_search_term("project planning") == "project* AND planning*"
|
||||
)
|
||||
|
||||
def test_version_strings_with_dots_handled_correctly(self, search_repository):
|
||||
"""Version strings with dots should be quoted to prevent FTS5 syntax errors."""
|
||||
# This reproduces the bug where "Basic Memory v0.13.0b2" becomes "Basic* AND Memory* AND v0.13.0b2*"
|
||||
# which causes FTS5 syntax errors because v0.13.0b2* is not valid FTS5 syntax
|
||||
result = search_repository._prepare_search_term("Basic Memory v0.13.0b2")
|
||||
# Should be quoted because of dots in v0.13.0b2
|
||||
assert result == '"Basic Memory v0.13.0b2"*'
|
||||
|
||||
def test_mixed_special_characters_in_multi_word_queries(self, search_repository):
|
||||
"""Multi-word queries with special characters in any word should be fully quoted."""
|
||||
# Any word containing special characters should cause the entire phrase to be quoted
|
||||
assert search_repository._prepare_search_term("config.json file") == '"config.json file"*'
|
||||
assert (
|
||||
search_repository._prepare_search_term("user@email.com account")
|
||||
== '"user@email.com account"*'
|
||||
)
|
||||
assert search_repository._prepare_search_term("node.js and react") == '"node.js and react"*'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_special_characters_returns_results(self, search_repository):
|
||||
"""Integration test: search with special characters should work gracefully."""
|
||||
# This test ensures the search doesn't crash with FTS5 syntax errors
|
||||
|
||||
# These should all return empty results gracefully, not crash
|
||||
results1 = await search_repository.search(search_text="C++")
|
||||
assert isinstance(results1, list) # Should not crash
|
||||
|
||||
results2 = await search_repository.search(search_text="function()")
|
||||
assert isinstance(results2, list) # Should not crash
|
||||
|
||||
results3 = await search_repository.search(search_text="+++malformed+++")
|
||||
assert isinstance(results3, list) # Should not crash, return empty results
|
||||
|
||||
results4 = await search_repository.search(search_text="email@domain.com")
|
||||
assert isinstance(results4, list) # Should not crash
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boolean_search_still_works(self, search_repository):
|
||||
"""Boolean search operations should continue to work."""
|
||||
# These should not crash and should respect boolean logic
|
||||
results1 = await search_repository.search(search_text="hello AND world")
|
||||
assert isinstance(results1, list)
|
||||
|
||||
results2 = await search_repository.search(search_text="cat OR dog")
|
||||
assert isinstance(results2, list)
|
||||
|
||||
results3 = await search_repository.search(search_text="project NOT meeting")
|
||||
assert isinstance(results3, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalink_match_exact_with_slash(self, search_repository):
|
||||
"""Test exact permalink matching with slash (line 249 coverage)."""
|
||||
# This tests the exact match path: if "/" in permalink_text:
|
||||
results = await search_repository.search(permalink_match="test/path")
|
||||
assert isinstance(results, list)
|
||||
# Should use exact equality matching for paths with slashes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalink_match_simple_term(self, search_repository):
|
||||
"""Test permalink matching with simple term (no slash)."""
|
||||
# This tests the simple term path that goes through _prepare_search_term
|
||||
results = await search_repository.search(permalink_match="simpleterm")
|
||||
assert isinstance(results, list)
|
||||
# Should use FTS5 MATCH for simple terms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fts5_error_handling_database_error(self, search_repository):
|
||||
"""Test that non-FTS5 database errors are properly re-raised."""
|
||||
import unittest.mock
|
||||
|
||||
# Mock the scoped_session to raise a non-FTS5 error
|
||||
with unittest.mock.patch("basic_memory.db.scoped_session") as mock_scoped_session:
|
||||
mock_session = unittest.mock.AsyncMock()
|
||||
mock_scoped_session.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
# Simulate a database error that's NOT an FTS5 syntax error
|
||||
mock_session.execute.side_effect = Exception("Database connection failed")
|
||||
|
||||
# This should re-raise the exception (not return empty list)
|
||||
with pytest.raises(Exception, match="Database connection failed"):
|
||||
await search_repository.search(search_text="test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_string_search_integration(self, search_repository, search_entity):
|
||||
"""Integration test: searching for version strings should work without FTS5 errors."""
|
||||
# Index an entity with version information
|
||||
search_row = SearchIndexRow(
|
||||
id=search_entity.id,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
title="Basic Memory v0.13.0b2 Release",
|
||||
content_stems="basic memory version 0.13.0b2 beta release notes features",
|
||||
content_snippet="Basic Memory v0.13.0b2 is a beta release with new features",
|
||||
permalink=search_entity.permalink,
|
||||
file_path=search_entity.file_path,
|
||||
entity_id=search_entity.id,
|
||||
metadata={"entity_type": search_entity.entity_type},
|
||||
created_at=search_entity.created_at,
|
||||
updated_at=search_entity.updated_at,
|
||||
project_id=search_repository.project_id,
|
||||
)
|
||||
|
||||
await search_repository.index_item(search_row)
|
||||
|
||||
# This should not cause FTS5 syntax errors and should find the entity
|
||||
results = await search_repository.search(search_text="Basic Memory v0.13.0b2")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Basic Memory v0.13.0b2 Release"
|
||||
|
||||
# Test other version-like patterns
|
||||
results2 = await search_repository.search(search_text="v0.13.0b2")
|
||||
assert len(results2) == 1 # Should still find it due to content_stems
|
||||
|
||||
# Test with other problematic patterns
|
||||
results3 = await search_repository.search(search_text="node.js version")
|
||||
assert isinstance(results3, list) # Should not crash
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_only_search(self, search_repository, search_entity):
|
||||
"""Test that wildcard-only search '*' doesn't cause FTS5 errors (line 243 coverage)."""
|
||||
# Index an entity for testing
|
||||
search_row = SearchIndexRow(
|
||||
id=search_entity.id,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
title="Test Entity",
|
||||
content_stems="test entity content",
|
||||
content_snippet="This is a test entity",
|
||||
permalink=search_entity.permalink,
|
||||
file_path=search_entity.file_path,
|
||||
entity_id=search_entity.id,
|
||||
metadata={"entity_type": search_entity.entity_type},
|
||||
created_at=search_entity.created_at,
|
||||
updated_at=search_entity.updated_at,
|
||||
project_id=search_repository.project_id,
|
||||
)
|
||||
|
||||
await search_repository.index_item(search_row)
|
||||
|
||||
# Test wildcard-only search - should not crash and should return results
|
||||
results = await search_repository.search(search_text="*")
|
||||
assert isinstance(results, list) # Should not crash
|
||||
assert len(results) >= 1 # Should return all results, including our test entity
|
||||
|
||||
# Test empty string search - should also not crash
|
||||
results_empty = await search_repository.search(search_text="")
|
||||
assert isinstance(results_empty, list) # Should not crash
|
||||
|
||||
# Test whitespace-only search
|
||||
results_whitespace = await search_repository.search(search_text=" ")
|
||||
assert isinstance(results_whitespace, list) # Should not crash
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for memory URL validation functionality."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from basic_memory.schemas.memory import (
|
||||
normalize_memory_url,
|
||||
validate_memory_url_path,
|
||||
memory_url,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateMemoryUrlPath:
|
||||
"""Test the validate_memory_url_path function."""
|
||||
|
||||
def test_valid_paths(self):
|
||||
"""Test that valid paths pass validation."""
|
||||
valid_paths = [
|
||||
"notes/meeting",
|
||||
"projects/basic-memory",
|
||||
"research/findings-2025",
|
||||
"specs/search",
|
||||
"docs/api-spec",
|
||||
"folder/subfolder/note",
|
||||
"single-note",
|
||||
"notes/with-hyphens",
|
||||
"notes/with_underscores",
|
||||
"notes/with123numbers",
|
||||
"pattern/*", # Wildcard pattern matching
|
||||
"deep/*/pattern",
|
||||
]
|
||||
|
||||
for path in valid_paths:
|
||||
assert validate_memory_url_path(path), f"Path '{path}' should be valid"
|
||||
|
||||
def test_invalid_empty_paths(self):
|
||||
"""Test that empty/whitespace paths fail validation."""
|
||||
invalid_paths = [
|
||||
"",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
" \n ",
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
assert not validate_memory_url_path(path), f"Path '{path}' should be invalid"
|
||||
|
||||
def test_invalid_double_slashes(self):
|
||||
"""Test that paths with double slashes fail validation."""
|
||||
invalid_paths = [
|
||||
"notes//meeting",
|
||||
"//root",
|
||||
"folder//subfolder/note",
|
||||
"path//with//multiple//doubles",
|
||||
"memory//test",
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
assert not validate_memory_url_path(path), (
|
||||
f"Path '{path}' should be invalid (double slashes)"
|
||||
)
|
||||
|
||||
def test_invalid_protocol_schemes(self):
|
||||
"""Test that paths with protocol schemes fail validation."""
|
||||
invalid_paths = [
|
||||
"http://example.com",
|
||||
"https://example.com/path",
|
||||
"file://local/path",
|
||||
"ftp://server.com",
|
||||
"invalid://test",
|
||||
"custom://scheme",
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
assert not validate_memory_url_path(path), (
|
||||
f"Path '{path}' should be invalid (protocol scheme)"
|
||||
)
|
||||
|
||||
def test_invalid_characters(self):
|
||||
"""Test that paths with invalid characters fail validation."""
|
||||
invalid_paths = [
|
||||
"notes<with>brackets",
|
||||
'notes"with"quotes',
|
||||
"notes|with|pipes",
|
||||
"notes?with?questions",
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
assert not validate_memory_url_path(path), (
|
||||
f"Path '{path}' should be invalid (invalid chars)"
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeMemoryUrl:
|
||||
"""Test the normalize_memory_url function."""
|
||||
|
||||
def test_valid_normalization(self):
|
||||
"""Test that valid URLs are properly normalized."""
|
||||
test_cases = [
|
||||
("specs/search", "memory://specs/search"),
|
||||
("memory://specs/search", "memory://specs/search"),
|
||||
("notes/meeting-2025", "memory://notes/meeting-2025"),
|
||||
("memory://notes/meeting-2025", "memory://notes/meeting-2025"),
|
||||
("pattern/*", "memory://pattern/*"),
|
||||
("memory://pattern/*", "memory://pattern/*"),
|
||||
]
|
||||
|
||||
for input_url, expected in test_cases:
|
||||
result = normalize_memory_url(input_url)
|
||||
assert result == expected, (
|
||||
f"normalize_memory_url('{input_url}') should return '{expected}', got '{result}'"
|
||||
)
|
||||
|
||||
def test_empty_url(self):
|
||||
"""Test that empty URLs return empty string."""
|
||||
assert normalize_memory_url(None) == ""
|
||||
assert normalize_memory_url("") == ""
|
||||
|
||||
def test_invalid_double_slashes(self):
|
||||
"""Test that URLs with double slashes raise ValueError."""
|
||||
invalid_urls = [
|
||||
"memory//test",
|
||||
"notes//meeting",
|
||||
"//root",
|
||||
"memory://path//with//doubles",
|
||||
]
|
||||
|
||||
for url in invalid_urls:
|
||||
with pytest.raises(ValueError, match="contains double slashes"):
|
||||
normalize_memory_url(url)
|
||||
|
||||
def test_invalid_protocol_schemes(self):
|
||||
"""Test that URLs with other protocol schemes raise ValueError."""
|
||||
invalid_urls = [
|
||||
"http://example.com",
|
||||
"https://example.com/path",
|
||||
"file://local/path",
|
||||
"invalid://test",
|
||||
]
|
||||
|
||||
for url in invalid_urls:
|
||||
with pytest.raises(ValueError, match="contains protocol scheme"):
|
||||
normalize_memory_url(url)
|
||||
|
||||
def test_whitespace_only(self):
|
||||
"""Test that whitespace-only URLs raise ValueError."""
|
||||
invalid_urls = [
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
" \n ",
|
||||
]
|
||||
|
||||
for url in invalid_urls:
|
||||
with pytest.raises(ValueError, match="cannot be empty or whitespace"):
|
||||
normalize_memory_url(url)
|
||||
|
||||
def test_invalid_characters(self):
|
||||
"""Test that URLs with invalid characters raise ValueError."""
|
||||
invalid_urls = [
|
||||
"notes<brackets>",
|
||||
'notes"quotes"',
|
||||
"notes|pipes|",
|
||||
"notes?questions?",
|
||||
]
|
||||
|
||||
for url in invalid_urls:
|
||||
with pytest.raises(ValueError, match="contains invalid characters"):
|
||||
normalize_memory_url(url)
|
||||
|
||||
|
||||
class TestMemoryUrlPydanticValidation:
|
||||
"""Test the MemoryUrl Pydantic type validation."""
|
||||
|
||||
def test_valid_urls_pass_validation(self):
|
||||
"""Test that valid URLs pass Pydantic validation."""
|
||||
valid_urls = [
|
||||
"specs/search",
|
||||
"memory://specs/search",
|
||||
"notes/meeting-2025",
|
||||
"projects/basic-memory/docs",
|
||||
"pattern/*",
|
||||
]
|
||||
|
||||
for url in valid_urls:
|
||||
# Should not raise an exception
|
||||
result = memory_url.validate_python(url)
|
||||
assert result.startswith("memory://"), (
|
||||
f"Validated URL should start with memory://, got {result}"
|
||||
)
|
||||
|
||||
def test_invalid_urls_fail_validation(self):
|
||||
"""Test that invalid URLs fail Pydantic validation with clear errors."""
|
||||
invalid_test_cases = [
|
||||
("memory//test", "double slashes"),
|
||||
("invalid://test", "protocol scheme"),
|
||||
(" ", "empty or whitespace"),
|
||||
("notes<brackets>", "invalid characters"),
|
||||
]
|
||||
|
||||
for url, expected_error in invalid_test_cases:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
memory_url.validate_python(url)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "value_error" in error_msg, f"Should be a value_error for '{url}'"
|
||||
|
||||
def test_empty_string_fails_minlength(self):
|
||||
"""Test that empty strings fail MinLen validation."""
|
||||
with pytest.raises(ValidationError, match="at least 1"):
|
||||
memory_url.validate_python("")
|
||||
|
||||
def test_very_long_urls_fail_maxlength(self):
|
||||
"""Test that very long URLs fail MaxLen validation."""
|
||||
long_url = "a" * 3000 # Exceeds MaxLen(2028)
|
||||
with pytest.raises(ValidationError, match="at most 2028"):
|
||||
memory_url.validate_python(long_url)
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
"""Test that whitespace is properly stripped."""
|
||||
urls_with_whitespace = [
|
||||
" specs/search ",
|
||||
"\tprojects/basic-memory\t",
|
||||
"\nnotes/meeting\n",
|
||||
]
|
||||
|
||||
for url in urls_with_whitespace:
|
||||
result = memory_url.validate_python(url)
|
||||
assert not result.startswith(" ") and not result.endswith(" "), (
|
||||
f"Whitespace should be stripped from '{url}'"
|
||||
)
|
||||
assert "memory://" in result, "Result should contain memory:// prefix"
|
||||
|
||||
|
||||
class TestMemoryUrlErrorMessages:
|
||||
"""Test that error messages are clear and helpful."""
|
||||
|
||||
def test_double_slash_error_message(self):
|
||||
"""Test specific error message for double slashes."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
normalize_memory_url("memory//test")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "memory//test" in error_msg
|
||||
assert "double slashes" in error_msg
|
||||
|
||||
def test_protocol_scheme_error_message(self):
|
||||
"""Test specific error message for protocol schemes."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
normalize_memory_url("http://example.com")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "http://example.com" in error_msg
|
||||
assert "protocol scheme" in error_msg
|
||||
|
||||
def test_empty_error_message(self):
|
||||
"""Test specific error message for empty paths."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
normalize_memory_url(" ")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "empty or whitespace" in error_msg
|
||||
|
||||
def test_invalid_characters_error_message(self):
|
||||
"""Test specific error message for invalid characters."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
normalize_memory_url("notes<brackets>")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "notes<brackets>" in error_msg
|
||||
assert "invalid characters" in error_msg
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for Pydantic schema validation and conversion."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, time, timedelta
|
||||
from pydantic import ValidationError, BaseModel
|
||||
|
||||
from basic_memory.schemas import (
|
||||
@@ -12,7 +13,7 @@ from basic_memory.schemas import (
|
||||
RelationResponse,
|
||||
)
|
||||
from basic_memory.schemas.request import EditEntityRequest
|
||||
from basic_memory.schemas.base import to_snake_case, TimeFrame
|
||||
from basic_memory.schemas.base import to_snake_case, TimeFrame, parse_timeframe, validate_timeframe
|
||||
|
||||
|
||||
def test_entity_project_name():
|
||||
@@ -277,3 +278,150 @@ def test_edit_entity_request_replace_section_empty_section():
|
||||
"section": "", # Empty string triggers validation
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# New tests for timeframe parsing functions
|
||||
class TestTimeframeParsing:
|
||||
"""Test cases for parse_timeframe() and validate_timeframe() functions."""
|
||||
|
||||
def test_parse_timeframe_today(self):
|
||||
"""Test that parse_timeframe('today') returns start of current day."""
|
||||
result = parse_timeframe("today")
|
||||
expected = datetime.combine(datetime.now().date(), time.min)
|
||||
|
||||
assert result == expected
|
||||
assert result.hour == 0
|
||||
assert result.minute == 0
|
||||
assert result.second == 0
|
||||
assert result.microsecond == 0
|
||||
|
||||
def test_parse_timeframe_today_case_insensitive(self):
|
||||
"""Test that parse_timeframe handles 'today' case-insensitively."""
|
||||
test_cases = ["today", "TODAY", "Today", "ToDay"]
|
||||
expected = datetime.combine(datetime.now().date(), time.min)
|
||||
|
||||
for case in test_cases:
|
||||
result = parse_timeframe(case)
|
||||
assert result == expected
|
||||
|
||||
def test_parse_timeframe_other_formats(self):
|
||||
"""Test that parse_timeframe works with other dateparser formats."""
|
||||
now = datetime.now()
|
||||
|
||||
# Test 1d ago - should be approximately 24 hours ago
|
||||
result_1d = parse_timeframe("1d")
|
||||
expected_1d = now - timedelta(days=1)
|
||||
diff = abs((result_1d - expected_1d).total_seconds())
|
||||
assert diff < 60 # Within 1 minute tolerance
|
||||
|
||||
# Test yesterday - should be yesterday at same time
|
||||
result_yesterday = parse_timeframe("yesterday")
|
||||
# dateparser returns yesterday at current time, not start of yesterday
|
||||
assert result_yesterday.date() == (now.date() - timedelta(days=1))
|
||||
|
||||
# Test 1 week ago
|
||||
result_week = parse_timeframe("1 week ago")
|
||||
expected_week = now - timedelta(weeks=1)
|
||||
diff = abs((result_week - expected_week).total_seconds())
|
||||
assert diff < 3600 # Within 1 hour tolerance
|
||||
|
||||
def test_parse_timeframe_invalid(self):
|
||||
"""Test that parse_timeframe raises ValueError for invalid input."""
|
||||
with pytest.raises(ValueError, match="Could not parse timeframe: invalid-timeframe"):
|
||||
parse_timeframe("invalid-timeframe")
|
||||
|
||||
with pytest.raises(ValueError, match="Could not parse timeframe: not-a-date"):
|
||||
parse_timeframe("not-a-date")
|
||||
|
||||
def test_validate_timeframe_preserves_special_cases(self):
|
||||
"""Test that validate_timeframe preserves special timeframe strings."""
|
||||
# Should preserve 'today' as-is
|
||||
result = validate_timeframe("today")
|
||||
assert result == "today"
|
||||
|
||||
# Should preserve case-normalized version
|
||||
result = validate_timeframe("TODAY")
|
||||
assert result == "today"
|
||||
|
||||
result = validate_timeframe("Today")
|
||||
assert result == "today"
|
||||
|
||||
def test_validate_timeframe_converts_regular_formats(self):
|
||||
"""Test that validate_timeframe converts regular formats to duration."""
|
||||
# Test 1d format (should return as-is since it's already in standard format)
|
||||
result = validate_timeframe("1d")
|
||||
assert result == "1d"
|
||||
|
||||
# Test other formats get converted to days
|
||||
result = validate_timeframe("yesterday")
|
||||
assert result == "1d" # Yesterday is 1 day ago
|
||||
|
||||
# Test week format
|
||||
result = validate_timeframe("1 week ago")
|
||||
assert result == "7d" # 1 week = 7 days
|
||||
|
||||
def test_validate_timeframe_error_cases(self):
|
||||
"""Test that validate_timeframe raises appropriate errors."""
|
||||
# Invalid type
|
||||
with pytest.raises(ValueError, match="Timeframe must be a string"):
|
||||
validate_timeframe(123) # type: ignore
|
||||
|
||||
# Future timeframe
|
||||
with pytest.raises(ValueError, match="Timeframe cannot be in the future"):
|
||||
validate_timeframe("tomorrow")
|
||||
|
||||
# Too far in past (>365 days)
|
||||
with pytest.raises(ValueError, match="Timeframe should be <= 1 year"):
|
||||
validate_timeframe("2 years ago")
|
||||
|
||||
# Invalid format that can't be parsed
|
||||
with pytest.raises(ValueError, match="Could not parse timeframe"):
|
||||
validate_timeframe("not-a-real-timeframe")
|
||||
|
||||
def test_timeframe_annotation_with_today(self):
|
||||
"""Test that TimeFrame annotation works correctly with 'today'."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
timeframe: TimeFrame
|
||||
|
||||
# Should preserve 'today'
|
||||
model = TestModel(timeframe="today")
|
||||
assert model.timeframe == "today"
|
||||
|
||||
# Should work with other formats
|
||||
model = TestModel(timeframe="1d")
|
||||
assert model.timeframe == "1d"
|
||||
|
||||
model = TestModel(timeframe="yesterday")
|
||||
assert model.timeframe == "1d"
|
||||
|
||||
def test_timeframe_integration_today_vs_1d(self):
|
||||
"""Test the specific bug fix: 'today' vs '1d' behavior."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
timeframe: TimeFrame
|
||||
|
||||
# 'today' should be preserved
|
||||
today_model = TestModel(timeframe="today")
|
||||
assert today_model.timeframe == "today"
|
||||
|
||||
# '1d' should also be preserved (it's already in standard format)
|
||||
oneday_model = TestModel(timeframe="1d")
|
||||
assert oneday_model.timeframe == "1d"
|
||||
|
||||
# When parsed by parse_timeframe, they should be different
|
||||
today_parsed = parse_timeframe("today")
|
||||
oneday_parsed = parse_timeframe("1d")
|
||||
|
||||
# 'today' should be start of today (00:00:00)
|
||||
assert today_parsed.hour == 0
|
||||
assert today_parsed.minute == 0
|
||||
|
||||
# '1d' should be 24 hours ago (same time yesterday)
|
||||
now = datetime.now()
|
||||
expected_1d = now - timedelta(days=1)
|
||||
diff = abs((oneday_parsed - expected_1d).total_seconds())
|
||||
assert diff < 60 # Within 1 minute
|
||||
|
||||
# They should be different times
|
||||
assert today_parsed != oneday_parsed
|
||||
|
||||
@@ -869,6 +869,119 @@ async def test_edit_entity_with_observations_and_relations(
|
||||
assert new_rel.relation_type == "relates to"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_from_markdown_race_condition_handling(
|
||||
entity_service: EntityService, file_service: FileService
|
||||
):
|
||||
"""Test that create_entity_from_markdown handles race condition with IntegrityError (lines 304-311)."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
file_path = Path("test/race-condition.md")
|
||||
|
||||
# Create a mock EntityMarkdown object
|
||||
from basic_memory.markdown.schemas import (
|
||||
EntityFrontmatter,
|
||||
EntityMarkdown as RealEntityMarkdown,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
frontmatter = EntityFrontmatter(metadata={"title": "Race Condition Test", "type": "test"})
|
||||
markdown = RealEntityMarkdown(
|
||||
frontmatter=frontmatter,
|
||||
observations=[],
|
||||
relations=[],
|
||||
created=datetime.now(timezone.utc),
|
||||
modified=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Mock the repository.add to raise IntegrityError on first call, then succeed on second
|
||||
original_add = entity_service.repository.add
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_add(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# Simulate race condition - another process created the entity
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
|
||||
else:
|
||||
return await original_add(*args, **kwargs)
|
||||
|
||||
# Mock update method to return a dummy entity
|
||||
async def mock_update(*args, **kwargs):
|
||||
from basic_memory.models import Entity
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return Entity(
|
||||
id=1,
|
||||
title="Race Condition Test",
|
||||
entity_type="test",
|
||||
file_path=str(file_path),
|
||||
permalink="test/race-condition-test",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(entity_service.repository, "add", side_effect=mock_add),
|
||||
patch.object(
|
||||
entity_service, "update_entity_and_observations", side_effect=mock_update
|
||||
) as mock_update_call,
|
||||
):
|
||||
# Call the method
|
||||
result = await entity_service.create_entity_from_markdown(file_path, markdown)
|
||||
|
||||
# Verify it handled the race condition gracefully
|
||||
assert result is not None
|
||||
assert result.title == "Race Condition Test"
|
||||
assert result.file_path == str(file_path)
|
||||
|
||||
# Verify that update_entity_and_observations was called as fallback
|
||||
mock_update_call.assert_called_once_with(file_path, markdown)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_from_markdown_integrity_error_reraise(
|
||||
entity_service: EntityService, file_service: FileService
|
||||
):
|
||||
"""Test that create_entity_from_markdown re-raises IntegrityError for non-race-condition cases."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
file_path = Path("test/integrity-error.md")
|
||||
|
||||
# Create a mock EntityMarkdown object
|
||||
from basic_memory.markdown.schemas import (
|
||||
EntityFrontmatter,
|
||||
EntityMarkdown as RealEntityMarkdown,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
frontmatter = EntityFrontmatter(metadata={"title": "Integrity Error Test", "type": "test"})
|
||||
markdown = RealEntityMarkdown(
|
||||
frontmatter=frontmatter,
|
||||
observations=[],
|
||||
relations=[],
|
||||
created=datetime.now(timezone.utc),
|
||||
modified=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Mock the repository.add to raise a different IntegrityError (not file_path/permalink constraint)
|
||||
async def mock_add(*args, **kwargs):
|
||||
# Simulate a different constraint violation
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None)
|
||||
|
||||
with patch.object(entity_service.repository, "add", side_effect=mock_add):
|
||||
# Should re-raise the IntegrityError since it's not a file_path/permalink constraint
|
||||
with pytest.raises(
|
||||
IntegrityError, match="UNIQUE constraint failed: entity.some_other_field"
|
||||
):
|
||||
await entity_service.create_entity_from_markdown(file_path, markdown)
|
||||
|
||||
|
||||
# Edge case tests for find_replace operation
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_entity_find_replace_not_found(entity_service: EntityService):
|
||||
|
||||
@@ -35,44 +35,42 @@ async def test_initialize_database_error(mock_run_migrations, project_config):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("basic_memory.services.initialization.reconcile_projects_with_config")
|
||||
@patch("basic_memory.services.initialization.migrate_legacy_projects")
|
||||
@patch("basic_memory.services.migration_service.migration_manager")
|
||||
@patch("basic_memory.services.initialization.initialize_database")
|
||||
@patch("basic_memory.services.initialization.initialize_file_sync")
|
||||
async def test_initialize_app(
|
||||
mock_initialize_file_sync,
|
||||
mock_initialize_database,
|
||||
mock_migrate_legacy_projects,
|
||||
mock_migration_manager,
|
||||
mock_reconcile_projects,
|
||||
app_config,
|
||||
):
|
||||
"""Test app initialization."""
|
||||
mock_initialize_file_sync.return_value = None
|
||||
mock_migration_manager.start_background_migration = AsyncMock()
|
||||
|
||||
result = await initialize_app(app_config)
|
||||
|
||||
mock_initialize_database.assert_called_once_with(app_config)
|
||||
mock_reconcile_projects.assert_called_once_with(app_config)
|
||||
mock_migrate_legacy_projects.assert_called_once_with(app_config)
|
||||
mock_initialize_file_sync.assert_not_called()
|
||||
assert result is None
|
||||
mock_migration_manager.start_background_migration.assert_called_once_with(app_config)
|
||||
assert result == mock_migration_manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("basic_memory.services.initialization.initialize_database")
|
||||
@patch("basic_memory.services.initialization.reconcile_projects_with_config")
|
||||
@patch("basic_memory.services.initialization.migrate_legacy_projects")
|
||||
@patch("basic_memory.services.migration_service.migration_manager")
|
||||
async def test_initialize_app_sync_disabled(
|
||||
mock_migrate_legacy_projects, mock_reconcile_projects, mock_initialize_database, app_config
|
||||
mock_migration_manager, mock_reconcile_projects, mock_initialize_database, app_config
|
||||
):
|
||||
"""Test app initialization with sync disabled."""
|
||||
app_config.sync_changes = False
|
||||
mock_migration_manager.start_background_migration = AsyncMock()
|
||||
|
||||
result = await initialize_app(app_config)
|
||||
|
||||
mock_initialize_database.assert_called_once_with(app_config)
|
||||
mock_reconcile_projects.assert_called_once_with(app_config)
|
||||
mock_migrate_legacy_projects.assert_called_once_with(app_config)
|
||||
assert result is None
|
||||
mock_migration_manager.start_background_migration.assert_called_once_with(app_config)
|
||||
assert result == mock_migration_manager
|
||||
|
||||
|
||||
@patch("basic_memory.services.initialization.asyncio.run")
|
||||
@@ -260,7 +258,9 @@ async def test_migrate_legacy_project_data_success(mock_rmtree, tmp_path):
|
||||
result = await migrate_legacy_project_data(mock_project, legacy_dir)
|
||||
|
||||
# Assertions
|
||||
mock_sync_service.sync.assert_called_once_with(Path(mock_project.path))
|
||||
mock_sync_service.sync.assert_called_once_with(
|
||||
Path(mock_project.path), project_name=mock_project.name
|
||||
)
|
||||
mock_rmtree.assert_called_once_with(legacy_dir)
|
||||
assert result is True
|
||||
|
||||
@@ -291,7 +291,9 @@ async def test_migrate_legacy_project_data_rmtree_error(mock_rmtree, tmp_path):
|
||||
result = await migrate_legacy_project_data(mock_project, legacy_dir)
|
||||
|
||||
# Assertions
|
||||
mock_sync_service.sync.assert_called_once_with(Path(mock_project.path))
|
||||
mock_sync_service.sync.assert_called_once_with(
|
||||
Path(mock_project.path), project_name=mock_project.name
|
||||
)
|
||||
mock_rmtree.assert_called_once_with(legacy_dir)
|
||||
assert result is False
|
||||
|
||||
@@ -345,8 +347,12 @@ async def test_initialize_file_sync_sequential(
|
||||
|
||||
# Should call sync on each project
|
||||
assert mock_sync_service.sync.call_count == 2
|
||||
mock_sync_service.sync.assert_any_call(Path(mock_project1.path))
|
||||
mock_sync_service.sync.assert_any_call(Path(mock_project2.path))
|
||||
mock_sync_service.sync.assert_any_call(
|
||||
Path(mock_project1.path), project_name=mock_project1.name
|
||||
)
|
||||
mock_sync_service.sync.assert_any_call(
|
||||
Path(mock_project2.path), project_name=mock_project2.name
|
||||
)
|
||||
|
||||
# Should start the watch service
|
||||
mock_watch_service.run.assert_called_once()
|
||||
|
||||
@@ -220,3 +220,139 @@ async def test_folder_title_pattern_with_md_extension(link_resolver, test_entiti
|
||||
entity = await link_resolver.resolve_link("components/core-service")
|
||||
assert entity is not None
|
||||
assert entity.permalink == "components/core-service"
|
||||
|
||||
|
||||
# Tests for strict mode parameter combinations
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_parameter_combinations(link_resolver, test_entities):
|
||||
"""Test all combinations of use_search and strict parameters."""
|
||||
|
||||
# Test queries
|
||||
exact_match = "Auth Service" # Should always work (unique title)
|
||||
fuzzy_match = "Auth Serv" # Should only work with fuzzy search enabled
|
||||
non_existent = "Does Not Exist" # Should never work
|
||||
|
||||
# Case 1: use_search=True, strict=False (default behavior - fuzzy matching allowed)
|
||||
result = await link_resolver.resolve_link(exact_match, use_search=True, strict=False)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
result = await link_resolver.resolve_link(fuzzy_match, use_search=True, strict=False)
|
||||
assert result is not None # Should find "Auth Service" via fuzzy matching
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
result = await link_resolver.resolve_link(non_existent, use_search=True, strict=False)
|
||||
assert result is None
|
||||
|
||||
# Case 2: use_search=True, strict=True (exact matches only, even with search enabled)
|
||||
result = await link_resolver.resolve_link(exact_match, use_search=True, strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
result = await link_resolver.resolve_link(fuzzy_match, use_search=True, strict=True)
|
||||
assert result is None # Should NOT find via fuzzy matching in strict mode
|
||||
|
||||
result = await link_resolver.resolve_link(non_existent, use_search=True, strict=True)
|
||||
assert result is None
|
||||
|
||||
# Case 3: use_search=False, strict=False (no search, exact repository matches only)
|
||||
result = await link_resolver.resolve_link(exact_match, use_search=False, strict=False)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
result = await link_resolver.resolve_link(fuzzy_match, use_search=False, strict=False)
|
||||
assert result is None # No search means no fuzzy matching
|
||||
|
||||
result = await link_resolver.resolve_link(non_existent, use_search=False, strict=False)
|
||||
assert result is None
|
||||
|
||||
# Case 4: use_search=False, strict=True (redundant but should work same as case 3)
|
||||
result = await link_resolver.resolve_link(exact_match, use_search=False, strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
result = await link_resolver.resolve_link(fuzzy_match, use_search=False, strict=True)
|
||||
assert result is None # No search means no fuzzy matching
|
||||
|
||||
result = await link_resolver.resolve_link(non_existent, use_search=False, strict=True)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_match_types_in_strict_mode(link_resolver, test_entities):
|
||||
"""Test that all types of exact matches work in strict mode."""
|
||||
|
||||
# 1. Exact permalink match
|
||||
result = await link_resolver.resolve_link("components/core-service", strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
# 2. Exact title match
|
||||
result = await link_resolver.resolve_link("Core Service", strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
# 3. Exact file path match
|
||||
result = await link_resolver.resolve_link("components/Core Service.md", strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
# 4. Folder/title pattern with .md extension added
|
||||
result = await link_resolver.resolve_link("components/Core Service", strict=True)
|
||||
assert result is not None
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
# 5. Non-markdown file (Image.png)
|
||||
result = await link_resolver.resolve_link("Image.png", strict=True)
|
||||
assert result is not None
|
||||
assert result.title == "Image.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_matching_blocked_in_strict_mode(link_resolver, test_entities):
|
||||
"""Test that various fuzzy matching scenarios are blocked in strict mode."""
|
||||
|
||||
# Partial matches that would work in normal mode
|
||||
fuzzy_queries = [
|
||||
"Auth Serv", # Partial title
|
||||
"auth-service", # Lowercase permalink variation
|
||||
"Core", # Single word from title
|
||||
"Service", # Common word
|
||||
"Serv", # Partial word
|
||||
]
|
||||
|
||||
for query in fuzzy_queries:
|
||||
# Should NOT work in strict mode
|
||||
strict_result = await link_resolver.resolve_link(query, strict=True)
|
||||
assert strict_result is None, f"Query '{query}' should return None in strict mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_normalization_with_strict_mode(link_resolver, test_entities):
|
||||
"""Test that link normalization still works in strict mode."""
|
||||
|
||||
# Test bracket removal and alias handling in strict mode
|
||||
queries_and_expected = [
|
||||
("[[Core Service]]", "components/core-service"),
|
||||
("[[Core Service|Main]]", "components/core-service"), # Alias should be ignored
|
||||
(" [[ Core Service ]] ", "components/core-service"), # Extra whitespace
|
||||
]
|
||||
|
||||
for query, expected_permalink in queries_and_expected:
|
||||
result = await link_resolver.resolve_link(query, strict=True)
|
||||
assert result is not None, f"Query '{query}' should find entity in strict mode"
|
||||
assert result.permalink == expected_permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entities):
|
||||
"""Test how duplicate titles are handled in strict mode."""
|
||||
|
||||
# "Core Service" appears twice in test data (components/core-service and components2/core-service)
|
||||
# In strict mode, if there are multiple exact title matches, it should still return the first one
|
||||
# (same behavior as normal mode for exact matches)
|
||||
|
||||
result = await link_resolver.resolve_link("Core Service", strict=True)
|
||||
assert result is not None
|
||||
# Should return the first match (components/core-service based on test fixture order)
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
@@ -123,10 +123,10 @@ async def test_get_system_status(project_service: ProjectService):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics(project_service: ProjectService, test_graph):
|
||||
async def test_get_statistics(project_service: ProjectService, test_graph, test_project):
|
||||
"""Test getting statistics."""
|
||||
# Get statistics
|
||||
statistics = await project_service.get_statistics()
|
||||
statistics = await project_service.get_statistics(test_project.id)
|
||||
|
||||
# Assert it returns a valid ProjectStatistics object
|
||||
assert isinstance(statistics, ProjectStatistics)
|
||||
@@ -135,10 +135,10 @@ async def test_get_statistics(project_service: ProjectService, test_graph):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_activity_metrics(project_service: ProjectService, test_graph):
|
||||
async def test_get_activity_metrics(project_service: ProjectService, test_graph, test_project):
|
||||
"""Test getting activity metrics."""
|
||||
# Get activity metrics
|
||||
metrics = await project_service.get_activity_metrics()
|
||||
metrics = await project_service.get_activity_metrics(test_project.id)
|
||||
|
||||
# Assert it returns a valid ActivityMetrics object
|
||||
assert isinstance(metrics, ActivityMetrics)
|
||||
@@ -147,10 +147,10 @@ async def test_get_activity_metrics(project_service: ProjectService, test_graph)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_info(project_service: ProjectService, test_graph):
|
||||
async def test_get_project_info(project_service: ProjectService, test_graph, test_project):
|
||||
"""Test getting full project info."""
|
||||
# Get project info
|
||||
info = await project_service.get_project_info()
|
||||
info = await project_service.get_project_info(test_project.name)
|
||||
|
||||
# Assert it returns a valid ProjectInfoResponse object
|
||||
assert isinstance(info, ProjectInfoResponse)
|
||||
@@ -469,3 +469,135 @@ async def test_synchronize_projects_calls_ensure_single_default(
|
||||
# Clean up test project
|
||||
if test_project_name in project_service.projects:
|
||||
await project_service.remove_project(test_project_name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synchronize_projects_normalizes_project_names(
|
||||
project_service: ProjectService, tmp_path
|
||||
):
|
||||
"""Test that synchronize_projects normalizes project names in config to match database format."""
|
||||
# Use a project name that needs normalization (uppercase, spaces)
|
||||
unnormalized_name = "Test Project With Spaces"
|
||||
expected_normalized_name = "test-project-with-spaces"
|
||||
test_project_path = str(tmp_path / "test-project-spaces")
|
||||
|
||||
# Make sure the test directory exists
|
||||
os.makedirs(test_project_path, exist_ok=True)
|
||||
|
||||
# Import config manager outside try block
|
||||
from basic_memory.config import config_manager
|
||||
|
||||
try:
|
||||
# Manually add the unnormalized project name to config
|
||||
|
||||
# Save the original config state for potential debugging
|
||||
# original_projects = config_manager.projects.copy()
|
||||
|
||||
# Add project with unnormalized name directly to config
|
||||
config_manager.config.projects[unnormalized_name] = test_project_path
|
||||
config_manager.save_config(config_manager.config)
|
||||
|
||||
# Verify the unnormalized name is in config
|
||||
assert unnormalized_name in project_service.projects
|
||||
assert project_service.projects[unnormalized_name] == test_project_path
|
||||
|
||||
# Call synchronize_projects - this should normalize the project name
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
# Verify the config was updated with normalized name
|
||||
assert expected_normalized_name in project_service.projects
|
||||
assert unnormalized_name not in project_service.projects
|
||||
assert project_service.projects[expected_normalized_name] == test_project_path
|
||||
|
||||
# Verify the project was added to database with normalized name
|
||||
db_project = await project_service.repository.get_by_name(expected_normalized_name)
|
||||
assert db_project is not None
|
||||
assert db_project.name == expected_normalized_name
|
||||
assert db_project.path == test_project_path
|
||||
assert db_project.permalink == expected_normalized_name
|
||||
|
||||
# Verify the unnormalized name is not in database
|
||||
unnormalized_db_project = await project_service.repository.get_by_name(unnormalized_name)
|
||||
assert unnormalized_db_project is None
|
||||
|
||||
finally:
|
||||
# Clean up - remove any test projects from both config and database
|
||||
current_projects = project_service.projects.copy()
|
||||
for name in [unnormalized_name, expected_normalized_name]:
|
||||
if name in current_projects:
|
||||
try:
|
||||
await project_service.remove_project(name)
|
||||
except Exception:
|
||||
# Try to clean up manually if remove_project fails
|
||||
try:
|
||||
config_manager.remove_project(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove from database
|
||||
db_project = await project_service.repository.get_by_name(name)
|
||||
if db_project:
|
||||
await project_service.repository.delete(db_project.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synchronize_projects_handles_case_sensitivity_bug(
|
||||
project_service: ProjectService, tmp_path
|
||||
):
|
||||
"""Test that synchronize_projects fixes the case sensitivity bug (Personal vs personal)."""
|
||||
# Simulate the exact bug scenario: config has "Personal" but database expects "personal"
|
||||
config_name = "Personal"
|
||||
normalized_name = "personal"
|
||||
test_project_path = str(tmp_path / "personal-project")
|
||||
|
||||
# Make sure the test directory exists
|
||||
os.makedirs(test_project_path, exist_ok=True)
|
||||
|
||||
# Import config manager outside try block
|
||||
from basic_memory.config import config_manager
|
||||
|
||||
try:
|
||||
# Add project with uppercase name to config (simulating the bug scenario)
|
||||
config_manager.config.projects[config_name] = test_project_path
|
||||
config_manager.save_config(config_manager.config)
|
||||
|
||||
# Verify the uppercase name is in config
|
||||
assert config_name in project_service.projects
|
||||
assert project_service.projects[config_name] == test_project_path
|
||||
|
||||
# Call synchronize_projects - this should fix the case sensitivity issue
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
# Verify the config was updated to use normalized case
|
||||
assert normalized_name in project_service.projects
|
||||
assert config_name not in project_service.projects
|
||||
assert project_service.projects[normalized_name] == test_project_path
|
||||
|
||||
# Verify the project exists in database with correct normalized name
|
||||
db_project = await project_service.repository.get_by_name(normalized_name)
|
||||
assert db_project is not None
|
||||
assert db_project.name == normalized_name
|
||||
assert db_project.path == test_project_path
|
||||
|
||||
# Verify we can now switch to this project without case sensitivity errors
|
||||
# (This would have failed before the fix with "Personal" != "personal")
|
||||
project_lookup = await project_service.get_project(normalized_name)
|
||||
assert project_lookup is not None
|
||||
assert project_lookup.name == normalized_name
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
for name in [config_name, normalized_name]:
|
||||
if name in project_service.projects:
|
||||
try:
|
||||
await project_service.remove_project(name)
|
||||
except Exception:
|
||||
# Manual cleanup if needed
|
||||
try:
|
||||
config_manager.remove_project(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db_project = await project_service.repository.get_by_name(name)
|
||||
if db_project:
|
||||
await project_service.repository.delete(db_project.id)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Test sync status service functionality."""
|
||||
|
||||
import pytest
|
||||
from basic_memory.services.sync_status_service import SyncStatusTracker, SyncStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_tracker():
|
||||
"""Create a fresh sync status tracker for testing."""
|
||||
return SyncStatusTracker()
|
||||
|
||||
|
||||
def test_sync_tracker_initial_state(sync_tracker):
|
||||
"""Test initial state of sync tracker."""
|
||||
assert sync_tracker.is_ready
|
||||
assert not sync_tracker.is_syncing
|
||||
assert sync_tracker.global_status == SyncStatus.IDLE
|
||||
assert sync_tracker.get_summary() == "✅ System ready"
|
||||
|
||||
|
||||
def test_start_project_sync(sync_tracker):
|
||||
"""Test starting project sync."""
|
||||
sync_tracker.start_project_sync("test-project", files_total=10)
|
||||
|
||||
assert not sync_tracker.is_ready
|
||||
assert sync_tracker.is_syncing
|
||||
assert sync_tracker.global_status == SyncStatus.SYNCING
|
||||
|
||||
project_status = sync_tracker.get_project_status("test-project")
|
||||
assert project_status is not None
|
||||
assert project_status.status == SyncStatus.SCANNING
|
||||
assert project_status.message == "Scanning files"
|
||||
assert project_status.files_total == 10
|
||||
|
||||
|
||||
def test_update_project_progress(sync_tracker):
|
||||
"""Test updating project progress."""
|
||||
sync_tracker.start_project_sync("test-project") # Use default files_total=0
|
||||
sync_tracker.update_project_progress(
|
||||
"test-project", SyncStatus.SYNCING, "Processing files", files_processed=5, files_total=10
|
||||
)
|
||||
|
||||
project_status = sync_tracker.get_project_status("test-project")
|
||||
assert project_status.status == SyncStatus.SYNCING
|
||||
assert project_status.message == "Processing files"
|
||||
assert project_status.files_processed == 5
|
||||
assert project_status.files_total == 10
|
||||
assert sync_tracker.global_status == SyncStatus.SYNCING
|
||||
|
||||
|
||||
def test_complete_project_sync(sync_tracker):
|
||||
"""Test completing project sync."""
|
||||
sync_tracker.start_project_sync("test-project")
|
||||
sync_tracker.complete_project_sync("test-project")
|
||||
|
||||
assert sync_tracker.is_ready
|
||||
assert not sync_tracker.is_syncing
|
||||
assert sync_tracker.global_status == SyncStatus.COMPLETED
|
||||
|
||||
project_status = sync_tracker.get_project_status("test-project")
|
||||
assert project_status.status == SyncStatus.COMPLETED
|
||||
assert project_status.message == "Sync completed"
|
||||
|
||||
|
||||
def test_fail_project_sync(sync_tracker):
|
||||
"""Test failing project sync."""
|
||||
sync_tracker.start_project_sync("test-project")
|
||||
sync_tracker.fail_project_sync("test-project", "Connection error")
|
||||
|
||||
assert not sync_tracker.is_ready
|
||||
assert not sync_tracker.is_syncing
|
||||
assert sync_tracker.global_status == SyncStatus.FAILED
|
||||
|
||||
project_status = sync_tracker.get_project_status("test-project")
|
||||
assert project_status.status == SyncStatus.FAILED
|
||||
assert project_status.error == "Connection error"
|
||||
|
||||
|
||||
def test_start_project_watch(sync_tracker):
|
||||
"""Test starting project watch mode."""
|
||||
sync_tracker.start_project_watch("test-project")
|
||||
|
||||
assert sync_tracker.is_ready
|
||||
assert not sync_tracker.is_syncing
|
||||
assert sync_tracker.global_status == SyncStatus.COMPLETED
|
||||
|
||||
project_status = sync_tracker.get_project_status("test-project")
|
||||
assert project_status.status == SyncStatus.WATCHING
|
||||
assert project_status.message == "Watching for changes"
|
||||
|
||||
|
||||
def test_multiple_projects_status(sync_tracker):
|
||||
"""Test status with multiple projects."""
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.start_project_sync("project2")
|
||||
|
||||
# Both scanning - should be syncing
|
||||
assert sync_tracker.global_status == SyncStatus.SYNCING
|
||||
assert sync_tracker.is_syncing
|
||||
|
||||
# Complete one project
|
||||
sync_tracker.complete_project_sync("project1")
|
||||
assert sync_tracker.global_status == SyncStatus.SYNCING # Still syncing
|
||||
|
||||
# Complete second project
|
||||
sync_tracker.complete_project_sync("project2")
|
||||
assert sync_tracker.global_status == SyncStatus.COMPLETED
|
||||
assert sync_tracker.is_ready
|
||||
|
||||
|
||||
def test_mixed_project_statuses(sync_tracker):
|
||||
"""Test mixed project statuses."""
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.start_project_sync("project2")
|
||||
|
||||
# Fail one project
|
||||
sync_tracker.fail_project_sync("project1", "Error")
|
||||
# Complete other project
|
||||
sync_tracker.complete_project_sync("project2")
|
||||
|
||||
# Should show failed status
|
||||
assert sync_tracker.global_status == SyncStatus.FAILED
|
||||
assert not sync_tracker.is_ready
|
||||
|
||||
|
||||
def test_get_summary_with_progress(sync_tracker):
|
||||
"""Test summary with progress information."""
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.update_project_progress(
|
||||
"project1", SyncStatus.SYNCING, "Processing", files_processed=25, files_total=100
|
||||
)
|
||||
|
||||
summary = sync_tracker.get_summary()
|
||||
assert "🔄 Syncing 1 projects" in summary
|
||||
assert "(25/100 files, 25%)" in summary
|
||||
|
||||
|
||||
def test_get_all_projects(sync_tracker):
|
||||
"""Test getting all project statuses."""
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.start_project_sync("project2")
|
||||
|
||||
all_projects = sync_tracker.get_all_projects()
|
||||
assert len(all_projects) == 2
|
||||
assert "project1" in all_projects
|
||||
assert "project2" in all_projects
|
||||
assert all_projects["project1"].status == SyncStatus.SCANNING
|
||||
assert all_projects["project2"].status == SyncStatus.SCANNING
|
||||
|
||||
|
||||
def test_clear_completed(sync_tracker):
|
||||
"""Test clearing completed project statuses."""
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.start_project_sync("project2")
|
||||
|
||||
sync_tracker.complete_project_sync("project1")
|
||||
sync_tracker.fail_project_sync("project2", "Error")
|
||||
|
||||
# Should have 2 projects before clearing
|
||||
assert len(sync_tracker.get_all_projects()) == 2
|
||||
|
||||
sync_tracker.clear_completed()
|
||||
|
||||
# Should only have the failed project after clearing
|
||||
remaining = sync_tracker.get_all_projects()
|
||||
assert len(remaining) == 1
|
||||
assert "project2" in remaining
|
||||
assert remaining["project2"].status == SyncStatus.FAILED
|
||||
|
||||
|
||||
def test_summary_messages(sync_tracker):
|
||||
"""Test various summary messages."""
|
||||
# Initial state
|
||||
assert sync_tracker.get_summary() == "✅ System ready"
|
||||
|
||||
# All completed
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.complete_project_sync("project1")
|
||||
assert sync_tracker.get_summary() == "✅ All projects synced successfully"
|
||||
|
||||
# Failed projects
|
||||
sync_tracker.fail_project_sync("project1", "Test error")
|
||||
assert "❌ Sync failed for: project1" in sync_tracker.get_summary()
|
||||
|
||||
|
||||
def test_global_status_edge_cases(sync_tracker):
|
||||
"""Test edge cases for global status calculation."""
|
||||
# Test mixed statuses (some completed, some watching) - should be completed
|
||||
sync_tracker.start_project_sync("project1")
|
||||
sync_tracker.start_project_sync("project2")
|
||||
|
||||
sync_tracker.complete_project_sync("project1")
|
||||
sync_tracker.start_project_watch("project2")
|
||||
|
||||
assert sync_tracker.global_status == SyncStatus.COMPLETED
|
||||
|
||||
# Test fallback case - create a scenario that doesn't match specific conditions
|
||||
sync_tracker.start_project_sync("project3")
|
||||
sync_tracker.update_project_progress("project3", SyncStatus.IDLE, "Idle")
|
||||
|
||||
# This should trigger the "else" clause in _update_global_status
|
||||
assert sync_tracker.global_status == SyncStatus.SYNCING
|
||||
|
||||
|
||||
def test_summary_without_file_counts(sync_tracker):
|
||||
"""Test summary when projects don't have file counts."""
|
||||
sync_tracker.start_project_sync("project1") # files_total defaults to 0
|
||||
sync_tracker.start_project_sync("project2") # files_total defaults to 0
|
||||
|
||||
# Don't set file counts - should use the fallback message
|
||||
summary = sync_tracker.get_summary()
|
||||
assert "🔄 Syncing 2 projects" in summary
|
||||
assert "files" not in summary # Should not show file progress
|
||||
@@ -367,6 +367,7 @@ modified: 2024-01-01
|
||||
assert "design" in categories
|
||||
|
||||
|
||||
@pytest.mark.skip("sometimes fails")
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_entity_with_order_dependent_relations(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
@@ -1088,3 +1089,225 @@ permalink: note
|
||||
""".strip()
|
||||
== file_one_content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_regular_file_race_condition_handling(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
):
|
||||
"""Test that sync_regular_file handles race condition with IntegrityError (lines 380-401)."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create a test file
|
||||
test_file = project_config.home / "test_race.md"
|
||||
test_content = """
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Race Condition
|
||||
This is a test file for race condition handling.
|
||||
"""
|
||||
await create_test_file(test_file, test_content)
|
||||
|
||||
# Mock the entity_repository.add to raise IntegrityError on first call
|
||||
original_add = sync_service.entity_repository.add
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_add(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# Simulate race condition - another process created the entity
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
|
||||
else:
|
||||
return await original_add(*args, **kwargs)
|
||||
|
||||
# Mock get_by_file_path to return an existing entity (simulating the race condition result)
|
||||
async def mock_get_by_file_path(file_path):
|
||||
from basic_memory.models import Entity
|
||||
|
||||
return Entity(
|
||||
id=1,
|
||||
title="Test Race Condition",
|
||||
entity_type="knowledge",
|
||||
file_path=str(file_path),
|
||||
permalink="test-race-condition",
|
||||
content_type="text/markdown",
|
||||
checksum="old_checksum",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Mock update to return the updated entity
|
||||
async def mock_update(entity_id, updates):
|
||||
from basic_memory.models import Entity
|
||||
|
||||
return Entity(
|
||||
id=entity_id,
|
||||
title="Test Race Condition",
|
||||
entity_type="knowledge",
|
||||
file_path=updates["file_path"],
|
||||
permalink="test-race-condition",
|
||||
content_type="text/markdown",
|
||||
checksum=updates["checksum"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
|
||||
patch.object(
|
||||
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
|
||||
) as mock_get,
|
||||
patch.object(
|
||||
sync_service.entity_repository, "update", side_effect=mock_update
|
||||
) as mock_update_call,
|
||||
):
|
||||
# Call sync_regular_file
|
||||
entity, checksum = await sync_service.sync_regular_file(
|
||||
str(test_file.relative_to(project_config.home)), new=True
|
||||
)
|
||||
|
||||
# Verify it handled the race condition gracefully
|
||||
assert entity is not None
|
||||
assert entity.title == "Test Race Condition"
|
||||
assert entity.file_path == str(test_file.relative_to(project_config.home))
|
||||
|
||||
# Verify that get_by_file_path and update were called as fallback
|
||||
assert mock_get.call_count >= 1 # May be called multiple times
|
||||
mock_update_call.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_regular_file_integrity_error_reraise(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
):
|
||||
"""Test that sync_regular_file re-raises IntegrityError for non-race-condition cases."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# Create a test file
|
||||
test_file = project_config.home / "test_integrity.md"
|
||||
test_content = """
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Integrity Error
|
||||
This is a test file for integrity error handling.
|
||||
"""
|
||||
await create_test_file(test_file, test_content)
|
||||
|
||||
# Mock the entity_repository.add to raise a different IntegrityError (not file_path constraint)
|
||||
async def mock_add(*args, **kwargs):
|
||||
# Simulate a different constraint violation
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None)
|
||||
|
||||
with patch.object(sync_service.entity_repository, "add", side_effect=mock_add):
|
||||
# Should re-raise the IntegrityError since it's not a file_path constraint
|
||||
with pytest.raises(
|
||||
IntegrityError, match="UNIQUE constraint failed: entity.some_other_field"
|
||||
):
|
||||
await sync_service.sync_regular_file(
|
||||
str(test_file.relative_to(project_config.home)), new=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_regular_file_race_condition_entity_not_found(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
):
|
||||
"""Test handling when entity is not found after IntegrityError (pragma: no cover case)."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# Create a test file
|
||||
test_file = project_config.home / "test_not_found.md"
|
||||
test_content = """
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Not Found
|
||||
This is a test file for entity not found after constraint violation.
|
||||
"""
|
||||
await create_test_file(test_file, test_content)
|
||||
|
||||
# Mock the entity_repository.add to raise IntegrityError
|
||||
async def mock_add(*args, **kwargs):
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
|
||||
|
||||
# Mock get_by_file_path to return None (entity not found)
|
||||
async def mock_get_by_file_path(file_path):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
|
||||
patch.object(
|
||||
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
|
||||
),
|
||||
):
|
||||
# Should raise ValueError when entity is not found after constraint violation
|
||||
with pytest.raises(ValueError, match="Entity not found after constraint violation"):
|
||||
await sync_service.sync_regular_file(
|
||||
str(test_file.relative_to(project_config.home)), new=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_regular_file_race_condition_update_failed(
|
||||
sync_service: SyncService, project_config: ProjectConfig
|
||||
):
|
||||
"""Test handling when update fails after IntegrityError (pragma: no cover case)."""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create a test file
|
||||
test_file = project_config.home / "test_update_fail.md"
|
||||
test_content = """
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Update Fail
|
||||
This is a test file for update failure after constraint violation.
|
||||
"""
|
||||
await create_test_file(test_file, test_content)
|
||||
|
||||
# Mock the entity_repository.add to raise IntegrityError
|
||||
async def mock_add(*args, **kwargs):
|
||||
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
|
||||
|
||||
# Mock get_by_file_path to return an existing entity
|
||||
async def mock_get_by_file_path(file_path):
|
||||
from basic_memory.models import Entity
|
||||
|
||||
return Entity(
|
||||
id=1,
|
||||
title="Test Update Fail",
|
||||
entity_type="knowledge",
|
||||
file_path=str(file_path),
|
||||
permalink="test-update-fail",
|
||||
content_type="text/markdown",
|
||||
checksum="old_checksum",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Mock update to return None (failure)
|
||||
async def mock_update(entity_id, updates):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(sync_service.entity_repository, "add", side_effect=mock_add),
|
||||
patch.object(
|
||||
sync_service.entity_repository, "get_by_file_path", side_effect=mock_get_by_file_path
|
||||
),
|
||||
patch.object(sync_service.entity_repository, "update", side_effect=mock_update),
|
||||
):
|
||||
# Should raise ValueError when update fails
|
||||
with pytest.raises(ValueError, match="Failed to update entity with ID"):
|
||||
await sync_service.sync_regular_file(
|
||||
str(test_file.relative_to(project_config.home)), new=True
|
||||
)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12.1"
|
||||
resolution-markers = [
|
||||
"(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'",
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 'i686' and sys_platform == 'linux'",
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"platform_machine == 'armv7l' and sys_platform == 'linux'",
|
||||
"platform_machine == 'ppc64le' and sys_platform == 'linux'",
|
||||
"platform_machine == 's390x' and sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
@@ -101,10 +92,10 @@ dependencies = [
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest-aio" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-frontmatter" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qasync" },
|
||||
{ name = "rich" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typer" },
|
||||
@@ -114,14 +105,13 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "cx-freeze" },
|
||||
{ name = "gevent" },
|
||||
{ name = "icecream" },
|
||||
{ name = "pyqt6" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -143,10 +133,10 @@ requires-dist = [
|
||||
{ name = "pydantic-settings", specifier = ">=2.6.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pyright", specifier = ">=1.1.390" },
|
||||
{ name = "pytest-aio", specifier = ">=1.9.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||
{ name = "python-frontmatter", specifier = ">=1.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.1" },
|
||||
{ name = "qasync", specifier = ">=0.27.1" },
|
||||
{ name = "rich", specifier = ">=13.9.4" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
@@ -156,26 +146,16 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "cx-freeze", specifier = ">=7.2.10" },
|
||||
{ name = "gevent", specifier = ">=24.11.1" },
|
||||
{ name = "icecream", specifier = ">=2.1.3" },
|
||||
{ name = "pyqt6", specifier = ">=6.8.1" },
|
||||
{ name = "pytest", specifier = ">=8.3.4" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-mock", specifier = ">=3.12.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.1.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cabarchive"
|
||||
version = "0.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/d3/a544aed878edc269ce4427bc937310b73624e1d595de7f4e5bcab413a639/cabarchive-0.2.4.tar.gz", hash = "sha256:04f60089473114cf26eab2b7e1d09611c5bfaf8edd3202dacef66bb5c71e48cf", size = 21064, upload-time = "2022-02-23T09:28:10.911Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/fb/713421f46c68f4bf9cd26f05bda0c233446108997b6b4d83d7ef07f20009/cabarchive-0.2.4-py3-none-any.whl", hash = "sha256:4afabd224eb2e40af8e907379fb8eec6b0adfb71c2aef4457ec3a4d77383c059", size = 25729, upload-time = "2022-02-23T09:28:09.571Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
@@ -316,58 +296,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cx-freeze"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cabarchive", marker = "sys_platform == 'win32'" },
|
||||
{ name = "cx-logging", marker = "platform_machine != 'ARM64' and sys_platform == 'win32'" },
|
||||
{ name = "dmgbuild", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "filelock" },
|
||||
{ name = "lief", marker = "platform_machine != 'ARM64' and sys_platform == 'win32'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "patchelf", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'armv7l' and sys_platform == 'linux') or (platform_machine == 'i686' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 's390x' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "striprtf", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/fa/835edcb0bbfffc09bea4a723c26779e3691513c6bfd41dc92498289218be/cx_freeze-8.3.0.tar.gz", hash = "sha256:491998d513f04841ec7967e2a3792db198597bde8a0c9333706b1f96060bdb35", size = 3180070, upload-time = "2025-05-12T00:18:41.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d6/4c66e670768cdc8219bbd5e3efd96a25506f16e83b599004ffae0828e6b0/cx_freeze-8.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3d6f158ad36170caad12a4aae5b65ed4fdf8d772c60c2dad8bf9341a1fc8b4c6", size = 21986587, upload-time = "2025-05-12T00:17:41.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/97/ddd0daa6de5da6d142a77095d66c8466442f0f8721c6eaa52b63bdbbb29a/cx_freeze-8.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abdba6a199dbd3a2ac661ec25160aceffcb94f3508757dd13639dca1fc82572", size = 14439323, upload-time = "2025-05-12T00:17:43.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/0b/b4cf3e7dffd1a4fa6aa80b26af6b21d0b6dafff56495003639eebdc9a9ba/cx_freeze-8.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdd7da34aeb55332d7ed9a5dd75a6a5b8a007a28458d79d0acad2611c5162e55", size = 15943470, upload-time = "2025-05-12T00:17:46.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/b5/21dfa6fd4580bed578e22f4be2f42d585d1e064f1b58fc2321477030414e/cx_freeze-8.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95d0460511a295f65f25e537cd1e716013868f5cab944a20fc77f5e9c3425ec6", size = 14576320, upload-time = "2025-05-12T00:17:49.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/08/76270e82bff702edd584e252239c1ab92e1807cf5ca2efafd0c69a948775/cx_freeze-8.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c661650119ceb4c2c779134d4a34823b63c8bea5c5686c33a013cd374f3763c3", size = 15600098, upload-time = "2025-05-12T00:17:51.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/4da11732f32ed51f2b734caa3fe87559734f68f508ce54b56196ae1c4410/cx_freeze-8.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56e52892393562a00792635bb8ab6d5720290b7b86ae21b6eb002a610fac5713", size = 15382203, upload-time = "2025-05-12T00:17:54.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1a/64c825770df0b9cb69e5f15c2647e708bf8e13f55da1011749658bc83c37/cx_freeze-8.3.0-cp312-cp312-win32.whl", hash = "sha256:3bad93b5e44c9faee254b0b27a1698c053b569122e73a32858b8e80e340aa8f2", size = 2336981, upload-time = "2025-05-12T00:17:57.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/68/09458532149bcb26bbc078ed232c2f970476d6381045ce76de32ef6014c2/cx_freeze-8.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:82887045c831e5c03f4a33f8baab826b785c6400493a077c482cc45c15fd531c", size = 2341781, upload-time = "2025-05-12T00:17:59.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fe/ebe723ade801df8f1030d90b9b676efd43bbf12ca833bb4b82108101ed8e/cx_freeze-8.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:72b9d7e3e98bbc175096b66e67208aea5b2e283f07e3d826c40f89f60a821ae1", size = 2329301, upload-time = "2025-05-12T00:18:00.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ba/a98447964bde34e93774ff500c2efcd0dce150754e835c32bbf11754ee92/cx_freeze-8.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5ab5f97a3719282b9105b4d5eacd9b669f79d8e0129e20a55137746663d288ad", size = 21407613, upload-time = "2025-05-12T00:18:02.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/df/ba05eba858fa33bfcdde589d4b22333ff1444f42ff66e88ad98133105126/cx_freeze-8.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a27d8af666b7ef4a8fa612591b5555c57d564f4f17861bdd11e0bd050a33b592", size = 12443001, upload-time = "2025-05-12T00:18:05.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/da/a97fbb2ee9fb958aca527a9a018a98e8127f0b43c4fb09323d2cdbc4ec94/cx_freeze-8.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35ee2d0de99dea99156507a63722a5eefacbc492d2bf582978a6dbb3fecc972b", size = 12559468, upload-time = "2025-05-12T00:18:08.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/22/5e1c967e4c8bd129f0fe5d94b0f653bf7709fde251c2dc77f6c5da097163/cx_freeze-8.3.0-cp313-cp313-win32.whl", hash = "sha256:c19b092980e3430a963d328432763742baf852d3ff5fef096b2f32e130cfc0ed", size = 2333521, upload-time = "2025-05-12T00:18:10.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/61/18c51dfb8bfcd36619c9314d36168c5254d0ce6d40f70fe1ace55edd1991/cx_freeze-8.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:007fb9507b5265c0922aaea10173651a2138b3d75ee9a67156fea4c9fb2b2582", size = 2337819, upload-time = "2025-05-12T00:18:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4b/53a5c7d44e482edadba39f7c62e8cafbc22a699f79230aa7bcb23257c12c/cx_freeze-8.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:bab3634e91c09f235a40b998a9b23327625c9032014c2a9365aa3e8c5f6b5a05", size = 2326957, upload-time = "2025-05-12T00:18:13.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/dd/dce38e545203c7ef14bf9c9c2beb1d05093f7b1d7c95ca03ff716c920413/cx_freeze-8.3.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:061c81fcff963d0735ff3a85abb9ca9d29d3663ce8eeef6b663bd93ecafb93bb", size = 21209751, upload-time = "2025-05-12T00:18:15.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fc/82153be6a3e7e6ad9d2baa1453f5e6c6e744f711f12284d50daa95c63e30/cx_freeze-8.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0db71e7c540b0b95396e4c1c18af2748d96c2c2e44142a0e65bb8925f736cc6", size = 12657585, upload-time = "2025-05-12T00:18:19.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a3/9d72b12ab11a89ef84e3c03d5290b3b58dd5c3427e6d6f5597c776e01ab8/cx_freeze-8.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca2eb036fffd7fc07e793989db4424557d9b00c7b82e33f575dbc40d72f52f7b", size = 13887006, upload-time = "2025-05-12T00:18:22.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ab/08a5aa1744a708de8ff4bc9c6edd6addc5effdb6c31a85ff425284e4563f/cx_freeze-8.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a58582c34ccfc94e9e19acc784511396e95c324bb54c5454b7eafec5a205c677", size = 12738066, upload-time = "2025-05-12T00:18:25.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/59/86beaf28c76921f338a2799295ab50766737064920d5182d238eff8578c7/cx_freeze-8.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c41676ebf3e5ca7dd086dedf3a9d5b5627f3c98ffccf64db0aeebd5102199b05", size = 13642689, upload-time = "2025-05-12T00:18:27.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/bb/0b6992fb528dca772f83ab5534ce00e43f978d7ac393bab5d3e2553fb7a9/cx_freeze-8.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ae0cfb83bc82671c4701a36954c5e8c5cf9440777365b78e9ceba51522becd40", size = 13322215, upload-time = "2025-05-12T00:18:30.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cx-logging"
|
||||
version = "3.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/69/50b0c38e26658072b0221f1ea243c47dd56a9f3f50e5754aa5a39189145c/cx_logging-3.2.1.tar.gz", hash = "sha256:812665ae5012680a6fe47095c3772bce638e47cf05b2c3483db3bdbe6b06da44", size = 26966, upload-time = "2024-10-13T03:13:10.561Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9b/d8babcfafa7233b862b310a6fe630fc5e6ced02453ca4e60b0c819afbaff/cx_Logging-3.2.1-cp312-cp312-win32.whl", hash = "sha256:3f3de06cf09d5986b39e930c213567c340b3237dfce03d8d3bf6099475eaa02e", size = 22869, upload-time = "2024-10-13T03:13:28.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/52/b6bd4f4d51eb4f3523da182cdf5969a560e35f4ef178f34841ba6795addc/cx_Logging-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3452add0544db6ff29116b72a4c48761aaffa9b638728330433853c0c4ad2ea1", size = 26911, upload-time = "2024-10-13T03:13:29.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/78/0ce28b89aedf369b02bb5cb763324e799844144386fba75c03128ea9e2ff/cx_Logging-3.2.1-cp313-cp313-win32.whl", hash = "sha256:330a29030bdca8795c99b678b4f6d87a75fb606eed1da206fdd9fa579a33dc21", size = 22874, upload-time = "2024-10-13T03:13:32.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/23/dab5f561888951ec02843f087f34a59c791e8ac6423c25a412eb49300633/cx_Logging-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e14748b031522a95aa2db4adfc5f2be5f96f4d0fe687da591114f73a09e66926", size = 26916, upload-time = "2024-10-13T03:13:34.085Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.2.1"
|
||||
@@ -383,19 +311,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658, upload-time = "2025-02-05T12:34:53.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dmgbuild"
|
||||
version = "1.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ds-store", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/93/b9702c68d5dedfd6b91c76268a89091ff681b8e3b9a026e7919b6ab730a4/dmgbuild-1.6.5.tar.gz", hash = "sha256:c5cbeec574bad84a324348aa7c36d4aada04568c99fb104dec18d22ba3259f45", size = 36848, upload-time = "2025-03-21T01:04:10.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/4a/b16f1081f69592c6dba92baa4d3ca7a5685091a0f840f4b5e01be41aaf84/dmgbuild-1.6.5-py3-none-any.whl", hash = "sha256:e19ab8c5e8238e6455d9ccb9175817be7fd62b9cdd1eef20f63dd88e0ec469ab", size = 34906, upload-time = "2025-03-21T01:04:08.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
@@ -405,18 +320,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ds-store"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/36/902259bf7ddb142dd91cf7a9794aa15e1a8ab985974f90375e5d3463b441/ds_store-1.3.1.tar.gz", hash = "sha256:c27d413caf13c19acb85d75da4752673f1f38267f9eb6ba81b3b5aa99c2d207c", size = 27052, upload-time = "2022-11-24T06:13:34.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bf/b1c10362a0d670ee8ae086d92c3ab795fca2a927e4ff25e7cd15224d3863/ds_store-1.3.1-py3-none-any.whl", hash = "sha256:fbacbb0bd5193ab3e66e5a47fff63619f15e374ffbec8ae29744251a6c8f05b5", size = 16268, upload-time = "2022-11-24T06:13:30.797Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.2.0"
|
||||
@@ -442,6 +345,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "executing"
|
||||
version = "2.2.0"
|
||||
@@ -496,7 +408,7 @@ standard = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.6.1"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
@@ -507,20 +419,10 @@ dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/d2/5bd3cf09b63e51c3e10ece07a1031cfe362cf080bb0155e6b894414301b5/fastmcp-2.6.1.tar.gz", hash = "sha256:212f15a4edf8289e5c3c70796910dc612ef891f84df3257a277457bb761d1362", size = 1585121, upload-time = "2025-06-03T13:31:14.354Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/69/8820d3c0e17ed2c7baed3e322191509285fc724c60f9cac5b28037feb5c9/fastmcp-2.7.1.tar.gz", hash = "sha256:489b8480a3e3a96b9eb1847e77f0272b732ad397b2ddad3a25eb185cc99b6c9c", size = 1591616, upload-time = "2025-06-08T01:50:02.349Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/33/b452a453f62e9b7f6b3f0f263dffb2fa155bc1cab3c69902f7d32b11d9d1/fastmcp-2.6.1-py3-none-any.whl", hash = "sha256:d83a2fcffa721cbb91b29c738d39de20b54e1b1ebef5be652d6a4956ecac7ad3", size = 125910, upload-time = "2025-06-03T13:31:12.315Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/b8/af0bb06d1388b680c64ec7b9767d3718e51e65d91e425c1296446f10a9fc/fastmcp-2.7.1-py3-none-any.whl", hash = "sha256:e75b4c7088338f2532d79f37a2ae654f47bfd7d3d15340233fda25bc168231b6", size = 127618, upload-time = "2025-06-08T01:50:00.945Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -556,36 +458,35 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.2"
|
||||
version = "3.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/c1/a82edae11d46c0d83481aacaa1e578fea21d94a1ef400afd734d47ad95ad/greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485", size = 185797, upload-time = "2025-05-09T19:47:35.066Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413, upload-time = "2025-05-09T14:51:32.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242, upload-time = "2025-05-09T15:24:02.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444, upload-time = "2025-05-09T15:24:49.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067, upload-time = "2025-05-09T15:29:24.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153, upload-time = "2025-05-09T14:53:34.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865, upload-time = "2025-05-09T14:53:45.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575, upload-time = "2025-05-09T15:27:04.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/05/356813470060bce0e81c3df63ab8cd1967c1ff6f5189760c1a4734d405ba/greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5", size = 1147460, upload-time = "2025-05-09T14:54:00.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239, upload-time = "2025-05-09T14:57:17.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150, upload-time = "2025-05-09T14:50:30.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381, upload-time = "2025-05-09T15:24:12.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427, upload-time = "2025-05-09T15:24:51.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/49/6d79f58fa695b618654adac64e56aff2eeb13344dc28259af8f505662bb1/greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5", size = 645795, upload-time = "2025-05-09T15:29:26.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398, upload-time = "2025-05-09T14:53:36.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795, upload-time = "2025-05-09T14:53:47.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976, upload-time = "2025-05-09T15:27:06.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/30/d0e88c1cfcc1b3331d63c2b54a0a3a4a950ef202fb8b92e772ca714a9221/greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708", size = 1145509, upload-time = "2025-05-09T14:54:02.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023, upload-time = "2025-05-09T14:53:24.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911, upload-time = "2025-05-09T15:24:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251, upload-time = "2025-05-09T15:24:52.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/26/7db30868f73e86b9125264d2959acabea132b444b88185ba5c462cb8e571/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763", size = 632620, upload-time = "2025-05-09T15:29:28.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851, upload-time = "2025-05-09T14:53:38.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718, upload-time = "2025-05-09T14:53:48.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752, upload-time = "2025-05-09T15:27:08.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/3b/3b97f9d33c1f2eb081759da62bd6162159db260f602f048bc2f36b4c453e/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51", size = 1125170, upload-time = "2025-05-09T14:54:04.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899, upload-time = "2025-05-09T14:54:01.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload-time = "2025-06-05T16:48:19.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload-time = "2025-06-05T16:12:40.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload-time = "2025-06-05T16:29:49.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -701,19 +602,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lief"
|
||||
version = "0.16.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/68/c7df68afe1c37be667f1adb74544b06316fd1338dd577fd0c1289817d2d1/lief-0.16.5-cp312-cp312-win32.whl", hash = "sha256:768f91db886432c4b257fb88365a2c6842f26190b73964cf9274c276bc17b490", size = 3049882, upload-time = "2025-04-19T16:51:53.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/8b/0fdc6b420e24df7c8cc02be595c425e821f2d4eb1be98eb16a7cf4e87fd0/lief-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:587225fd6e1ec424a1a776928beb67095894254c51148b78903844d62faa1a2d", size = 3178830, upload-time = "2025-04-19T16:51:55.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/a6/f751d12b88527b591f26a7c4a2b896806c065d9bdfb49eaabec9e6aead41/lief-0.16.5-cp312-cp312-win_arm64.whl", hash = "sha256:ef043c1796d221f128597dc32819fa6bb31da26d2a9b911a32d4a5cdfb566f85", size = 3066592, upload-time = "2025-04-19T16:51:57.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/72fe8e8bfbfea9d76350635965f668e855490c6f2779c08bf1b9ab3a505d/lief-0.16.5-cp313-cp313-win32.whl", hash = "sha256:6fc879c1c90bf31f7720ece90bd919cbfeeb3bdbc9327f6a16d4dc1af273aef9", size = 3049849, upload-time = "2025-04-19T16:52:11.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/fc/6faf93a5b44f9e7df193e9fc95b93a7f34b2155b1b470ef61f2f25704a84/lief-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:2f208359d10ade57ace7f7625e2f5e4ca214b4b67f9ade24ca07dafb08e37b0c", size = 3178645, upload-time = "2025-04-19T16:52:13.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/47/d0a47b6856d832a2ab0896faa773b4506b41e39131684892017351e8ff28/lief-0.16.5-cp313-cp313-win_arm64.whl", hash = "sha256:afb7d946aa2b62c95831d3be45f2516324418335b077f5337012b779e8dcc97b", size = 3066502, upload-time = "2025-04-19T16:52:14.787Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
@@ -727,15 +615,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac-alias"
|
||||
version = "2.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/a3/83b50f620d318a98363dc7e701fb94856eaaecc472e23a89ac625697b3ea/mac_alias-2.2.2.tar.gz", hash = "sha256:c99c728eb512e955c11f1a6203a0ffa8883b26549e8afe68804031aa5da856b7", size = 34073, upload-time = "2022-12-06T00:37:47.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/a1/4136777ed6a56df83e7c748ad28892f0672cbbcdc3b3d15a57df6ba72443/mac_alias-2.2.2-py3-none-any.whl", hash = "sha256:504ab8ac546f35bbd75ad014d6ad977c426660aa721f2cd3acf3dc2f664141bd", size = 21220, upload-time = "2022-12-06T00:37:46.025Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
@@ -800,7 +679,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.9.2"
|
||||
version = "1.9.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -813,9 +692,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/03/77c49cce3ace96e6787af624611b627b2828f0dca0f8df6f330a10eea51e/mcp-1.9.2.tar.gz", hash = "sha256:3c7651c053d635fd235990a12e84509fe32780cd359a5bbef352e20d4d963c05", size = 333066, upload-time = "2025-05-29T14:42:17.76Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/df/8fefc0c6c7a5c66914763e3ff3893f9a03435628f6625d5e3b0dc45d73db/mcp-1.9.3.tar.gz", hash = "sha256:587ba38448e81885e5d1b84055cfcc0ca56d35cd0c58f50941cab01109405388", size = 333045, upload-time = "2025-06-05T15:48:25.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a6/8f5ee9da9f67c0fd8933f63d6105f02eabdac8a8c0926728368ffbb6744d/mcp-1.9.2-py3-none-any.whl", hash = "sha256:bc29f7fd67d157fef378f89a4210384f5fecf1168d0feb12d22929818723f978", size = 131083, upload-time = "2025-05-29T14:42:16.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/45/823ad05504bea55cb0feb7470387f151252127ad5c72f8882e8fe6cf5c0e/mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9", size = 131063, upload-time = "2025-06-05T15:48:24.171Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -857,20 +736,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "patchelf"
|
||||
version = "0.17.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/41/dc3ee5838db2d90be935adb53ae7745135d9c719d070b1989b246f983c7f/patchelf-0.17.2.2.tar.gz", hash = "sha256:080b2ac3074fd4ab257700088e82470425e56609aa0dd07abe548f04b7b3b007", size = 149517, upload-time = "2025-03-16T08:30:21.909Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/25b5d10d971f509fe6bc8951b855f0f05be4c24e0dd1616c14a6e1a9116a/patchelf-0.17.2.2-py3-none-manylinux1_i686.manylinux_2_5_i686.musllinux_1_1_i686.whl", hash = "sha256:3b8a4d7cccac04d8231dec321245611bf147b199cbf4da305d1a364ff689fb58", size = 524182, upload-time = "2025-03-16T08:30:11.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f9/e070956e350ccdfdf059251836f757ad91ac0c01b0ba3e033ea7188d8d42/patchelf-0.17.2.2-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:e334ebb1c5aa9fc740fd95ebe449271899fe1e45a3eb0941300b304f7e3d1299", size = 466519, upload-time = "2025-03-16T08:30:13.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/0d/dc3ac6c6e9e9d0d3e40bee1abe95a07034f83627319e60a7dc9abdbfafee/patchelf-0.17.2.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3d32cd69442a229724f7f071b61cef1f87ccd80cf755af0b1ecefd553fa9ae3f", size = 462123, upload-time = "2025-03-16T08:30:15.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f6/b842b19c2b72df1c524ab3793c3ec9cf3926c7c841e0b64b34f95d7fb806/patchelf-0.17.2.2-py3-none-manylinux2014_armv7l.manylinux_2_17_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:05f6bbdbe484439cb025e20c60abd37e432e6798dfa3f39a072e6b7499072a8c", size = 412347, upload-time = "2025-03-16T08:30:17.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0b/33eb3087703d903dd01cf6b0d64e067bf3718a5e8b1239bc6fc2c4b1fdb2/patchelf-0.17.2.2-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:b54e79ceb444ec6a536a5dc2e8fc9c771ec6a1fa7d5f4dbb3dc0e5b8e5ff82e1", size = 522827, upload-time = "2025-03-16T08:30:18.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/25/6379dc26714b5a40f51b3c7927d668b00a51517e857da7f3cb09d1d0bcb6/patchelf-0.17.2.2-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.musllinux_1_1_s390x.whl", hash = "sha256:24374cdbd9a072230339024fb6922577cb3231396640610b069f678bc483f21e", size = 565961, upload-time = "2025-03-16T08:30:20.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.2.1"
|
||||
@@ -1042,54 +907,6 @@ version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/af/409edba35fc597f1e386e3860303791ab5a28d6cc9a8aecbc567051b19a9/PyMeta3-0.5.1.tar.gz", hash = "sha256:18bda326d9a9bbf587bfc0ee0bc96864964d78b067288bcf55d4d98681d05bcb", size = 29566, upload-time = "2015-02-22T16:30:06.858Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6"
|
||||
version = "6.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyqt6-qt6" },
|
||||
{ name = "pyqt6-sip" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/de/102e8e66149085acf38bbf01df572a2cd53259bcd99b7d8ecef0d6b36172/pyqt6-6.9.0.tar.gz", hash = "sha256:6a8ff8e3cd18311bb7d937f7d741e787040ae7ff47ce751c28a94c5cddc1b4e6", size = 1066831, upload-time = "2025-04-08T09:00:46.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e5/f9e2b5326d6103bce4894a969be54ce3be4b0a7a6ff848228e6a61a9993f/PyQt6-6.9.0-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:5344240747e81bde1a4e0e98d4e6e2d96ad56a985d8f36b69cd529c1ca9ff760", size = 12257215, upload-time = "2025-04-08T09:00:37.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/3a/bcc7687c5a11079bbd1606a015514562f2ac8cb01c5e3e4a3b30fcbdad36/PyQt6-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e344868228c71fc89a0edeb325497df4ff731a89cfa5fe57a9a4e9baecc9512b", size = 8259731, upload-time = "2025-04-08T09:00:40.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/47/13ab0b916b5bad07ab04767b412043f5c1ca206bf38a906b1d8d5c520a98/PyQt6-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1cbc5a282454cf19691be09eadbde019783f1ae0523e269b211b0173b67373f6", size = 8207593, upload-time = "2025-04-08T09:00:42.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/955cfd880f2725a218ee7b272c005658e857e9224823d49c32c93517f6d9/PyQt6-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:d36482000f0cd7ce84a35863766f88a5e671233d5f1024656b600cd8915b3752", size = 6748279, upload-time = "2025-04-08T09:00:43.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/38/586ce139b1673a27607f7b85c594878e1bba215abdca3de67732b463f7b2/PyQt6-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:0c8b7251608e05b479cfe731f95857e853067459f7cbbcfe90f89de1bcf04280", size = 5478122, upload-time = "2025-04-08T09:00:45.296Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6-qt6"
|
||||
version = "6.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/11/8c450442bf4702ed810689a045f9c5d9236d709163886f09374fd8d84143/PyQt6_Qt6-6.9.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:b1c4e4a78f0f22fbf88556e3d07c99e5ce93032feae5c1e575958d914612e0f9", size = 66804297, upload-time = "2025-04-08T08:51:42.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/be/191ba4402c24646f6b98c326ff0ee22e820096c69e67ba5860a687057616/PyQt6_Qt6-6.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d3875119dec6bf5f799facea362aa0ad39bb23aa9654112faa92477abccb5ff", size = 60943708, upload-time = "2025-04-08T08:51:48.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/70/ec018b6e979b3914c984e5ab7e130918930d5423735ac96c70c328227b9b/PyQt6_Qt6-6.9.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9c0e603c934e4f130c110190fbf2c482ff1221a58317266570678bc02db6b152", size = 81846956, upload-time = "2025-04-08T08:51:54.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ed/2d78cd08be415a21dac2e7277967b90b0c05afc4782100f0a037447bb1c6/PyQt6_Qt6-6.9.0-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:cf840e8ae20a0704e0343810cf0e485552db28bf09ea976e58ec0e9b7bb27fcd", size = 80295982, upload-time = "2025-04-08T08:52:00.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/24/6b6168a75c7b6a55b9f6b5c897e6164ec15e94594af11a6f358c49845442/PyQt6_Qt6-6.9.0-py3-none-win_amd64.whl", hash = "sha256:c825a6f5a9875ef04ef6681eda16aa3a9e9ad71847aa78dfafcf388c8007aa0a", size = 73652485, upload-time = "2025-04-08T08:52:07.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/fd/1238931df039e46e128d53974c0cfc9d34da3d54c5662bd589fe7b0a67c2/PyQt6_Qt6-6.9.0-py3-none-win_arm64.whl", hash = "sha256:1188f118d1c570d27fba39707e3d8a48525f979816e73de0da55b9e6fa9ad0a1", size = 49568913, upload-time = "2025-04-08T08:52:12.587Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6-sip"
|
||||
version = "13.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/4a/96daf6c2e4f689faae9bd8cebb52754e76522c58a6af9b5ec86a2e8ec8b4/pyqt6_sip-13.10.2.tar.gz", hash = "sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe", size = 92548, upload-time = "2025-05-23T12:26:49.901Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5b/1240017e0d59575289ba52b58fd7f95e7ddf0ed2ede95f3f7e2dc845d337/pyqt6_sip-13.10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c", size = 112199, upload-time = "2025-05-23T12:26:32.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/11/1fc3bae02a12a3ac8354aa579b56206286e8b5ca9586677b1058c81c2f74/pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6", size = 322757, upload-time = "2025-05-23T12:26:33.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/40/de9491213f480a27199690616959a17a0f234962b86aa1dd4ca2584e922d/pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7", size = 304251, upload-time = "2025-05-23T12:26:35.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/21/cc80e03f1052408c62c341e9fe9b81454c94184f4bd8a95d29d2ec86df92/pyqt6_sip-13.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e", size = 53519, upload-time = "2025-05-23T12:26:36.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/cf/53bd0863252b260a502659cb3124d9c9fe38047df9360e529b437b4ac890/pyqt6_sip-13.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92", size = 45349, upload-time = "2025-05-23T12:26:37.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/1e/979ea64c98ca26979d8ce11e9a36579e17d22a71f51d7366d6eec3c82c13/pyqt6_sip-13.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1", size = 112227, upload-time = "2025-05-23T12:26:38.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/21/84c230048e3bfef4a9209d16e56dcd2ae10590d03a31556ae8b5f1dcc724/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca", size = 322920, upload-time = "2025-05-23T12:26:39.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/c6a28a142f14e735088534cc92951c3f48cccd77cdd4f3b10d7996be420f/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b", size = 303833, upload-time = "2025-05-23T12:26:41.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/63/e5adf350c1c3123d4865c013f164c5265512fa79f09ad464fb2fdf9f9e61/pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277", size = 53527, upload-time = "2025-05-23T12:26:42.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/74/2df4195306d050fbf4963fb5636108a66e5afa6dc05fd9e81e51ec96c384/pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244", size = 45373, upload-time = "2025-05-23T12:26:43.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.401"
|
||||
@@ -1119,6 +936,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-aio"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/26/1eaef5fd99c7e66fbf0cf9d774e3055268328f58b22262d39feb73bbd185/pytest_aio-1.9.0.tar.gz", hash = "sha256:aa72e6ca4672b7f5a08ce44e7c6254dca988d3d578bf0c9485a47c3bff393ac1", size = 5702, upload-time = "2024-07-31T12:42:23.016Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/85/f58b1fb37f4a4e78af6ee6900b5351c97671a63a88aa5e09d45d9c32c430/pytest_aio-1.9.0-py3-none-any.whl", hash = "sha256:12a72816224863d402921b325086b398df8a0f4ca767639968a8097d762ac548", size = 6605, upload-time = "2024-07-31T12:42:22.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.0.0"
|
||||
@@ -1156,6 +985,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "execnet" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/865845cfe987b21658e871d16e0a24e871e00884c545f246dd8f6f69edda/pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126", size = 87550, upload-time = "2025-05-26T21:18:20.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/b2/0e802fde6f1c5b2f7ae7e9ad42b83fd4ecebac18a8a8c2f2f14e39dce6e1/pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0", size = 46142, upload-time = "2025-05-26T21:18:18.759Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -1233,15 +1075,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qasync"
|
||||
version = "0.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/e0/7c7c973f52e1765d6ddfc41e9272294f65d5d52b8f5f5eae92adf411ad46/qasync-0.27.1.tar.gz", hash = "sha256:8dc768fd1ee5de1044c7c305eccf2d39d24d87803ea71189d4024fb475f4985f", size = 14287, upload-time = "2023-11-19T14:19:55.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/06/bc628aa2981bcfd452a08ee435b812fd3eee4ada8acb8a76c4a09d1a5a77/qasync-0.27.1-py3-none-any.whl", hash = "sha256:5d57335723bc7d9b328dadd8cb2ed7978640e4bf2da184889ce50ee3ad2602c7", size = 14866, upload-time = "2023-11-19T14:19:54.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2024.11.6"
|
||||
@@ -1309,36 +1142,36 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.12"
|
||||
version = "0.11.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/0a/92416b159ec00cdf11e5882a9d80d29bf84bba3dbebc51c4898bfbca1da6/ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603", size = 4202289, upload-time = "2025-05-29T13:31:40.037Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/cc/53eb79f012d15e136d40a8e8fc519ba8f55a057f60b29c2df34efd47c6e3/ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc", size = 10285597, upload-time = "2025-05-29T13:30:57.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d7/73386e9fb0232b015a23f62fea7503f96e29c29e6c45461d4a73bac74df9/ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3", size = 11053154, upload-time = "2025-05-29T13:31:00.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/eb/3eae144c5114e92deb65a0cb2c72326c8469e14991e9bc3ec0349da1331c/ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa", size = 10403048, upload-time = "2025-05-29T13:31:03.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/64/20c54b20e58b1058db6689e94731f2a22e9f7abab74e1a758dfba058b6ca/ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012", size = 10597062, upload-time = "2025-05-29T13:31:05.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3a/79fa6a9a39422a400564ca7233a689a151f1039110f0bbbabcb38106883a/ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a", size = 10155152, upload-time = "2025-05-29T13:31:07.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/a4/22c2c97b2340aa968af3a39bc38045e78d36abd4ed3fa2bde91c31e712e3/ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7", size = 11723067, upload-time = "2025-05-29T13:31:10.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/cf/3e452fbd9597bcd8058856ecd42b22751749d07935793a1856d988154151/ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a", size = 12460807, upload-time = "2025-05-29T13:31:12.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/ec/8f170381a15e1eb7d93cb4feef8d17334d5a1eb33fee273aee5d1f8241a3/ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13", size = 12063261, upload-time = "2025-05-29T13:31:15.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/57208f8c0a8153a14652a85f4116c0002148e83770d7a41f2e90b52d2b4e/ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be", size = 11329601, upload-time = "2025-05-29T13:31:18.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/56/edf942f7fdac5888094d9ffa303f12096f1a93eb46570bcf5f14c0c70880/ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd", size = 11522186, upload-time = "2025-05-29T13:31:21.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/63/79ffef65246911ed7e2290aeece48739d9603b3a35f9529fec0fc6c26400/ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef", size = 10449032, upload-time = "2025-05-29T13:31:23.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/19/8c9d4d8a1c2a3f5a1ea45a64b42593d50e28b8e038f1aafd65d6b43647f3/ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5", size = 10129370, upload-time = "2025-05-29T13:31:25.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/0f/2d15533eaa18f460530a857e1778900cd867ded67f16c85723569d54e410/ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02", size = 11123529, upload-time = "2025-05-29T13:31:28.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/e2/4c2ac669534bdded835356813f48ea33cfb3a947dc47f270038364587088/ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c", size = 11577642, upload-time = "2025-05-29T13:31:30.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9b/c9ddf7f924d5617a1c94a93ba595f4b24cb5bc50e98b94433ab3f7ad27e5/ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6", size = 10475511, upload-time = "2025-05-29T13:31:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/74fb6d3470c1aada019ffff33c0f9210af746cca0a4de19a1f10ce54968a/ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832", size = 11523573, upload-time = "2025-05-29T13:31:35.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.4.0"
|
||||
version = "80.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/0cc40fe41fd2adb80a2f388987f4f8db3c866c69e33e0b4c8b093fdf700e/setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006", size = 1315008, upload-time = "2025-05-09T20:42:27.972Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/93/dba5ed08c2e31ec7cdc2ce75705a484ef0be1a2fecac8a58272489349de8/setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2", size = 1200812, upload-time = "2025-05-09T20:42:25.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1421,15 +1254,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "striprtf"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533, upload-time = "2025-03-27T22:55:56.874Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879, upload-time = "2025-03-27T22:55:55.977Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
|
||||
Reference in New Issue
Block a user