diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 000000000..af46b2973 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,53 @@ +name: Dev Release + +on: + push: + branches: [main] + workflow_dispatch: # Allow manual triggering + +jobs: + dev-release: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + run: | + pip install uv + + - name: Install dependencies and build + run: | + uv venv + uv sync + uv build + + - name: Check if this is a dev version + id: check_version + run: | + VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)") + echo "version=$VERSION" >> $GITHUB_OUTPUT + if [[ "$VERSION" == *"dev"* ]]; then + echo "is_dev=true" >> $GITHUB_OUTPUT + echo "Dev version detected: $VERSION" + else + echo "is_dev=false" >> $GITHUB_OUTPUT + echo "Release version detected: $VERSION, skipping dev release" + fi + + - name: Publish dev version to PyPI + if: steps.check_version.outputs.is_dev == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} + skip-existing: true # Don't fail if version already exists \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bb768d80..332aff576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,96 +1,60 @@ name: Release on: - workflow_dispatch: - inputs: - version_type: - description: 'Type of version bump (major, minor, patch)' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major + push: + tags: + - 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc. jobs: release: runs-on: ubuntu-latest - concurrency: release permissions: id-token: write contents: write - outputs: - released: ${{ steps.release.outputs.released }} - tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Python Semantic Release - id: release - uses: python-semantic-release/python-semantic-release@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - if: steps.release.outputs.released == 'true' - with: - password: ${{ secrets.PYPI_TOKEN }} - - - name: Publish to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.8.9 - if: steps.release.outputs.released == 'true' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - tag: ${{ steps.release.outputs.tag }} - - build-macos: - needs: release - if: needs.release.outputs.released == 'true' - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.release.outputs.tag }} - - - name: Set up Python "3.12" - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: "3.12" - cache: 'pip' - - - name: Install librsvg - run: brew install librsvg - name: Install uv run: | pip install uv - - name: Create virtual env + - name: Install dependencies and build run: | uv venv - - - name: Install dependencies - run: | uv sync + uv build - - name: Build macOS installer - run: | - make installer-mac - xattr -dr com.apple.quarantine "installer/build/Basic Memory Installer.app" - - - name: Zip macOS installer + - name: Verify version matches tag run: | - cd installer/build - zip -ry "Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip" "Basic Memory Installer.app" - - - name: Upload macOS installer - uses: softprops/action-gh-release@v1 + # 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 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 with: - files: installer/build/Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip - tag_name: ${{ needs.release.outputs.tag }} + files: | + dist/*.whl + dist/*.tar.gz + generate_release_notes: true + tag_name: ${{ github.ref_name }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..6d6cad668 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "basic-memory": { + "command": "uv", + "args": [ + "--directory", + "/Users/phernandez/dev/basicmachines/basic-memory", + "run", + "src/basic_memory/cli/main.py", + "mcp" + ] + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 534c82f04..064ea893d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,4 +187,38 @@ Basic Memory Pro is a desktop GUI application that wraps the basic-memory CLI/MC - Multiple project support with visual switching interface local repo: /Users/phernandez/dev/basicmachines/basic-memory-pro -github: https://github.com/basicmachines-co/basic-memory-pro \ No newline at end of file +github: https://github.com/basicmachines-co/basic-memory-pro + +## Release and Version Management + +Basic Memory uses `uv-dynamic-versioning` for automatic version management based on git tags: + +### Version Types +- **Development versions**: Automatically generated from commits (e.g., `0.12.4.dev26+468a22f`) +- **Beta releases**: Created by tagging with beta suffixes (e.g., `v0.13.0b1`, `v0.13.0rc1`) +- **Stable releases**: Created by tagging with version numbers (e.g., `v0.13.0`) + +### Release Workflows + +#### Development Builds (Automatic) +- Triggered on every push to `main` branch +- Publishes dev versions like `0.12.4.dev26+468a22f` to PyPI +- Allows continuous testing of latest changes +- Users install with: `pip install basic-memory --pre --force-reinstall` + +#### Beta/RC Releases (Manual) +- Create beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1` +- Automatically builds and publishes to PyPI as pre-release +- Users install with: `pip install basic-memory --pre` +- Use for milestone testing before stable release + +#### Stable Releases (Manual) +- Create version tag: `git tag v0.13.0 && git push origin v0.13.0` +- Automatically builds, creates GitHub release, and publishes to PyPI +- Users install with: `pip install basic-memory` + +### For Development +- No manual version bumping required +- Versions automatically derived from git tags +- `pyproject.toml` uses `dynamic = ["version"]` +- `__init__.py` dynamically reads version from package metadata \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0402cec0f..5e6d1fd8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,6 +144,40 @@ agreement to the DCO. - **Database Testing**: Use in-memory SQLite for testing database operations - **Fixtures**: Use async pytest fixtures for setup and teardown +## Release Process + +Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work: + +### Version Management +- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`) +- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`) +- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`) + +### Release Workflows + +#### Development Builds +- Automatically published to PyPI on every commit to `main` +- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash) +- Users install with: `pip install basic-memory --pre --force-reinstall` + +#### Beta Releases +1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1` +2. GitHub Actions automatically builds and publishes to PyPI +3. Users install with: `pip install basic-memory --pre` + +#### Stable Releases +1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0` +2. GitHub Actions automatically: + - Builds the package with version `0.13.0` + - Creates GitHub release with auto-generated notes + - Publishes to PyPI +3. Users install with: `pip install basic-memory` + +### For Contributors +- No manual version bumping required +- Versions are automatically derived from git tags +- Focus on code changes, not version management + ## Creating Issues If you're planning to work on something, please create an issue first to discuss the approach. Include: diff --git a/EDIT_NOTE.md b/EDIT_NOTE.md new file mode 100644 index 000000000..5aa1d6fcf --- /dev/null +++ b/EDIT_NOTE.md @@ -0,0 +1,42 @@ + Looking at write_note, I can see it's a complete content replacement tool. An edit_note() tool would be really valuable for incremental changes. Here's my thinking: + + Use Cases for edit_note(): + + - Append new sections to existing notes (most common) + - Update specific information without rewriting everything + - Add observations/relations to existing content + - Fix typos or update facts + - Prepend updates like meeting notes with timestamps + + Proposed Design: + + @mcp.tool() + async def edit_note( + identifier: str, # title, permalink, or memory:// URL + operation: str, # "append", "prepend", "replace_section", "find_replace" + content: str, # content to add/replace + section: str = None, # for replace_section - header name like "## Notes" + find_text: str = None, # for find_replace + ) -> str: + + Operations: + + 1. append - Add content to end (most useful) + edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y") + 2. prepend - Add content to beginning + edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\n- Progress on feature...") + 3. replace_section - Replace content under specific header + edit_note("specs/api", "replace_section", "New API design...", section="## Implementation") + 4. find_replace - Simple text replacement + edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0") + + Implementation Flow: + + 1. Use read_note() internally to get current content + 2. Apply the specified operation + 3. Use existing PUT /knowledge/entities/{permalink} endpoint + 4. Return similar summary as write_note() + + This would be perfect for AI assistants making targeted updates without having to regenerate entire documents. The append operation alone would handle 80% of use cases. + + Want me to implement this? I'd start with just append and prepend for v0.13.0 to keep it simple. \ No newline at end of file diff --git a/FRONTMATTER_SEARCH.md b/FRONTMATTER_SEARCH.md new file mode 100644 index 000000000..a650a401b --- /dev/null +++ b/FRONTMATTER_SEARCH.md @@ -0,0 +1,186 @@ +# Frontmatter Tag Search Implementation + +## Overview + +This document outlines the implementation of frontmatter tag search functionality for Basic Memory. The goal is to enable users to search for entities based on their frontmatter tags, improving discoverability of content. + +## Current State + +### What Works +- ✅ Tags are parsed from YAML frontmatter and stored in `entity.entity_metadata` +- ✅ FTS5 search infrastructure is in place +- ✅ Observation tags are already indexed and searchable +- ✅ Search metadata structure supports additional fields + +### What's Missing +- ✅ Entity frontmatter tags are now included in search indexing (COMPLETED) +- ❌ No special tag search syntax (e.g., `tag:foo`) - Future Phase 2 + +### Example Data +Current entity metadata includes tags: +```json +{ + "title": "Business Strategy Index", + "type": "note", + "permalink": "business/business-strategy-index", + "tags": ["business", "strategy", "planning", "organization"] +} +``` + +## Implementation Plan + +### Phase 1: Basic Tag Search (v0.13.0) - LOW RISK ⭐ + +**Goal:** Make frontmatter tags searchable via regular text search + +**Approach:** Add entity tags to `content_stems` during search indexing + +**Benefits:** +- Users can search for tags as regular text +- Zero risk to existing search functionality +- Immediate value with minimal code changes + +**Implementation Tasks:** + +1. **Update Search Indexing** (`search_service.py`) + - Extract tags from `entity.entity_metadata` + - Add tags to `content_stems` for entity indexing + - Handle both string and list tag formats + +2. **Add Tests** + - Test tag extraction from entity metadata + - Test searching for entities by tag content + - Test both list and string tag formats + +3. **Verify Existing Tag Data** + - Ensure consistent tag format in metadata + - Test with real data from existing entities + +### Phase 2: Enhanced Tag Search (Future) - MEDIUM RISK ⭐⭐⭐ + +**Goal:** Add dedicated tag search syntax (`tag:foo`) + +**Approach:** Extend search query parsing and repository + +**Benefits:** +- More precise tag-only searches +- Better search result categorization +- Foundation for advanced tag operations + +**Implementation Tasks:** +- Update search query parsing to handle `tag:` prefix +- Add tag-specific search repository methods +- Update search result metadata to highlight tag matches +- Comprehensive testing of new search syntax + +## File Changes Required (Phase 1) + +### Primary Changes + +1. **`src/basic_memory/services/search_service.py`** + - Update `index_entity_markdown()` method + - Add entity tag extraction logic + - Include tags in content_stems + +2. **`tests/services/test_search_service.py`** + - Add test for entity tag indexing + - Add test for searching entities by tags + - Test tag format handling + +### Supporting Changes + +3. **`tests/mcp/test_tool_search.py`** (if exists) + - Add integration tests for tag search via MCP tools + +## Success Criteria + +### Phase 1 ✅ COMPLETED +- [x] Entity frontmatter tags are included in search index +- [x] Users can find entities by searching tag text +- [x] All existing search functionality continues to work +- [x] Test coverage for new functionality +- [x] Works with both list and string tag formats + +### Phase 2 (Future) +- [ ] `tag:foo` syntax returns only entities with that tag +- [ ] Multiple tag search (`tag:foo tag:bar`) +- [ ] Tag autocomplete/suggestions +- [ ] Search result metadata shows matched tags + +## Risk Assessment + +### Phase 1 Risks: ⭐ VERY LOW +- **Code Impact:** ~20 lines in search service +- **Search Logic:** No changes to core search functionality +- **Backward Compatibility:** 100% - only adds to existing search content +- **Testing:** Straightforward unit tests required + +### Phase 2 Risks: ⭐⭐⭐ MEDIUM +- **Code Impact:** Query parsing, repository methods, API changes +- **Search Logic:** New search syntax parsing required +- **Backward Compatibility:** Must maintain existing search behavior +- **Testing:** Complex query parsing and edge case testing + +## Implementation Notes + +### Tag Format Handling +Entity metadata contains tags in different formats: +```python +# List format (preferred) +"tags": ["business", "strategy", "planning"] + +# String format (legacy) +"tags": "['documentation', 'tools', 'best-practices']" + +# Empty +"tags": "[]" +``` + +The implementation must handle all formats gracefully. + +### Search Content Inclusion +Tags will be added to `content_stems` which already includes: +- Entity title variants +- Entity content +- Permalink variants +- File path variants + +Adding tags to this stream maintains consistency with existing search behavior. + +## Implementation Details (Phase 1 COMPLETED) + +### Changes Made + +1. **`src/basic_memory/services/search_service.py`** ✅ + - Added `_extract_entity_tags()` helper method to handle multiple tag formats + - Modified `index_entity_markdown()` to include entity tags in `content_stems` + - Added proper error handling for malformed tag data + +2. **`tests/services/test_search_service.py`** ✅ + - Added 8 comprehensive tests covering all tag formats and edge cases + - Tests verify tag extraction, search indexing, and search functionality + - Includes tests for both list and string tag formats + +### Key Implementation Features + +- **Robust Tag Parsing:** Handles list format, string format, and edge cases +- **Safe Evaluation:** Uses `ast.literal_eval()` for parsing string representations +- **Backward Compatible:** Zero impact on existing search functionality +- **Comprehensive Testing:** Full test coverage for all scenarios + +### Tag Format Support +```python +# All these formats are now properly handled: +"tags": ["business", "strategy"] # List format +"tags": "['documentation', 'tools']" # String format +"tags": "[]" # Empty string +"tags": [] # Empty list +# Missing tags key or metadata - gracefully handled +``` + +## Next Steps (Future) + +1. **Consider Phase 2:** Enhanced tag search syntax for future release +2. **Monitor Usage:** Track how users search for tags +3. **Gather Feedback:** Understand if `tag:foo` syntax would be valuable +4. **Performance Monitoring:** Ensure tag indexing doesn't impact performance \ No newline at end of file diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 000000000..2f89641af --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,89 @@ +# v0.13.0 Release Issues + +This document tracks the issues identified for the v0.13.0 release, organized by priority. + +## High Priority Bug Fixes + +These issues address core functionality problems and should be resolved first: + +### ~~#118: [BUG] Non-standard tag markup in YAML frontmatter~~ ✅ COMPLETED +- **Impact**: Data quality issue affecting tag formatting +- **Description**: Tags are improperly formatted with `#` prefix and incorrect YAML indentation +- **Expected**: `tags:\n - basicmemory` +- **Actual**: `tags:\n- '#basicmemory'` +- **Complexity**: Low - straightforward formatting fix +- **User Impact**: High - affects all tag usage +- **Resolution**: Fixed in write_note.py by removing `#` prefix from tag formatting + +### ~~#110: [BUG] `--project` flag ignored in some commands~~ ✅ COMPLETED +- **Impact**: Breaks multi-project functionality added in v0.12.3 +- **Description**: Commands like `project info` and `sync` don't respect `--project` flag +- **Root Cause**: Inconsistent project parameter handling across CLI commands +- **Complexity**: Medium - requires CLI argument parsing review +- **User Impact**: High - breaks core multi-project workflow +- **Resolution**: Fixed CLI app callback to update global config when --project specified + +### ~~#107: [BUG] Fails to update note ("already exists")~~ ✅ ALREADY RESOLVED +- **Impact**: Prevents updating existing notes via write_note tool +- **Description**: `write_note` errors when target file exists, breaking daily note workflows +- **Root Cause**: EntityParser couldn't handle absolute paths correctly +- **Complexity**: Medium - requires write_note behavior enhancement +- **User Impact**: High - breaks core knowledge management workflow +- **Resolution**: Fixed in commit 9bff1f7 - EntityParser now handles absolute paths correctly + +## Medium Priority Enhancements + +These features would improve user experience and can be added if time permits: + +### ~~#52: Search frontmatter tags~~ ✅ COMPLETED +- **Impact**: Enhances search capabilities +- **Description**: Include YAML frontmatter tags in search index +- **Implementation**: Index tags in search metadata, possibly add "tag:" search prefix +- **Complexity**: Medium - requires search index modification +- **User Impact**: Medium - improves discoverability +- **Resolution**: Implemented Phase 1 - frontmatter tags now included in FTS5 search index + +### ~~#93: Reliable write_note Behavior for Populating Link Placeholders~~ ✅ COMPLETED +- **Impact**: Improves WikiLink workflow +- **Description**: Handle system-generated placeholder files gracefully in write_note +- **Features Needed**: + - Detect and populate placeholder files + - Respect user-specified permalinks in frontmatter + - Consistent file conflict handling +- **Complexity**: High - requires significant write_note refactoring +- **User Impact**: Medium-High - smooths linking workflow +- **Resolution**: Fixed entity_service.py to parse frontmatter before permalink resolution. Both new and existing notes now respect custom permalinks specified in frontmatter. + +## Lower Priority Issues + +These issues are tracked but not planned for v0.13.0: + +### External/Third-party +- **#116**: MseeP.ai badge PR (external contribution) + +### Diagnostic/Investigation Needed +- **#99**: Timeout logs on Windows +- **#108**: Claude connection interruptions +- **#111**: Highlight app MCP errors +- **#97**: Notes become inaccessible on Windows 11 +- **#96**: LLM not generating proper knowledge graph format + +## Implementation Strategy + +1. **Start with High Priority bugs** - these fix broken functionality +2. **Add Medium Priority enhancements** if time allows +3. **Investigate Lower Priority issues** for future releases + +## Success Criteria for v0.13.0 + +- [x] YAML tag formatting follows standard specification +- [x] `--project` flag works consistently across all commands +- [x] `write_note` can update existing notes reliably +- [x] Custom permalinks in frontmatter are respected by write_note +- [x] Frontmatter tags are included in search index +- [x] Comprehensive test coverage for all fixes +- [ ] Documentation updates for any behavior changes + +## Notes + +This release focuses on stability and core functionality fixes rather than major new features. The goal is to ensure the multi-project system introduced in v0.12.3 works reliably and that basic knowledge management workflows are robust. \ No newline at end of file diff --git a/MOVE.md b/MOVE.md new file mode 100644 index 000000000..e9b90fd12 --- /dev/null +++ b/MOVE.md @@ -0,0 +1,168 @@ +# move_note() Implementation Plan + +## Overview +Implement `move_note()` MCP tool to move notes to new locations while maintaining database consistency and search indexing. Follows the established MCP → API → Service architecture pattern. + +## Architecture + +``` +MCP Tool → API Route → Service Logic +move_note() → POST /knowledge/move → entity_service.move_entity() +``` + +## Implementation Tasks + +### Phase 1: Service Layer +- [ ] Add `move_entity()` method to `EntityService` +- [ ] Handle file path resolution and validation +- [ ] Implement physical file move with rollback on failure +- [ ] Update database (file_path, permalink if configured, checksum) +- [ ] Update search index +- [ ] Add comprehensive error handling + +### Phase 2: API Layer +- [ ] Create `MoveEntityRequest` schema in `schemas/` +- [ ] Add `POST /knowledge/move` route to `knowledge_router.py` +- [ ] Handle project parameter and validation +- [ ] Return formatted success/error messages + +### Phase 3: MCP Tool +- [ ] Create `move_note.py` in `mcp/tools/` +- [ ] Implement tool with project parameter support +- [ ] Add to tool registry in `mcp/server.py` +- [ ] Follow existing tool patterns for httpx client usage + +### Phase 4: Testing +- [ ] Unit tests for `EntityService.move_entity()` +- [ ] API route tests in `test_knowledge_router.py` +- [ ] MCP tool integration tests +- [ ] Error case testing (rollback scenarios) +- [ ] Cross-project move testing + +## Detailed Implementation + +### Service Method Signature +```python +# src/basic_memory/services/entity_service.py +async def move_entity( + self, + identifier: str, # title, permalink, or memory:// URL + destination_path: str, # new path relative to project root + project_config: ProjectConfig +) -> str: + """Move entity to new location with database consistency.""" +``` + +### API Schema +```python +# src/basic_memory/schemas/memory.py +class MoveEntityRequest(BaseModel): + identifier: str + destination_path: str + project: str +``` + +### MCP Tool Signature +```python +# src/basic_memory/mcp/tools/move_note.py +@tool +async def move_note( + identifier: str, + destination_path: str, + project: Optional[str] = None +) -> str: + """Move a note to a new location, updating database and maintaining links.""" +``` + +## Service Implementation Logic + +### 1. Entity Resolution +- Use existing `link_resolver` to find entity by identifier +- Validate entity exists and get current file_path +- Get current project config for file operations + +### 2. Path Validation +- Validate destination_path format +- Ensure destination directory can be created +- Check destination doesn't already exist +- Verify source file exists on filesystem + +### 3. File Operations +- Create destination directory if needed +- Move physical file with `Path.rename()` +- Implement rollback on subsequent failures + +### 4. Database Updates +- Update entity file_path +- Generate new permalink if `update_permalinks_on_move` is True +- Update frontmatter with new permalink if changed +- Recalculate and update checksum +- Use existing repository methods + +### 5. Search Re-indexing +- Call `search_service.index_entity()` with updated entity +- Existing search cleanup should be handled automatically + +## Error Handling + +### Validation Errors +- Entity not found by identifier +- Source file doesn't exist on filesystem +- Destination already exists +- Invalid destination path format + +### Operation Errors +- File system permission errors +- Database update failures +- Search index update failures + +### Rollback Strategy +- On database failure: restore original file location +- On search failure: log error but don't rollback (search can be rebuilt) +- Clear error messages for each failure type + +## Return Messages + +### Success +``` +✅ Note moved successfully + +📁 **old/path.md** → **new/path.md** +🔗 Permalink updated: old-permalink → new-permalink +📊 Database and search index updated + + +``` + +### Failure +``` +❌ Move failed: [specific error message] + + +``` + +## Testing Strategy + +### Unit Tests +- `test_entity_service.py` - Add move_entity tests +- Path validation edge cases +- Permalink generation scenarios +- Error handling and rollback + +### Integration Tests +- `test_knowledge_router.py` - API endpoint tests +- `test_tool_move_note.py` - MCP tool tests +- Cross-project move scenarios +- Full workflow from MCP to filesystem + +### Edge Cases +- Moving to same location (no-op) +- Moving across project boundaries +- Moving files with complex wikilink references +- Concurrent move operations + +## Future Enhancements (Not v0.13.0) +- Update wikilinks in other files that reference moved note +- Batch move operations +- Move with automatic link fixing +- Integration with git for move tracking \ No newline at end of file diff --git a/Makefile b/Makefile index 66c27daa4..09c47bbd5 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,15 @@ -.PHONY: install test test-module lint clean format type-check installer-mac installer-win check +.PHONY: install test test-module lint clean format type-check installer-mac installer-win check test-int install: pip install -e ".[dev]" -test: +test-unit: uv run pytest -p pytest_mock -v -# Run tests for a specific module -# Usage: make test-module m=path/to/module.py [cov=module_path] -test-module: - @if [ -z "$(m)" ]; then \ - echo "Usage: make test-module m=path/to/module.py [cov=module_path]"; \ - exit 1; \ - fi; \ - if [ -z "$(cov)" ]; then \ - uv run pytest $(m) -v; \ - else \ - uv run pytest $(m) -v --cov=$(cov); \ - fi +test-int: + uv run pytest -p pytest_mock -v --no-cov test-int + +test: test-unit test-int lint: ruff check . --fix diff --git a/PROJECT_MANAGEMENT.md b/PROJECT_MANAGEMENT.md new file mode 100644 index 000000000..d60680202 --- /dev/null +++ b/PROJECT_MANAGEMENT.md @@ -0,0 +1,311 @@ +Current vs. Desired State + +Current: Project context is fixed at startup → Restart required to switch +Desired: Fluid project switching during conversation → "Switch to my work-notes project" + +## UX Scenarios to Consider + +### Scenario 1: Project Discovery & Switching + +User: "What projects do I have?" +Assistant: [calls list_projects()] +• personal-notes (active) +• work-project +• code-snippets + +User: "Switch to work-project" +Assistant: [calls switch_project("work-project")] +✓ Switched to work-project + +User: "What did I work on yesterday?" +Assistant: [calls recent_activity() in work-project context] + +### Scenario 2: Cross-Project Operations + +User: "Create a note about this meeting in my personal-notes project" +Assistant: [calls write_note(..., project="personal-notes")] + +User: "Now search for 'API design' across all my projects" +Assistant: [calls search_across_projects("API design")] + +### Scenario 3: Context Awareness + +User: "Edit my todo list" +Assistant: [calls read_note("todo-list")] +📍 Note from work-project: "Todo List" +• Finish API documentation +• Review pull requests + +## Design Options + +### Option A: Session-Based Context + +# New MCP tools for project management +switch_project("work-project") # Sets session context +list_projects() # Shows available projects +get_current_project() # Shows active project + +# Existing tools use session context +edit_note("my-note", "append", "content") # Uses work-project + +### Option B: Explicit Project Parameters + +# Add optional project param to all tools +edit_note("my-note", "append", "content", project="personal-notes") +search_notes("query", project="work-project") + +# If no project specified, use session default +edit_note("my-note", "append", "content") # Uses current context + +### Option C: Hybrid (Most Flexible) + +# Set default context +switch_project("work-project") + +# Use context by default +edit_note("my-note", "append", "content") + +# Override when needed +search_notes("query", project="personal-notes") + +Technical Implementation Ideas + +Session State Management + +# Simple in-memory session store +SESSION_STORE = { + "session_123": { + "current_project": "work-project", + "default_project": "personal-notes" + } +} + +## New MCP Tools + +@tool +async def list_projects() -> str: + """List all available projects.""" + +@tool +async def switch_project(project_name: str) -> str: + """Switch to a different project context.""" + +@tool +async def get_current_project() -> str: + """Show the currently active project.""" + +@tool +async def search_across_projects(query: str) -> str: + """Search across all projects.""" + +@tool +async def set_default_project(project-name: str) -> str: + """Set default project. Requires restart""" + +## Enhanced Existing Tools + +@tool +async def edit_note( + identifier: str, + operation: str, + content: str, + project: Optional[str] = None # New optional parameter +) -> str: + # If project not specified, use session context + project_id = project or get_session_project() + +## UX Questions to Consider + +1. Context Visibility: Should every tool response show which project it's operating on? + +- we could add a footer or something to the tool result that the LLM could understand is just metadata, not to display to the user + +2. Error Handling: What happens when you reference a non-existent project? + +- we would need to validate the project as an input and show an error + +3. Default Behavior: Should there be a "global search" that works across all projects? + +- i'm thinking this is a "not now" thing + +4. State Persistence: Should project context persist across MCP reconnections? + +- I think we always startup with the "default" project. If the user wants to change it, they can update the config, or call the new tool. + +5. Conversation Flow: How do we make project switching feel natural in conversation? + + What's your vision for the ideal user experience? Should it feel more like: +- A file system: "cd into work-project, then edit my notes" +- A workspace switcher: "Switch to work mode" vs "Switch to personal mode" +- Context tags: "In work-project, show me recent activity" + +Something like "lets switch to project X", LLM responds "ok we are working in project X, and shows project summary" + +# Implementation Plan - Client-Side Project Management + +## Overview +Implement ad-hoc project switching as an MCP-only feature. No API changes needed - just session state management on the MCP side with enhanced tools. + +## Core Components + +### 1. Session State Management +```python +# src/basic_memory/mcp/project_session.py +class ProjectSession: + """Simple in-memory project context for MCP session.""" + _current_project: Optional[str] = None + _default_project: Optional[str] = None + + @classmethod + def initialize(cls, default_project: str): + """Set the default project from config on startup.""" + cls._default_project = default_project + cls._current_project = default_project + + @classmethod + def get_current_project(cls) -> str: + return cls._current_project or cls._default_project or "main" + + @classmethod + def set_current_project(cls, project_name: str): + cls._current_project = project_name + + @classmethod + def get_default_project(cls) -> str: + return cls._default_project or "main" +``` + +### 2. New MCP Tools +File: `src/basic_memory/mcp/tools/project_management.py` + +```python +@tool +async def list_projects() -> str: + """List all available projects with their status.""" + +@tool +async def switch_project(project_name: str) -> str: + """Switch to a different project context. Shows project summary after switching.""" + +@tool +async def get_current_project() -> str: + """Show the currently active project and basic stats.""" + +@tool +async def set_default_project(project_name: str) -> str: + """Set default project in config. Requires restart to take effect.""" +``` + +### 3. Enhanced Existing Tools +Add optional `project` parameter to all existing tools: +- `edit_note(..., project: Optional[str] = None)` +- `write_note(..., project: Optional[str] = None)` +- `read_note(..., project: Optional[str] = None)` +- `search_notes(..., project: Optional[str] = None)` +- `recent_activity(..., project: Optional[str] = None)` + +### 4. Tool Response Metadata +Add project context footer to all tool responses: +```python +def add_project_metadata(result: str, project_name: str) -> str: + """Add project context as metadata footer.""" + return f"{result}\n\n" +``` + +## Implementation Tasks + +### Phase 1: Core Infrastructure ✅ +- [x] Create `ProjectSession` class +- [x] Create `project_management.py` tools file +- [x] Initialize session state in MCP server startup +- [x] Add project validation utilities + +### Phase 2: New Tools Implementation ✅ +- [x] Implement `list_projects()` +- [x] Implement `switch_project()` +- [x] Implement `get_current_project()` +- [x] Implement `set_default_project()` + +### Phase 3: Enhance Existing Tools ✅ +- [x] Add `project` parameter to all existing tools +- [x] Update tools to use session context when project not specified +- [x] Add project metadata to tool responses +- [x] Update tool documentation + +### Phase 4: Testing & Polish ✅ +- [x] Add comprehensive tests for project management tools +- [x] Test cross-project operations +- [x] Test error handling for invalid projects +- [x] Update documentation and examples +- [x] All tests passing (146/146 MCP, 16/16 CLI) +- [x] 100% test coverage achieved + +### Phase 5: v0.13.0 Additional Features +- [x] Implement `edit_note()` MCP tool (append/prepend operations) +- [ ] Add `move_note()` functionality +- [ ] Implement agent mode capabilities +- [ ] Update release notes + +### Later +- [ ] Add prompt agent functionality + +## Expected UX Flow + +``` +User: "What projects do I have?" +Assistant: [calls list_projects()] + +Available projects: +• main (current, default) +• work-notes +• personal-journal +• code-snippets + +--- + +User: "Switch to work-notes" +Assistant: [calls switch_project("work-notes")] + +✓ Switched to work-notes project + +Project Summary: +• 47 notes +• Last updated: 2 hours ago +• Recent activity: 3 notes modified today + +--- + +User: "What did I work on yesterday?" +Assistant: [calls recent_activity() - uses work-notes context] + +Recent activity in work-notes: +• Updated "API Design Notes" +• Created "Meeting with Team Lead" +• Modified "Project Timeline" + +--- + +User: "Edit my todo list" +Assistant: [calls edit_note("todo-list", ...) - uses work-notes context] + +Edited note (append) in work-notes: +• file_path: Todo List.md +• Added 2 lines to end of note +``` + +## Technical Details + +### Error Handling +- Validate project names against available projects +- Show helpful error messages for non-existent projects +- Graceful fallback to default project on errors + +### Context Visibility +- Add `` footer to all tool responses +- LLM can use this metadata but doesn't need to show to user +- Clear indication in tool responses which project is active + +### State Management +- Session state resets to default project on MCP restart +- No persistence across reconnections (keeps it simple) +- Config changes require restart (matches current behavior) \ No newline at end of file diff --git a/README.md b/README.md index 6bcc0a367..7c911258d 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,24 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi - [Managing multiple Projects](https://memory.basicmachines.co/docs/cli-reference#project) - [Importing data from OpenAI/Claude Projects](https://memory.basicmachines.co/docs/cli-reference#import) +## Installation Options + +### Stable Release +```bash +pip install basic-memory +``` + +### Beta/Pre-releases +```bash +pip install basic-memory --pre +``` + +### Development Builds +Development versions are automatically published on every commit to main with versions like `0.12.4.dev26+468a22f`: +```bash +pip install basic-memory --pre --force-reinstall +``` + ## License AGPL-3.0 diff --git a/RELEASE_NOTES_v0.13.0.md b/RELEASE_NOTES_v0.13.0.md index 7e27469af..e5c106505 100644 --- a/RELEASE_NOTES_v0.13.0.md +++ b/RELEASE_NOTES_v0.13.0.md @@ -2,222 +2,236 @@ ## Overview -This is a major release that introduces multi-project support, OAuth authentication, server-side templating, and numerous improvements to the MCP server implementation. The codebase has been significantly refactored to support a unified database architecture while maintaining backward compatibility. +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. Multi-Project Support 🎯 -- **Unified Database Architecture**: All projects now share a single SQLite database with proper isolation -- **Project Management API**: New endpoints for creating, updating, and managing projects -- **Project Configuration**: Projects can be defined in `config.json` and synced with the database -- **Default Project**: Backward compatibility maintained with automatic default project creation -- **Project Switching**: CLI commands and API endpoints now support project context - -### 2. OAuth 2.1 Authentication 🔐 -- **Multiple Provider Support**: - - Basic (in-memory) provider for development - - Supabase provider for production deployments - - External providers (GitHub, Google) framework -- **JWT-based Access Tokens**: Secure token generation and validation -- **PKCE Support**: Enhanced security for authorization code flow -- **MCP Inspector Integration**: Full support for authenticated testing -- **CLI Commands**: `basic-memory auth register-client` and `basic-memory auth test-auth` - -### 3. Server-Side Template Engine 📝 -- **Handlebars Templates**: Server-side rendering of prompts and responses -- **Custom Helpers**: Rich set of template helpers for formatting -- **Structured Output**: XML-formatted responses for better LLM consumption -- **Template Caching**: Improved performance with template compilation caching - -### 4. Enhanced Import System 📥 -- **Unified Importer Framework**: Base class for all importers with consistent interface -- **API Support**: New `/import` endpoints for triggering imports via API -- **Progress Tracking**: Real-time progress updates during import operations -- **Multiple Formats**: - - ChatGPT conversations - - Claude conversations - - Claude projects - - Memory JSON format - -### 5. Directory Navigation 📁 -- **Directory Service**: Browse and navigate project file structure -- **API Endpoints**: `/directory/tree` and `/directory/list` endpoints -- **Hierarchical View**: Tree structure representation of knowledge base - -## API Changes - -### New Endpoints - -#### Project Management -- `GET /projects` - List all projects -- `POST /projects` - Create new project -- `GET /projects/{project_id}` - Get project details -- `PUT /projects/{project_id}` - Update project -- `DELETE /projects/{project_id}` - Delete project -- `POST /projects/{project_id}/set-default` - Set default project - -#### Import API -- `GET /{project}/import/types` - List available importers -- `POST /{project}/import/{importer_type}/analyze` - Analyze import source -- `POST /{project}/import/{importer_type}/preview` - Preview import -- `POST /{project}/import/{importer_type}/execute` - Execute import - -#### Directory API -- `GET /{project}/directory/tree` - Get directory tree -- `GET /{project}/directory/list` - List directory contents - -#### Prompt Templates -- `POST /{project}/prompts/search` - Search with formatted output -- `POST /{project}/prompts/continue-conversation` - Continue conversation with context - -#### Management API -- `GET /management/sync/status` - Get sync status -- `POST /management/sync/start` - Start background sync -- `POST /management/sync/stop` - Stop background sync - -### Updated Endpoints - -All knowledge-related endpoints now require project context: -- `/{project}/entities` -- `/{project}/observations` -- `/{project}/search` -- `/{project}/memory` - -## CLI Changes - -### New Commands -- `basic-memory auth` - OAuth client management -- `basic-memory project create` - Create new project -- `basic-memory project list` - List all projects -- `basic-memory project set-default` - Set default project -- `basic-memory project delete` - Delete project -- `basic-memory project info` - Show project statistics - -### Updated Commands -- Import commands now support `--project` flag -- Sync commands operate on all active projects by default -- MCP server defaults to stdio transport (use `--transport streamable-http` for HTTP) - -## Configuration Changes - -### config.json Structure -```json -{ - "projects": { - "main": "~/basic-memory", - "my-project": "~/my-notes", - "work": "~/work/notes" - }, - "default_project": "main", - "sync_changes": true -} +### 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] ``` -### Environment Variables -- `FASTMCP_AUTH_ENABLED` - Enable OAuth authentication -- `FASTMCP_AUTH_SECRET_KEY` - JWT signing key -- `FASTMCP_AUTH_PROVIDER` - OAuth provider type -- `FASTMCP_AUTH_REQUIRED_SCOPES` - Required OAuth scopes +**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 -## Database Changes +### 2. Advanced Note Editing ✏️ -### New Tables -- `project` - Project definitions and metadata -- Migration: `5fe1ab1ccebe_add_projects_table.py` +**Edit notes incrementally without rewriting entire documents:** -### Schema Updates -- All knowledge tables now include `project_id` foreign key -- Search index updated to support project filtering -- Backward compatibility maintained via default project +```python +# Append new sections to existing notes +edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y") -## Performance Improvements +# Prepend timestamps to meeting notes +edit_note("meeting-notes", "prepend", "## 2025-05-27 Update\n- Progress update...") -- **Concurrent Initialization**: Projects initialize in parallel -- **Optimized Queries**: Better use of indexes and joins -- **Template Caching**: Compiled templates cached in memory -- **Batch Operations**: Reduced database round trips +# Replace specific sections under headers +edit_note("api-spec", "replace_section", "New implementation details", section="## Implementation") -## Bug Fixes +# Find and replace with validation +edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0", expected_replacements=2) +``` -- Fixed duplicate initialization in MCP server startup -- Fixed JWT audience validation for OAuth tokens -- Fixed trailing slash requirement for MCP endpoints -- Corrected OAuth endpoint paths -- Fixed stdio transport initialization -- Improved error handling in file sync operations -- Fixed search result ranking and filtering +**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 -## Breaking Changes +### 3. Smart File Management 📁 -- **Project Context Required**: API endpoints now require project context -- **Database Location**: Unified database at `~/.basic-memory/memory.db` -- **Import Module Restructure**: Import functionality moved to dedicated module +**Move and organize notes:** -## Migration Guide +```python +# Simple moves with automatic folder creation +move_note("my-note", "work/projects/my-note.md") -### For Existing Users +# Organize within the active project +move_note("shared-doc", "archive/old-docs/shared-doc.md") -1. **Automatic Migration**: First run will migrate existing data to default project -2. **Project Configuration**: Add projects to `config.json` if using multiple projects -3. **API Updates**: Update API calls to include project context +# Rename operations +move_note("old-name", "same-folder/new-name.md") +``` -### For API Consumers +**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 -```python -# Old -response = client.get("/entities") +### 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 -# New -response = client.get("/main/entities") # 'main' is default project +**Example:** +```yaml +--- +title: Coffee Brewing Methods +tags: [coffee, brewing, equipment] +--- ``` +Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods" -### For OAuth Setup +### 5. Unified Database Architecture 🗄️ -```bash -# Enable OAuth -export FASTMCP_AUTH_ENABLED=true -export FASTMCP_AUTH_SECRET_KEY="your-secret-key" +**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:** -# Start server -basic-memory mcp --transport streamable-http +```bash +# Stable release +uv tool install basic-memory -# Get token -basic-memory auth test-auth +# Beta/pre-releases +uv tool install basic-memory --pre ``` -## Dependencies -### Added -- `python-dotenv` - Environment variable management -- `pydantic` >= 2.0 - Enhanced validation +### 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 -### Updated -- `fastmcp` to latest version -- `mcp` to latest version -- All development dependencies updated +**Automatic Migration**: First run will automatically migrate existing data to the new unified database structure. No manual action required. -## Documentation +**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 -- New: [OAuth Authentication Guide](docs/OAuth%20Authentication%20Guide.md) -- New: [Supabase OAuth Setup](docs/Supabase%20OAuth%20Setup.md) -- Updated: [Claude.ai Integration](docs/Claude.ai%20Integration.md) -- Updated: Main README with project examples +**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 -## Testing -- Added comprehensive test coverage for new features -- OAuth provider tests with full flow validation -- Template engine tests with various scenarios -- Project service integration tests -- Import system unit tests -## Contributors -This release includes contributions from the Basic Machines team and the AI assistant Claude, demonstrating effective human-AI collaboration in software development. +## 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 -## Next Steps +# Beta releases +uv tool install basic-memory --pre --force-reinstall -- Production deployment guide updates -- Additional OAuth provider implementations -- Performance profiling and optimization -- Enhanced project analytics features \ No newline at end of file +# Latest development +uv tool install basic-memory --pre --force-reinstall +``` \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..94f21e4e6 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,337 @@ +# 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. \ No newline at end of file diff --git a/docs/AI Assistant Guide.md b/docs/AI Assistant Guide.md index d1eb3368f..356582535 100644 --- a/docs/AI Assistant Guide.md +++ b/docs/AI Assistant Guide.md @@ -10,6 +10,26 @@ You can [download](https://github.com/basicmachines-co/basic-memory/blob/main/do This guide helps you, the AI assistant, use Basic Memory tools effectively when working with users. It covers reading, writing, and navigating knowledge through the Model Context Protocol (MCP). +## Quick Reference + +**Essential Tools:** +- `write_note()` - Create/update notes (primary tool) +- `read_note()` - Read existing content +- `search_notes()` - Find information +- `edit_note()` - Modify existing notes incrementally (v0.13.0) +- `move_note()` - Organize files with database consistency (v0.13.0) + +**Project Management (v0.13.0):** +- `list_projects()` - Show available projects +- `switch_project()` - Change active project +- `get_current_project()` - Current project info + +**Key Principles:** +1. **Build connections** - Rich knowledge graphs > isolated notes +2. **Ask permission** - "Would you like me to record this?" +3. **Use exact titles** - For accurate `[[WikiLinks]]` +4. **Leverage v0.13.0** - Edit incrementally, organize proactively, switch projects contextually + ## Overview Basic Memory allows you and users to record context in local Markdown files, building a rich knowledge base through natural conversations. The system automatically creates a semantic knowledge graph from simple text patterns. @@ -37,49 +57,57 @@ Remember that a knowledge graph with 10 heavily connected notes is more valuable ## 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 +### Essential Content Management + +**Writing knowledge** (most important tool): +``` +write_note( + title="Search Design", + content="# Search Design\n...", + folder="specs", # Optional + tags=["search", "design"], # v0.13.0: now searchable! + project="work-notes" # v0.13.0: target specific project ) +``` -# 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 +**Reading knowledge:** +``` +read_note("Search Design") # By title +read_note("specs/search-design") # By path +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 +**Incremental editing** (v0.13.0): +``` +edit_note( + identifier="Search Design", + operation="append", # append, prepend, find_replace, replace_section + content="\n## New Section\nContent here..." ) +``` -# 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): +``` +move_note( + identifier="Old Note", + destination="archive/old-note.md" # Folders created automatically ) +``` -# 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 -) +### Project Management (v0.13.0) -# 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 -) +``` +list_projects() # Show available projects +switch_project("work-notes") # Change active project +get_current_project() # Current project info +``` + +### Search & Discovery + +``` +search_notes("authentication system") # v0.13.0: includes frontmatter tags +build_context("memory://specs/search") # Follow knowledge graph connections +recent_activity(timeframe="1 week") # Check what's been updated ``` ## memory:// URLs Explained @@ -158,6 +186,30 @@ Users will interact with Basic Memory in patterns like: [Then build_context() to understand connections] ``` +4. **Editing existing notes (v0.13.0)**: + ``` + Human: "Add a section about deployment to my API documentation" + + You: I'll add that section to your existing documentation. + [Use edit_note() with operation="append" to add new content] + ``` + +5. **Project management (v0.13.0)**: + ``` + Human: "Switch to my work project and show recent activity" + + You: I'll switch to your work project and check what's been updated recently. + [Use switch_project() then recent_activity()] + ``` + +6. **File organization (v0.13.0)**: + ``` + Human: "Move my old meeting notes to the archive folder" + + You: I'll organize those notes for you. + [Use move_note() to relocate files with database consistency] + ``` + ## Key Things to Remember 1. **Files are Truth** @@ -174,16 +226,27 @@ Users will interact with Basic Memory in patterns like: - Combine related information 3. **Writing Knowledge Wisely** - - Using the same title+folder will overwrite existing notes - - Structure content with clear headings and sections - - Use semantic markup for observations and relations + - Same title+folder overwrites existing notes + - Structure with clear headings and semantic markup + - Use tags for searchability (v0.13.0: frontmatter tags indexed) - Keep files organized in logical folders +4. **Leverage v0.13.0 Features** + - **Edit incrementally**: Use `edit_note()` for small changes vs rewriting + - **Switch projects**: Change context when user mentions different work areas + - **Organize proactively**: Move old content to archive folders + - **Cross-project operations**: Create notes in specific projects while maintaining context + ## Common Knowledge Patterns ### Capturing Decisions ```markdown +--- +title: Coffee Brewing Methods +tags: [coffee, brewing, pour-over, techniques] # v0.13.0: Now searchable! +--- + # Coffee Brewing Methods ## Context @@ -196,11 +259,13 @@ Pour over is my preferred method for light to medium roasts because it highlight - [technique] Blooming the coffee grounds for 30 seconds improves extraction #brewing - [preference] Water temperature between 195-205°F works best #temperature - [equipment] Gooseneck kettle provides better control of water flow #tools +- [timing] Total brew time of 3-4 minutes produces optimal extraction #process ## Relations - pairs_with [[Light Roast Beans]] - contrasts_with [[French Press Method]] - requires [[Proper Grinding Technique]] +- part_of [[Morning Coffee Routine]] ``` ### Recording Project Structure @@ -241,119 +306,63 @@ Discussed strategies for improving the chocolate chip cookie recipe. - pairs_with [[Homemade Ice Cream]] ``` -### Creating Effective Relations +## v0.13.0 Workflow Examples -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] - - # Check if specific entities exist - packing_tips_exists = "Packing Tips" in existing_entities - japan_travel_exists = "Japan Travel Guide" in existing_entities - - # Prepare relations section - include both existing and forward references - relations_section = "## Relations\n" - - # 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" - - # 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. +### Multi-Project Conversations -## Observations -- [area] Shibuya is a busy shopping district #shopping -- [transportation] Yamanote Line connects major neighborhoods #transit -- [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}") -``` +**User:** "I need to update my work documentation and also add a personal recipe note." -## Error Handling +**Workflow:** +1. `list_projects()` - Check available projects +2. `write_note(title="Sprint Planning", project="work-notes")` - Work content +3. `write_note(title="Weekend Recipes", project="personal")` - Personal content -Common issues to watch for: +### Incremental Note Building -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) - ``` +**User:** "Add a troubleshooting section to my setup guide." -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?") - ``` +**Workflow:** +1. `edit_note(identifier="Setup Guide", operation="append", content="\n## Troubleshooting\n...")` -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'.") - ``` +**User:** "Update the authentication section in my API docs." + +**Workflow:** +1. `edit_note(identifier="API Documentation", operation="replace_section", section="## Authentication")` + +### Smart File Organization + +**User:** "My notes are getting messy in the main folder." + +**Workflow:** +1. `move_note("Old Meeting Notes", "archive/2024/old-meetings.md")` +2. `move_note("Project Notes", "projects/client-work/notes.md")` + +### Creating Effective Relations + +When creating relations: +1. **Reference existing entities** by their exact title: `[[Exact Title]]` +2. **Create forward references** to entities that don't exist yet - they'll be linked automatically when created +3. **Search first** to find existing entities to reference +4. **Use meaningful relation types**: `implements`, `requires`, `part_of` vs generic `relates_to` + +**Example workflow:** +1. `search_notes("travel")` to find existing travel-related notes +2. Reference found entities: `- part_of [[Japan Travel Guide]]` +3. Add forward references: `- located_in [[Tokyo]]` (even if Tokyo note doesn't exist yet) + +## Common Issues & Solutions + +**Missing Content:** +- Try `search_notes()` with broader terms if `read_note()` fails +- Use fuzzy matching: search for partial titles + +**Forward References:** +- These are normal! Basic Memory links them automatically when target notes are created +- Inform users: "I've created forward references that will be linked when you create those notes" + +**Sync Issues:** +- If information seems outdated, suggest `basic-memory sync` +- Use `recent_activity()` to check if content is current ## Best Practices @@ -395,4 +404,5 @@ Common issues to watch for: - Offer to create summaries of scattered information - Suggest potential missing relations: "I notice this might relate to [topic], would you like me to add that connection?" + Built with ♥️ by Basic Machines diff --git a/docs/CLI Reference.md b/docs/CLI Reference.md index c5580c7b2..dd46ade73 100644 --- a/docs/CLI Reference.md +++ b/docs/CLI Reference.md @@ -10,6 +10,25 @@ Basic Memory provides command line tools for managing your knowledge base. This ## Core Commands +### auth (New in v0.13.0) + +Manage OAuth authentication for secure remote access: + +```bash +# Test authentication setup +basic-memory auth test-auth + +# Register OAuth client +basic-memory auth register-client +``` + +Supports multiple authentication providers: +- **Basic Provider**: For development and testing +- **Supabase Provider**: For production deployments +- **External Providers**: GitHub, Google integration framework + +See [[OAuth Authentication Guide]] for complete setup instructions. + ### sync Keeps files and the knowledge graph in sync: @@ -45,9 +64,9 @@ To change the properties, set the following values: ``` Thanks for using Basic Memory! -### import +### import (Enhanced in v0.13.0) -Imports external knowledge sources: +Imports external knowledge sources with support for project targeting: ```bash # Claude conversations @@ -59,12 +78,19 @@ basic-memory import claude projects # ChatGPT history basic-memory import chatgpt -# ChatGPT history +# Memory JSON format basic-memory import memory-json /path/to/memory.json +# Import to specific project (v0.13.0) +basic-memory --project=work import claude conversations ``` -> **Note**: After importing, run `basic-memory sync` to index the new files. +**New in v0.13.0:** +- **Project Targeting**: Import directly to specific projects +- **Real-time Sync**: Imported content available immediately +- **Unified Database**: All imports stored in centralized database + +> **Note**: Changes sync automatically - no manual sync required in v0.13.0. ### status Shows system status information: @@ -81,28 +107,32 @@ basic-memory status --json ``` -### project +### project (Enhanced in v0.13.0) -Create multiple projects to manage your knowledge. +Manage multiple projects with the new unified database architecture. Projects can now be switched instantly during conversations without restart. ```bash -# List all configured projects +# List all configured projects with status basic-memory project list -# Add a new project -basic-memory project add work ~/work-basic-memory +# Create a new project +basic-memory project create work ~/work-basic-memory # Set the default project -basic-memory project default work +basic-memory project set-default work -# Remove a project (doesn't delete files) -basic-memory project remove personal +# Delete a project (doesn't delete files) +basic-memory project delete personal -# Show current project -basic-memory project current +# Show detailed project statistics +basic-memory project info ``` -> Be sure to restart Claude Desktop after changing projects. +**New in v0.13.0:** +- **Unified Database**: All projects share a single database for better performance +- **Instant Switching**: Switch projects during conversations without restart +- **Enhanced Commands**: Updated project commands with better status information +- **Project Statistics**: Detailed info about entities, observations, and relations #### Using Projects in Commands @@ -122,6 +152,34 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable: BASIC_MEMORY_PROJECT=work basic-memory sync ``` +### tool (Enhanced in v0.13.0) + +Direct access to MCP tools via CLI with new editing and file management capabilities: + +```bash +# Create notes +basic-memory tool write-note --title "My Note" --content "Content here" + +# Edit notes incrementally (v0.13.0) +echo "New content" | basic-memory tool edit-note --title "My Note" --operation append + +# Move notes (v0.13.0) +basic-memory tool move-note --identifier "My Note" --destination "archive/my-note.md" + +# Search notes +basic-memory tool search-notes --query "authentication" + +# Project management (v0.13.0) +basic-memory tool list-projects +basic-memory tool switch-project --project-name "work" +``` + +**New in v0.13.0:** +- **edit-note**: Incremental editing (append, prepend, find/replace, section replace) +- **move-note**: File management with database consistency +- **Project tools**: list-projects, switch-project, get-current-project +- **Cross-project operations**: Use `--project` flag with any tool + ### help The full list of commands and help for each can be viewed with the `--help` argument. @@ -144,10 +202,11 @@ The full list of commands and help for each can be viewed with the `--help` argu │ --help Show this message and exit. │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ────────────────────────────────────────────────────────────────────────────────╮ -│ sync Sync knowledge files with the database. │ -│ status Show sync status between files and database. │ -│ reset Reset database (drop all tables and recreate). │ -│ mcp Run the MCP server for Claude Desktop integration. │ +│ auth OAuth authentication management (v0.13.0) │ +│ sync Sync knowledge files with the database │ +│ status Show sync status between files and database │ +│ reset Reset database (drop all tables and recreate) │ +│ mcp Run the MCP server for Claude Desktop integration │ │ import Import data from various sources │ │ tool Direct access to MCP tools via CLI │ │ project Manage multiple Basic Memory projects │ @@ -302,6 +361,29 @@ You can then use the `/mcp` command in the REPL: • basic-memory: connected ``` +## Version Management (New in v0.13.0) + +Basic Memory v0.13.0 introduces automatic version management and multiple installation options: + +```bash +# Stable releases +pip install basic-memory + +# Beta/pre-releases +pip install basic-memory --pre + +# Latest development builds (auto-published) +pip install basic-memory --pre --force-reinstall + +# Check current version +basic-memory --version +``` + +**Version Types:** +- **Stable**: `0.13.0` (manual git tags) +- **Beta**: `0.13.0b1` (manual git tags) +- **Development**: `0.12.4.dev26+468a22f` (automatic from commits) + ## Troubleshooting Common Issues ### Sync Conflicts diff --git a/docs/Getting Started with Basic Memory.md b/docs/Getting Started with Basic Memory.md index 43df77323..f14a3b338 100644 --- a/docs/Getting Started with Basic Memory.md +++ b/docs/Getting Started with Basic Memory.md @@ -20,14 +20,25 @@ The easiest way to install basic memory is via `uv`. See the [uv installation gu ### 1. Install Basic Memory +**v0.13.0 offers multiple installation options:** + ```bash -# Install with uv (recommended). +# Stable release (recommended) uv tool install basic-memory +# or: pip install basic-memory + +# Beta releases (new features, testing) +pip install basic-memory --pre -# Or with pip -pip install basic-memory +# Development builds (latest changes) +pip install basic-memory --pre --force-reinstall ``` +**Version Information:** +- **Stable**: Latest tested release (e.g., `0.13.0`) +- **Beta**: Pre-release versions (e.g., `0.13.0b1`) +- **Development**: Auto-published from git commits (e.g., `0.12.4.dev26+468a22f`) + > **Important**: You need to install Basic Memory using one of the commands above to use the command line tools. Using `uv tool install` will install the basic-memory package in a standalone virtual environment. See the [UV docs](https://docs.astral.sh/uv/concepts/tools/) for more info. @@ -99,30 +110,49 @@ To disable realtime sync, you can update the config. See [[CLI Reference#sync]]. To update Basic Memory when new versions are released: ```bash -# Update with uv (recommended) +# Update stable release uv tool upgrade basic-memory +# or: pip install --upgrade basic-memory -# Or with pip -pip install --upgrade basic-memory +# Update to latest beta (v0.13.0) +pip install --upgrade basic-memory --pre + +# Get latest development build +pip install --upgrade basic-memory --pre --force-reinstall ``` -> **Note**: After updating, you'll need to restart Claude Desktop and your sync process for changes to take effect. +**v0.13.0 Update Benefits:** +- **Fluid project switching** during conversations +- **Advanced note editing** capabilities +- **Smart file management** with move operations +- **Enhanced search** with frontmatter tag support + +> **Note**: After updating, restart Claude Desktop for changes to take effect. No sync restart needed in v0.13.0. -### 5. Change the default project directory +### 5. Multi-Project Setup (Enhanced in v0.13.0) -By default, Basic Memory will create a project in the `basic-memory` folder in your home directory. You can change this via the `project` [[CLI Reference#project|cli command]]. +By default, Basic Memory creates a project in `~/basic-memory`. v0.13.0 introduces **fluid project management** - switch between projects instantly during conversations. ``` -# Add a new project -basic-memory project add work ~/work-basic-memory +# Create a new project +basic-memory project create work ~/work-basic-memory # Set the default project -basic-memory project default work +basic-memory project set-default work -# List all configured projects -basic-memory project list +# List all projects with status +basic-memory project list + +# Get detailed project information +basic-memory project info ``` +**New in v0.13.0:** +- **Instant switching**: Change projects during conversations without restart +- **Unified database**: All projects in single `~/.basic-memory/memory.db` +- **Better performance**: Optimized queries and reduced file I/O +- **Session context**: Maintains active project throughout conversations + ## Troubleshooting Installation ### Common Issues @@ -168,6 +198,7 @@ If you encounter permission errors: --- title: Coffee Brewing Methods permalink: coffee-brewing-methods + tags: [coffee, brewing, equipment] # v0.13.0: Now searchable! --- # Coffee Brewing Methods @@ -180,11 +211,10 @@ If you encounter permission errors: - relates_to [[Other Coffee Topics]] ``` -5. **Start the sync process** in a Terminal window (optional): - ```bash - basic-memory sync --watch - ``` - Keep this running in the background. +**v0.13.0 Improvements:** +- **Real-time sync**: Changes appear immediately, no background sync needed +- **Searchable tags**: Frontmatter tags are now indexed for search +- **Better file organization**: Enhanced file management capabilities ## Using Special Prompts @@ -250,14 +280,37 @@ Or directly reference notes using memory:// URLs: You: "Take a look at memory://coffee-brewing-methods and let's discuss how to improve my technique." ``` -### Building On Previous Knowledge +### Building On Previous Knowledge (Enhanced in v0.13.0) Basic Memory enables continuous knowledge building: 1. **Reference previous discussions** in new conversations -2. **Add to existing notes** through conversations -3. **Create connections** between related topics -4. **Follow relationships** to build comprehensive context +2. **Edit notes incrementally** without rewriting entire documents +3. **Move and organize notes** as your knowledge base grows +4. **Switch between projects** instantly during conversations +5. **Search by tags** to find related content quickly +6. **Create connections** between related topics +7. **Follow relationships** to build comprehensive context + +### v0.13.0 Workflow Examples + +**Incremental Editing:** +``` +You: "Add a section about espresso to my coffee brewing notes" +Claude: [Uses edit_note to append new section] +``` + +**File Organization:** +``` +You: "Move my old meeting notes to an archive folder" +Claude: [Uses move_note with database consistency] +``` + +**Project Switching:** +``` +You: "Switch to my work project and show recent activity" +Claude: [Switches projects and shows work-specific content] +``` ## Importing Existing Conversations @@ -271,17 +324,24 @@ basic-memory import claude conversations basic-memory import chatgpt ``` -After importing, the changes will be synced. Initial syncs may take a few moments. You can see info about your project by running `basic-memrory project info`. +After importing, changes sync automatically in real-time. You can see project statistics by running `basic-memory project info`. ## Quick Tips -- Basic Memory will sync changes from your project in real time. +### General Usage +- Basic Memory syncs changes in real-time (no manual sync needed) - Use special prompts (Continue Conversation, Recent Activity, Search) to start contextual discussions - Build connections between notes for a richer knowledge graph -- Use direct `memory://` URLs with a permalink when you need precise context. See [[User Guide#Using memory // URLs]] -- Use git to version control your knowledge base (git integration is on the roadmap) +- Use direct `memory://` URLs with permalinks for precise context - Review and edit AI-generated notes for accuracy +### v0.13.0 Features +- **Switch projects instantly**: "Switch to my work project" - no restart needed +- **Edit notes incrementally**: "Add a section about..." instead of rewriting +- **Organize with moves**: "Move this to my archive folder" with database consistency +- **Search by tags**: Frontmatter tags are now searchable +- **Try beta builds**: `pip install basic-memory --pre` for latest features + ## Next Steps After getting started, explore these areas: @@ -290,4 +350,6 @@ After getting started, explore these areas: 2. **Understand the [[Knowledge Format]]** to learn how knowledge is structured 3. **Set up [[Obsidian Integration]]** for visual knowledge navigation 4. **Learn about [[Canvas]]** visualizations for mapping concepts -5. **Review the [[CLI Reference]]** for command line tools \ No newline at end of file +5. **Review the [[CLI Reference]]** for command line tools +6. **Explore [[OAuth Authentication Guide]]** for secure remote access (v0.13.0) +7. **Set up multiple projects** for different knowledge areas (v0.13.0) \ No newline at end of file diff --git a/docs/User Guide.md b/docs/User Guide.md index 9c90ab281..8664313e2 100644 --- a/docs/User Guide.md +++ b/docs/User Guide.md @@ -388,6 +388,59 @@ Maintain context for complex projects over time: ## Advanced Features +### Note Editing (New in v0.13.0) + +**Edit notes incrementally without rewriting entire documents:** + +``` +💬 "Add a new section about deployment to my API documentation" +🤖 [Uses edit_note to append new section] + +💬 "Update the date at the top of my meeting notes" +🤖 [Uses edit_note to prepend new timestamp] + +💬 "Replace the implementation section in my design doc" +🤖 [Uses edit_note to replace specific section] +``` + +Available editing operations: +- **Append**: Add content to end of notes +- **Prepend**: Add content to beginning of notes +- **Replace Section**: Replace content under specific headers +- **Find & Replace**: Simple text replacements with validation + +### File Management (New in v0.13.0) + +**Move and organize notes with full database consistency:** + +``` +💬 "Move my old meeting notes to the archive folder" +🤖 [Uses move_note with automatic folder creation and database updates] + +💬 "Reorganize my project files into a better structure" +🤖 [Moves files while maintaining search indexes and links] +``` + +Move operations include: +- **Database Consistency**: Updates file paths, permalinks, and checksums +- **Search Reindexing**: Maintains search functionality after moves +- **Folder Creation**: Automatically creates destination directories +- **Project Isolation**: Moves are contained within the current project +- **Rollback Protection**: Ensures data integrity during failed operations + +### Enhanced Search (New in v0.13.0) + +**Frontmatter tags are now searchable:** + +```yaml +--- +title: Coffee Brewing Methods +tags: [coffee, brewing, equipment] +--- +``` + +Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods" + ### Importing External Knowledge Import existing conversations: @@ -398,9 +451,12 @@ basic-memory import claude conversations # From ChatGPT basic-memory import chatgpt + +# Target specific projects (v0.13.0) +basic-memory --project=work import claude conversations ``` -After importing, run `basic-memory sync` to index everything. +After importing, changes sync automatically in real-time. ### Obsidian Integration @@ -460,12 +516,47 @@ basic-memory import claude conversations basic-memory import chatgpt ``` -## Multiple Projects +## Multiple Projects (v0.13.0) + +Basic Memory v0.13.0 introduces **fluid project management** - the ability to switch between projects instantly during conversations without restart. This allows you to maintain separate knowledge graphs for different purposes while seamlessly switching between them. + +### Instant Project Switching (New in v0.13.0) + +**Switch projects 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] +``` + +### Project-Specific Operations (New in v0.13.0) -Basic Memory supports managing multiple separate knowledge bases through projects. This feature allows you to maintain -separate knowledge graphs for different purposes (e.g., personal notes, work projects, research topics). +Some MCP tools support optional project parameters for targeting specific projects: + +``` +💬 "Create a note about this meeting in my personal-notes project" +🤖 [Creates note in personal-notes project] + +💬 "Switch to my work project" +🤖 [Switches project context, then all operations work within that project] +``` -Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config.json` +**Note**: Operations like search, move, and edit work within the currently active project. To work with content in different projects, switch to that project first or use the project parameter where supported. ### Managing Projects @@ -474,16 +565,16 @@ Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config basic-memory project list # Add a new project -basic-memory project add work ~/work-basic-memory +basic-memory project create work ~/work-basic-memory # Set the default project -basic-memory project default work +basic-memory project set-default work # Remove a project (doesn't delete files) -basic-memory project remove personal +basic-memory project delete personal -# Show current project -basic-memory project current +# Show current project statistics +basic-memory project info ``` ### Using Projects in Commands @@ -504,23 +595,32 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable: BASIC_MEMORY_PROJECT=work basic-memory sync ``` -### Project Isolation +### Unified Database Architecture (New in v0.13.0) -Each project maintains: +Basic Memory v0.13.0 uses a unified database architecture: -- Its own collection of markdown files in the specified directory -- A separate SQLite database for that project -- Complete knowledge graph isolation from other projects +- **Single Database**: All projects share `~/.basic-memory/memory.db` +- **Project Isolation**: Proper data separation with project context +- **Better Performance**: Optimized queries and reduced file I/O +- **Easier Backup**: Single database file contains all project data +- **Session Context**: Maintains active project throughout conversations ## Workflow Tips -1. Run sync in watch mode for automatic updates -2. Use git for version control of your knowledge base -3. Review and edit AI-created content for accuracy -4. Periodically organize and refine your knowledge structure -5. Build rich connections between related ideas -6. Use forward references to plan future documentation -7. Start conversations with special prompts to leverage existing knowledge +### General Workflow +1. **Project Organization**: Use multiple projects to separate different areas (work, personal, research) +2. **Session Context**: Switch projects during conversations without restart (v0.13.0) +3. **Real-time Sync**: Changes sync automatically - no need to run watch mode +4. **Review Content**: Edit AI-created content for accuracy +5. **Build Connections**: Create rich relationships between related ideas +6. **Use Special Prompts**: Start conversations with context from your knowledge base + +### v0.13.0 Workflow Enhancements +7. **Incremental Editing**: Use edit_note for small changes instead of rewriting entire documents +8. **File Organization**: Move and reorganize notes as your knowledge base grows +9. **Project-Specific Creation**: Create notes in specific projects using project parameters +10. **Search Tags**: Use frontmatter tags to improve content discoverability +11. **Project Statistics**: Monitor project growth and activity with project info commands ## Troubleshooting @@ -528,9 +628,8 @@ Each project maintains: If changes aren't showing up: -1. Verify `basic-memory sync --watch` is running -2. Run `basic-memory status` to check system state -3. Try a manual sync with `basic-memory sync` +1. Run `basic-memory status` to check system state +2. Try a manual sync with `basic-memory sync` ### Missing Content @@ -553,4 +652,6 @@ If relations aren't working: - implements [[Knowledge Format]] (How knowledge is structured) - relates_to [[Getting Started with Basic Memory]] (Setup and first steps) - relates_to [[Canvas]] (Creating visual knowledge maps) -- relates_to [[CLI Reference]] (Command line tools) \ No newline at end of file +- relates_to [[CLI Reference]] (Command line tools) +- enhanced_in_v0.13.0 [[OAuth Authentication Guide]] (Production authentication) +- enhanced_in_v0.13.0 [[Project Management]] (Multi-project workflows) \ No newline at end of file diff --git a/examples/testing/Test Note Creation - Basic Functionality.md b/examples/testing/Test Note Creation - Basic Functionality.md new file mode 100644 index 000000000..ca462f227 --- /dev/null +++ b/examples/testing/Test Note Creation - Basic Functionality.md @@ -0,0 +1,69 @@ +--- +title: Test Note Creation - Basic Functionality +type: note +permalink: testing/test-note-creation-basic-functionality +tags: +- '["testing"' +- '"core-functionality"' +- '"note-creation"]' +--- + +--- +title: Test Note Creation - Basic Functionality +tags: [testing, core-functionality, note-creation, edited] +test_status: active +last_edited: 2025-06-01 +--- + +# Test Note Creation - Basic Functionality + +## Test Status: COMPREHENSIVE TESTING IN PROGRESS +Testing basic note creation with various content types and structures. + +## Content Types Tested +- Plain text content ✓ +- Markdown formatting **bold**, *italic* +- Lists: + - Bullet points + - Numbered items +- Code blocks: `inline code` + +```python +# Block code +def test_function(): + return "Hello, Basic Memory!" +``` + +## Special Characters +- Unicode: café, naïve, résumé +- Emojis: 🚀 🔬 📝 +- Symbols: @#$%^&*() + +## Frontmatter Testing +This note should have proper frontmatter parsing. + +## Relations to Test +- connects_to [[Another Test Note]] +- validates [[Core Functionality Tests]] + +## Observations +- [success] Note creation initiated +- [test] Content variety included +- [validation] Special characters included + + +## Edit Test Results +- [success] Note reading via title lookup ✓ +- [success] Search functionality returns relevant results ✓ +- [success] Special characters (unicode, emojis) preserved ✓ +- [test] Now testing append edit operation ✓ + +## Performance Notes +- Note creation: Instantaneous +- Note reading: Fast response +- Search: Good relevance scoring + +## Next Tests +- Edit operations (append, prepend, find_replace) +- Move operations +- Cross-project functionality \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8d2ab1f74..d5a0fb7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "basic-memory" -version = "0.12.3" +dynamic = ["version"] description = "Local-first knowledge management combining Zettelkasten with knowledge graphs" readme = "README.md" requires-python = ">=3.12.1" @@ -47,7 +47,7 @@ basic-memory = "basic_memory.cli.main:app" bm = "basic_memory.cli.main:app" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"] build-backend = "hatchling.build" [tool.pytest.ini_options] @@ -79,6 +79,15 @@ dev-dependencies = [ "pyqt6>=6.8.1", ] +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +style = "pep440" +bump = true +fallback-version = "0.0.0" + [tool.pyright] include = ["src/"] exclude = ["**/__pycache__"] @@ -89,20 +98,6 @@ reportMissingTypeStubs = false pythonVersion = "3.12" -[tool.semantic_release] -version_variables = [ - "src/basic_memory/__init__.py:__version__", -] -version_toml = [ - "pyproject.toml:project.version", -] -major_on_zero = false -branch = "main" -changelog_file = "CHANGELOG.md" -build_command = "pip install uv && uv build" -dist_path = "dist/" -upload_to_pypi = true -commit_message = "chore(release): {version} [skip ci]" [tool.coverage.run] concurrency = ["thread", "gevent"] @@ -128,7 +123,8 @@ omit = [ "*/watch_service.py", # File system watching - complex integration testing "*/background_sync.py", # Background processes "*/cli/main.py", # CLI entry point + "*/mcp/tools/project_management.py", # Covered by integration tests ] [tool.logfire] -ignore_no_config = true +ignore_no_config = true \ No newline at end of file diff --git a/src/basic_memory/__init__.py b/src/basic_memory/__init__.py index 7f3df852e..32417ec4f 100644 --- a/src/basic_memory/__init__.py +++ b/src/basic_memory/__init__.py @@ -1,3 +1,9 @@ """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs""" -__version__ = "0.12.3" +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 diff --git a/src/basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py b/src/basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py index 8fab5bdda..c4e8f6d0a 100644 --- a/src/basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +++ b/src/basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py @@ -56,11 +56,6 @@ def upgrade() -> None: ); """) - # Print instruction to manually reindex after migration - print("\n------------------------------------------------------------------") - print("IMPORTANT: After migration completes, manually run the reindex command:") - print("basic-memory sync") - print("------------------------------------------------------------------\n") def downgrade() -> None: diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index 15a6fbae4..b3f6d9c0e 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -60,15 +60,18 @@ async def lifespan(app: FastAPI): # pragma: no cover # Include routers app.include_router(knowledge.router, prefix="/{project}") -app.include_router(management.router, prefix="/{project}") app.include_router(memory.router, prefix="/{project}") app.include_router(resource.router, prefix="/{project}") app.include_router(search.router, prefix="/{project}") -app.include_router(project.router, prefix="/{project}") +app.include_router(project.project_router, prefix="/{project}") app.include_router(directory_router.router, prefix="/{project}") app.include_router(prompt_router.router, prefix="/{project}") app.include_router(importer_router.router, prefix="/{project}") +# Project resource router works accross projects +app.include_router(project.project_resource_router) +app.include_router(management.router) + # Auth routes are handled by FastMCP automatically when auth is enabled diff --git a/src/basic_memory/api/routers/directory_router.py b/src/basic_memory/api/routers/directory_router.py index 6bc1256e7..b7d8a089a 100644 --- a/src/basic_memory/api/routers/directory_router.py +++ b/src/basic_memory/api/routers/directory_router.py @@ -1,6 +1,8 @@ """Router for directory tree operations.""" -from fastapi import APIRouter +from typing import List, Optional + +from fastapi import APIRouter, Query from basic_memory.deps import DirectoryServiceDep, ProjectIdDep from basic_memory.schemas.directory import DirectoryNode @@ -27,3 +29,35 @@ async def get_directory_tree( # Return the hierarchical tree return tree + + +@router.get("/list", response_model=List[DirectoryNode]) +async def list_directory( + directory_service: DirectoryServiceDep, + project_id: ProjectIdDep, + dir_name: str = Query("/", description="Directory path to list"), + depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"), + file_name_glob: Optional[str] = Query( + None, description="Glob pattern for filtering file names" + ), +): + """List directory contents with filtering and depth control. + + Args: + directory_service: Service for directory operations + project_id: ID of the current project + dir_name: Directory path to list (default: root "/") + depth: Recursion depth (1-10, default: 1 for immediate children only) + file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*") + + Returns: + List of DirectoryNode objects matching the criteria + """ + # Get directory listing with filtering + nodes = await directory_service.list_directory( + dir_name=dir_name, + depth=depth, + file_name_glob=file_name_glob, + ) + + return nodes diff --git a/src/basic_memory/api/routers/knowledge_router.py b/src/basic_memory/api/routers/knowledge_router.py index 567b28bb5..a3b12ebcc 100644 --- a/src/basic_memory/api/routers/knowledge_router.py +++ b/src/basic_memory/api/routers/knowledge_router.py @@ -10,6 +10,10 @@ get_search_service, SearchServiceDep, LinkResolverDep, + ProjectPathDep, + FileServiceDep, + ProjectConfigDep, + AppConfigDep, ) from basic_memory.schemas import ( EntityListResponse, @@ -17,6 +21,7 @@ DeleteEntitiesResponse, DeleteEntitiesRequest, ) +from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest from basic_memory.schemas.base import Permalink, Entity router = APIRouter(prefix="/knowledge", tags=["knowledge"]) @@ -43,41 +48,31 @@ async def create_entity( result = EntityResponse.model_validate(entity) logger.info( - "API response", - endpoint="create_entity", - title=result.title, - permalink=result.permalink, - status_code=201, + f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201" ) return result @router.put("/entities/{permalink:path}", response_model=EntityResponse) async def create_or_update_entity( + project: ProjectPathDep, permalink: Permalink, data: Entity, response: Response, background_tasks: BackgroundTasks, entity_service: EntityServiceDep, search_service: SearchServiceDep, + file_service: FileServiceDep, ) -> EntityResponse: """Create or update an entity. If entity exists, it will be updated, otherwise created.""" logger.info( - "API request", - endpoint="create_or_update_entity", - permalink=permalink, - entity_type=data.entity_type, - title=data.title, + f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}" ) # Validate permalink matches if data.permalink != permalink: logger.warning( - "API validation error", - endpoint="create_or_update_entity", - permalink=permalink, - data_permalink=data.permalink, - error="Permalink mismatch", + f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}", ) raise HTTPException( status_code=400, @@ -93,16 +88,107 @@ async def create_or_update_entity( result = EntityResponse.model_validate(entity) logger.info( - "API response", - endpoint="create_or_update_entity", - title=result.title, - permalink=result.permalink, - created=created, - status_code=response.status_code, + f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}" ) return result +@router.patch("/entities/{identifier:path}", response_model=EntityResponse) +async def edit_entity( + identifier: str, + data: EditEntityRequest, + background_tasks: BackgroundTasks, + entity_service: EntityServiceDep, + search_service: SearchServiceDep, +) -> EntityResponse: + """Edit an existing entity using various operations like append, prepend, find_replace, or replace_section. + + This endpoint allows for targeted edits without requiring the full entity content. + """ + logger.info( + f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'" + ) + + try: + # Edit the entity using the service + entity = await entity_service.edit_entity( + identifier=identifier, + operation=data.operation, + content=data.content, + section=data.section, + find_text=data.find_text, + expected_replacements=data.expected_replacements, + ) + + # Reindex the updated entity + await search_service.index_entity(entity, background_tasks=background_tasks) + + # Return the updated entity response + result = EntityResponse.model_validate(entity) + + logger.info( + "API response", + endpoint="edit_entity", + identifier=identifier, + operation=data.operation, + permalink=result.permalink, + status_code=200, + ) + + return result + + except Exception as e: + logger.error(f"Error editing entity: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/move") +async def move_entity( + data: MoveEntityRequest, + background_tasks: BackgroundTasks, + entity_service: EntityServiceDep, + project_config: ProjectConfigDep, + app_config: AppConfigDep, + search_service: SearchServiceDep, +) -> EntityResponse: + """Move an entity to a new file location with project consistency. + + This endpoint moves a note to a different path while maintaining project + consistency and optionally updating permalinks based on configuration. + """ + logger.info( + f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'" + ) + + try: + # Move the entity using the service + moved_entity = await entity_service.move_entity( + identifier=data.identifier, + destination_path=data.destination_path, + project_config=project_config, + app_config=app_config, + ) + + # Get the moved entity to reindex it + entity = await entity_service.link_resolver.resolve_link(data.destination_path) + if entity: + await search_service.index_entity(entity, background_tasks=background_tasks) + + logger.info( + "API response", + endpoint="move_entity", + identifier=data.identifier, + destination=data.destination_path, + status_code=200, + ) + result = EntityResponse.model_validate(moved_entity) + return result + + except Exception as e: + logger.error(f"Error moving entity: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + ## Read endpoints @@ -164,8 +250,8 @@ async def delete_entity( # Delete the entity deleted = await entity_service.delete_entity(entity.permalink or entity.id) - # Remove from search index - background_tasks.add_task(search_service.delete_by_permalink, entity.permalink) + # Remove from search index (entity, observations, and relations) + background_tasks.add_task(search_service.handle_delete, entity) result = DeleteEntitiesResponse(deleted=deleted) return result @@ -188,4 +274,4 @@ async def delete_entities( background_tasks.add_task(search_service.delete_by_permalink, permalink) result = DeleteEntitiesResponse(deleted=deleted) - return result + return result \ No newline at end of file diff --git a/src/basic_memory/api/routers/project_router.py b/src/basic_memory/api/routers/project_router.py index 3c5271c58..dea5f4126 100644 --- a/src/basic_memory/api/routers/project_router.py +++ b/src/basic_memory/api/routers/project_router.py @@ -8,17 +8,18 @@ from basic_memory.schemas.project_info import ( ProjectList, ProjectItem, - ProjectSwitchRequest, + ProjectInfoRequest, ProjectStatusResponse, - ProjectWatchStatus, ) -# Define the router - we'll combine stats and project operations -router = APIRouter(prefix="/project", tags=["project"]) +# Router for resources in a specific project +project_router = APIRouter(prefix="/project", tags=["project"]) +# Router for managing project resources +project_resource_router = APIRouter(prefix="/projects", tags=["project_management"]) -# Get project information (moved from project_info_router.py) -@router.get("/info", response_model=ProjectInfoResponse) + +@project_router.get("/info", response_model=ProjectInfoResponse) async def get_project_info( project_service: ProjectServiceDep, ) -> ProjectInfoResponse: @@ -26,8 +27,49 @@ async def get_project_info( return await project_service.get_project_info() +# Update a project +@project_router.patch("/{name}", response_model=ProjectStatusResponse) +async def update_project( + project_service: ProjectServiceDep, + project_name: str = Path(..., description="Name of the project to update"), + path: Optional[str] = Body(None, description="New path for the project"), + is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"), +) -> ProjectStatusResponse: + """Update a project's information in configuration and database. + + Args: + project_name: The name of the project to update + path: Optional new path for the project + is_active: Optional status update for the project + + Returns: + Response confirming the project was updated + """ + try: # pragma: no cover + # Get original project info for the response + old_project = ProjectItem( + name=project_name, + path=project_service.projects.get(project_name, ""), + ) + + await project_service.update_project(project_name, updated_path=path, is_active=is_active) + + # Get updated project info + updated_path = path if path else project_service.projects.get(project_name, "") + + return ProjectStatusResponse( + message=f"Project '{project_name}' updated successfully", + status="success", + default=(project_name == project_service.default_project), + old_project=old_project, + new_project=ProjectItem(name=project_name, path=updated_path), + ) + except ValueError as e: # pragma: no cover + raise HTTPException(status_code=400, detail=str(e)) + + # List all available projects -@router.get("/projects", response_model=ProjectList) +@project_resource_router.get("/projects", response_model=ProjectList) async def list_projects( project_service: ProjectServiceDep, ) -> ProjectList: @@ -36,32 +78,28 @@ async def list_projects( Returns: A list of all projects with metadata """ - projects_dict = project_service.projects + projects = await project_service.list_projects() default_project = project_service.default_project - current_project = project_service.current_project - project_items = [] - for name, path in projects_dict.items(): - project_items.append( - ProjectItem( - name=name, - path=path, - is_default=(name == default_project), - is_current=(name == current_project), - ) + project_items = [ + ProjectItem( + name=project.name, + path=project.path, + is_default=project.is_default or False, ) + for project in projects + ] return ProjectList( projects=project_items, default_project=default_project, - current_project=current_project, ) # Add a new project -@router.post("/projects", response_model=ProjectStatusResponse) +@project_resource_router.post("/projects", response_model=ProjectStatusResponse) async def add_project( - project_data: ProjectSwitchRequest, + project_data: ProjectInfoRequest, project_service: ProjectServiceDep, ) -> ProjectStatusResponse: """Add a new project to configuration and database. @@ -82,10 +120,8 @@ async def add_project( message=f"Project '{project_data.name}' added successfully", status="success", default=project_data.set_default, - new_project=ProjectWatchStatus( - name=project_data.name, - path=project_data.path, - watch_status=None, + new_project=ProjectItem( + name=project_data.name, path=project_data.path, is_default=project_data.set_default ), ) except ValueError as e: # pragma: no cover @@ -93,7 +129,7 @@ async def add_project( # Remove a project -@router.delete("/projects/{name}", response_model=ProjectStatusResponse) +@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse) async def remove_project( project_service: ProjectServiceDep, name: str = Path(..., description="Name of the project to remove"), @@ -106,28 +142,26 @@ async def remove_project( Returns: Response confirming the project was removed """ - try: # pragma: no cover - # Get project info before removal for the response - old_project = ProjectWatchStatus( - name=name, - path=project_service.projects.get(name, ""), - watch_status=None, - ) + try: + old_project = await project_service.get_project(name) + if not old_project: # pragma: no cover + raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover await project_service.remove_project(name) - return ProjectStatusResponse( # pyright: ignore [reportCallIssue] + return ProjectStatusResponse( message=f"Project '{name}' removed successfully", status="success", default=False, - old_project=old_project, + old_project=ProjectItem(name=old_project.name, path=old_project.path), + new_project=None, ) except ValueError as e: # pragma: no cover raise HTTPException(status_code=400, detail=str(e)) # Set a project as default -@router.put("/projects/{name}/default", response_model=ProjectStatusResponse) +@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse) async def set_default_project( project_service: ProjectServiceDep, name: str = Path(..., description="Name of the project to set as default"), @@ -140,78 +174,39 @@ async def set_default_project( Returns: Response confirming the project was set as default """ - try: # pragma: no cover + try: # Get the old default project - old_default = project_service.default_project - old_project = None - if old_default != name: - old_project = ProjectWatchStatus( - name=old_default, - path=project_service.projects.get(old_default, ""), - watch_status=None, + default_name = project_service.default_project + default_project = await project_service.get_project(default_name) + if not default_project: # pragma: no cover + raise HTTPException( # pragma: no cover + status_code=404, detail=f"Default Project: '{default_name}' does not exist" ) + # get the new project + new_default_project = await project_service.get_project(name) + if not new_default_project: # pragma: no cover + raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover + await project_service.set_default_project(name) return ProjectStatusResponse( message=f"Project '{name}' set as default successfully", status="success", default=True, - old_project=old_project, - new_project=ProjectWatchStatus( + old_project=ProjectItem(name=default_name, path=default_project.path), + new_project=ProjectItem( name=name, - path=project_service.projects.get(name, ""), - watch_status=None, + path=new_default_project.path, + is_default=True, ), ) except ValueError as e: # pragma: no cover raise HTTPException(status_code=400, detail=str(e)) -# Update a project -@router.patch("/projects/{name}", response_model=ProjectStatusResponse) -async def update_project( - project_service: ProjectServiceDep, - name: str = Path(..., description="Name of the project to update"), - path: Optional[str] = Body(None, description="New path for the project"), - is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"), -) -> ProjectStatusResponse: - """Update a project's information in configuration and database. - - Args: - name: The name of the project to update - path: Optional new path for the project - is_active: Optional status update for the project - - Returns: - Response confirming the project was updated - """ - try: # pragma: no cover - # Get original project info for the response - old_project = ProjectWatchStatus( - name=name, - path=project_service.projects.get(name, ""), - watch_status=None, - ) - - await project_service.update_project(name, updated_path=path, is_active=is_active) - - # Get updated project info - updated_path = path if path else project_service.projects.get(name, "") - - return ProjectStatusResponse( - message=f"Project '{name}' updated successfully", - status="success", - default=(name == project_service.default_project), - old_project=old_project, - new_project=ProjectWatchStatus(name=name, path=updated_path, watch_status=None), - ) - except ValueError as e: # pragma: no cover - raise HTTPException(status_code=400, detail=str(e)) - - # Synchronize projects between config and database -@router.post("/sync", response_model=ProjectStatusResponse) +@project_resource_router.post("/sync", response_model=ProjectStatusResponse) async def synchronize_projects( project_service: ProjectServiceDep, ) -> ProjectStatusResponse: diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index 413f5cdc1..e7ca7dbf9 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -2,6 +2,9 @@ import typer +from basic_memory.config import get_project_config +from basic_memory.mcp.project_session import session + def version_callback(value: bool) -> None: """Show version and exit.""" @@ -39,25 +42,6 @@ def app_callback( ) -> None: """Basic Memory - Local-first personal knowledge management.""" - # We use the project option to set the BASIC_MEMORY_PROJECT environment variable - # The config module will pick this up when loading - if project: # pragma: no cover - import os - import importlib - from basic_memory import config as config_module - - # Set the environment variable - os.environ["BASIC_MEMORY_PROJECT"] = project - - # Reload the config module to pick up the new project - importlib.reload(config_module) - - # Update the local reference - global app_config - from basic_memory.config import app_config as new_config - - app_config = new_config - # Run initialization for every command unless --version was specified if not version and ctx.invoked_subcommand is not None: from basic_memory.config import app_config @@ -65,6 +49,21 @@ def app_callback( ensure_initialization(app_config) + # Initialize MCP session with the specified project or default + if project: # pragma: no cover + # Use the project specified via --project flag + current_project_config = get_project_config(project) + session.set_current_project(current_project_config.name) + + # Update the global config to use this project + from basic_memory.config import update_current_project + + update_current_project(project) + else: + # Use the default project + current_project = app_config.default_project + session.set_current_project(current_project) + # Register sub-command groups import_app = typer.Typer(help="Import data from various sources") diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index d64d1f0bd..125c3b65a 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -10,7 +10,8 @@ from basic_memory.cli.app import app from basic_memory.config import config -from basic_memory.mcp.tools.project_info import project_info +from basic_memory.mcp.project_session import session +from basic_memory.mcp.resources.project_info import project_info import json from datetime import datetime @@ -35,7 +36,7 @@ def format_path(path: str) -> str: """Format a path for display, using ~ for home directory.""" home = str(Path.home()) if path.startswith(home): - return path.replace(home, "~", 1) + return path.replace(home, "~", 1) # pragma: no cover return path @@ -58,7 +59,7 @@ def list_projects() -> None: for project in result.projects: is_default = "✓" if project.is_default else "" - is_active = "✓" if project.is_current else "" + is_active = "✓" if session.get_current_project() == project.name else "" table.add_row(project.name, format_path(project.path), is_default, is_active) console.print(table) @@ -148,40 +149,6 @@ def set_default_project( console.print("[green]Project activated for current session[/green]") -@project_app.command("current") -def show_current_project() -> None: - """Show the current project.""" - # Use API to get current project - - project_url = config.project_url - - try: - response = asyncio.run(call_get(client, f"{project_url}/project/projects")) - result = ProjectList.model_validate(response.json()) - - # Find the current project from the API response - current_project = result.current_project - default_project = result.default_project - - # Find the project details in the list - for project in result.projects: - if project.name == current_project: - console.print(f"Current project: [cyan]{project.name}[/cyan]") - console.print(f"Path: [green]{format_path(project.path)}[/green]") - # Use app_config for database_path, not project config - from basic_memory.config import app_config - - console.print( - f"Database: [blue]{format_path(str(app_config.app_database_path))}[/blue]" - ) - console.print(f"Default project: [yellow]{default_project}[/yellow]") - break - except Exception as e: - console.print(f"[red]Error getting current project: {str(e)}[/red]") - console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]") - raise typer.Exit(1) - - @project_app.command("sync") def synchronize_projects() -> None: """Synchronize projects between configuration file and database.""" diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 364b75baa..efbe6958e 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -9,10 +9,12 @@ from loguru import logger from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from setuptools.command.setopt import config_file import basic_memory from basic_memory.utils import setup_logging, generate_permalink + DATABASE_NAME = "memory.db" APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory DATA_DIR_NAME = ".basic-memory" @@ -147,7 +149,11 @@ class ConfigManager: def __init__(self) -> None: """Initialize the configuration manager.""" - self.config_dir = Path.home() / DATA_DIR_NAME + home = os.getenv("HOME", Path.home()) + if isinstance(home, str): + home = Path(home) + + self.config_dir = home / DATA_DIR_NAME self.config_file = self.config_dir / CONFIG_FILE_NAME # Ensure config directory exists @@ -156,9 +162,6 @@ def __init__(self) -> None: # Load or create configuration self.config = self.load_config() - # Current project context for the session - self.current_project_id: int - def load_config(self) -> BasicMemoryConfig: """Load configuration from file or create default.""" if self.config_file.exists(): @@ -177,7 +180,7 @@ def load_config(self) -> BasicMemoryConfig: def save_config(self, config: BasicMemoryConfig) -> None: """Save configuration to file.""" - try: + try: self.config_file.write_text(json.dumps(config.model_dump(), indent=2)) except Exception as e: # pragma: no cover logger.error(f"Failed to save config: {e}") @@ -192,7 +195,7 @@ def default_project(self) -> str: """Get the default project name.""" return self.config.default_project - def add_project(self, name: str, path: str) -> None: + def add_project(self, name: str, path: str) -> ProjectConfig: """Add a new project to the configuration.""" if name in self.config.projects: # pragma: no cover raise ValueError(f"Project '{name}' already exists") @@ -203,6 +206,7 @@ def add_project(self, name: str, path: str) -> None: self.config.projects[name] = str(project_path) self.save_config(self.config) + return ProjectConfig(name=name, home=project_path) def remove_project(self, name: str) -> None: """Remove a project from the configuration.""" @@ -224,15 +228,36 @@ def set_default_project(self, name: str) -> None: self.save_config(self.config) -def get_project_config() -> ProjectConfig: - """Get the project configuration for the current session.""" +def get_project_config(project_name: Optional[str] = None) -> ProjectConfig: + """ + Get the project configuration for the current session. + If project_name is provided, it will be used instead of the default project. + """ - # Get project name from environment variable or use provided name or default - env_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None) - actual_project_name = env_project_name or config_manager.default_project + actual_project_name = None + + # load the config from file + global app_config + app_config = config_manager.load_config() + + # Get project name from environment variable + os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None) + if os_project_name: # pragma: no cover + logger.warning( + f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}" + ) + actual_project_name = project_name + # if the project_name is passed in, use it + elif not project_name: + # use default + actual_project_name = app_config.default_project + else: # pragma: no cover + actual_project_name = project_name # the config contains a dict[str,str] of project names and absolute paths - project_path = config_manager.projects.get(actual_project_name) + assert actual_project_name is not None, "actual_project_name cannot be None" + + project_path = app_config.projects.get(actual_project_name) if not project_path: # pragma: no cover raise ValueError(f"Project '{actual_project_name}' not found") @@ -249,6 +274,15 @@ def get_project_config() -> ProjectConfig: config: ProjectConfig = get_project_config() +def update_current_project(project_name: str) -> None: + """Update the global config to use a different project. + + This is used by the CLI when --project flag is specified. + """ + global config + config = get_project_config(project_name) # pragma: no cover + + # setup logging to a single log file in user home directory user_home = Path.home() log_dir = user_home / DATA_DIR_NAME diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 0e4205c1a..92affb143 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -1,6 +1,7 @@ """Dependency injection functions for basic-memory services.""" from typing import Annotated +from loguru import logger from fastapi import Depends, HTTPException, Path, status from sqlalchemy.ext.asyncio import ( @@ -8,9 +9,10 @@ AsyncEngine, async_sessionmaker, ) +import pathlib from basic_memory import db -from basic_memory.config import ProjectConfig, config, BasicMemoryConfig +from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.importers import ( ChatGPTImporter, ClaudeConversationsImporter, @@ -44,8 +46,30 @@ def get_app_config() -> BasicMemoryConfig: # pragma: no cover ## project -def get_project_config() -> ProjectConfig: # pragma: no cover - return config +async def get_project_config( + project: "ProjectPathDep", project_repository: "ProjectRepositoryDep" +) -> ProjectConfig: # pragma: no cover + """Get the current project referenced from request state. + + Args: + request: The current request object + project_repository: Repository for project operations + + Returns: + The resolved project config + + Raises: + HTTPException: If project is not found + """ + + project_obj = await project_repository.get_by_permalink(str(project)) + if project_obj: + return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path)) + + # Not found + raise HTTPException( # pragma: no cover + status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found." + ) ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover @@ -203,7 +227,12 @@ async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProc async def get_file_service( project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep ) -> FileService: - return FileService(project_config.home, markdown_processor) + logger.debug( + f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}" + ) + file_service = FileService(project_config.home, markdown_processor) + logger.debug(f"Created FileService for project: {file_service} ") + return file_service FileServiceDep = Annotated[FileService, Depends(get_file_service)] diff --git a/src/basic_memory/markdown/entity_parser.py b/src/basic_memory/markdown/entity_parser.py index 15aa67c97..6bea59fb9 100644 --- a/src/basic_memory/markdown/entity_parser.py +++ b/src/basic_memory/markdown/entity_parser.py @@ -92,7 +92,6 @@ def parse_date(self, value: Any) -> Optional[datetime]: async def parse_file(self, path: Path | str) -> EntityMarkdown: """Parse markdown file into EntityMarkdown.""" - # TODO move to api endpoint to check if absolute path was requested # Check if the path is already absolute if ( isinstance(path, Path) @@ -101,12 +100,16 @@ async def parse_file(self, path: Path | str) -> EntityMarkdown: ): absolute_path = Path(path) else: - absolute_path = self.base_path / path + absolute_path = self.get_file_path(path) # Parse frontmatter and content using python-frontmatter file_content = absolute_path.read_text(encoding="utf-8") return await self.parse_file_content(absolute_path, file_content) + def get_file_path(self, path): + """Get absolute path for a file using the base path for the project.""" + return self.base_path / path + async def parse_file_content(self, absolute_path, file_content): post = frontmatter.loads(file_content) # Extract file stat info diff --git a/src/basic_memory/mcp/project_session.py b/src/basic_memory/mcp/project_session.py new file mode 100644 index 000000000..059cbf3ef --- /dev/null +++ b/src/basic_memory/mcp/project_session.py @@ -0,0 +1,103 @@ +"""Project session management for Basic Memory MCP server. + +Provides simple in-memory project context for MCP tools, allowing users to switch +between projects during a conversation without restarting the server. +""" + +from dataclasses import dataclass +from typing import Optional +from loguru import logger + +from basic_memory.config import ProjectConfig, get_project_config + + +@dataclass +class ProjectSession: + """Simple in-memory project context for MCP session. + + This class manages the current project context that tools use when no explicit + project is specified. It's initialized with the default project from config + and can be changed during the conversation. + """ + + current_project: Optional[str] = None + default_project: Optional[str] = None + + def initialize(self, default_project: str) -> None: + """Set the default project from config on startup. + + Args: + default_project: The project name from configuration + """ + self.default_project = default_project + self.current_project = default_project + logger.info(f"Initialized project session with default project: {default_project}") + + def get_current_project(self) -> str: + """Get the currently active project name. + + Returns: + The current project name, falling back to default, then 'main' + """ + return self.current_project or self.default_project or "main" + + def set_current_project(self, project_name: str) -> None: + """Set the current project context. + + Args: + project_name: The project to switch to + """ + previous = self.current_project + self.current_project = project_name + logger.info(f"Switched project context: {previous} -> {project_name}") + + def get_default_project(self) -> str: + """Get the default project name from startup. + + Returns: + The default project name, or 'main' if not set + """ + return self.default_project or "main" # pragma: no cover + + def reset_to_default(self) -> None: # pragma: no cover + """Reset current project back to the default project.""" + self.current_project = self.default_project # pragma: no cover + logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover + + +# Global session instance +session = ProjectSession() + + +def get_active_project(project_override: Optional[str] = None) -> ProjectConfig: + """Get the active project name for a tool call. + + This is the main function tools should use to determine which project + to operate on. + + Args: + project_override: Optional explicit project name from tool parameter + + Returns: + The project name to use (override takes precedence over session context) + """ + if project_override: # pragma: no cover + project = get_project_config(project_override) + session.set_current_project(project_override) + return project + + current_project = session.get_current_project() + return get_project_config(current_project) + + +def add_project_metadata(result: str, project_name: str) -> str: + """Add project context as metadata footer for LLM awareness. + + Args: + result: The tool result string + project_name: The project name that was used + + Returns: + Result with project metadata footer + """ + return f"{result}\n\n" # pragma: no cover diff --git a/src/basic_memory/mcp/tools/project_info.py b/src/basic_memory/mcp/resources/project_info.py similarity index 89% rename from src/basic_memory/mcp/tools/project_info.py rename to src/basic_memory/mcp/resources/project_info.py index 2f0b3279f..a37576ebf 100644 --- a/src/basic_memory/mcp/tools/project_info.py +++ b/src/basic_memory/mcp/resources/project_info.py @@ -2,14 +2,15 @@ from loguru import logger -from basic_memory.config import get_project_config +from basic_memory.mcp.project_session import get_active_project from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_get from basic_memory.schemas import ProjectInfoResponse -@mcp.tool( +@mcp.resource( + uri="memory://project_info", description="Get information and statistics about the current Basic Memory project.", ) async def project_info() -> ProjectInfoResponse: @@ -44,7 +45,8 @@ async def project_info() -> ProjectInfoResponse: print(f"Basic Memory version: {info.system.version}") """ logger.info("Getting project info") - project_url = get_project_config().project_url + project_config = get_active_project() + project_url = project_config.project_url # Call the API endpoint response = await call_get(client, f"{project_url}/project/info") diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 712f9ef30..43a284123 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -15,6 +15,7 @@ from basic_memory.config import app_config from basic_memory.services.initialization import initialize_app from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider +from basic_memory.mcp.project_session import session from basic_memory.mcp.external_auth_provider import ( create_github_provider, create_google_provider, @@ -37,6 +38,10 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: """Manage application lifecycle with type-safe context""" # Initialize on startup watch_task = await initialize_app(app_config) + + # Initialize project session with default project + session.initialize(app_config.default_project) + try: yield AppContext(watch_task=watch_task) finally: diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 5cd8ced39..2f1242b2e 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -14,14 +14,34 @@ 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.project_management import ( + list_projects, + switch_project, + get_current_project, + set_default_project, + create_project, + delete_project, +) __all__ = [ "build_context", "canvas", + "create_project", "delete_note", + "delete_project", + "edit_note", + "get_current_project", + "list_directory", + "list_projects", + "move_note", "read_content", "read_note", "recent_activity", "search_notes", + "set_default_project", + "switch_project", "write_note", ] diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index 523cb0ddb..c7d9e42bb 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -4,10 +4,10 @@ from loguru import logger -from basic_memory.config import get_project_config from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_get +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import ( GraphContext, @@ -36,6 +36,7 @@ async def build_context( page: int = 1, page_size: int = 10, max_related: int = 10, + project: Optional[str] = None, ) -> GraphContext: """Get context needed to continue a discussion. @@ -50,6 +51,7 @@ async def build_context( page: Page number of results to return (default: 1) page_size: Number of results to return per page (default: 10) max_related: Maximum number of related results to return (default: 10) + project: Optional project name to build context from. If not provided, uses current active project. Returns: GraphContext containing: @@ -69,11 +71,15 @@ async def build_context( # Research the history of a feature build_context("memory://features/knowledge-graph", timeframe="3 months ago") + + # Build context from specific project + build_context("memory://specs/search", project="work-project") """ logger.info(f"Building context from {url}") url = normalize_memory_url(url) - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url response = await call_get( client, diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py index c9ac7d1b0..d0f5d204b 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -4,14 +4,14 @@ """ import json -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional from loguru import logger -from basic_memory.config import get_project_config from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_put +from basic_memory.mcp.project_session import get_active_project @mcp.tool( @@ -22,6 +22,7 @@ async def canvas( edges: List[Dict[str, Any]], title: str, folder: str, + project: Optional[str] = None, ) -> str: """Create an Obsidian canvas file with the provided nodes and edges. @@ -35,6 +36,7 @@ async def canvas( 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 + project: Optional project name to create canvas in. If not provided, uses current active project. Returns: A summary of the created canvas file @@ -72,8 +74,16 @@ async def canvas( ] } ``` + + Examples: + # Create canvas in current project + canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams") + + # Create canvas in specific project + canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams", project="work-project") """ - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url # Ensure path has .canvas extension file_title = title if title.endswith(".canvas") else f"{title}.canvas" diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 2f23afa2e..438c4164a 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -1,18 +1,19 @@ -from basic_memory.config import get_project_config -from basic_memory.mcp.tools.utils import call_delete - +from typing import Optional +from basic_memory.mcp.tools.utils import call_delete from basic_memory.mcp.server import mcp from basic_memory.mcp.async_client import client +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas import DeleteEntitiesResponse @mcp.tool(description="Delete a note by title or permalink") -async def delete_note(identifier: str) -> bool: +async def delete_note(identifier: str, project: Optional[str] = None) -> bool: """Delete a note from the knowledge base. Args: identifier: Note title or permalink + project: Optional project name to delete from. If not provided, uses current active project. Returns: True if note was deleted, False otherwise @@ -23,8 +24,12 @@ async def delete_note(identifier: str) -> bool: # Delete by permalink delete_note("notes/project-planning") + + # Delete from specific project + delete_note("notes/project-planning", project="work-project") """ - project_url = get_project_config().project_url + 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()) diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py new file mode 100644 index 000000000..79d70d553 --- /dev/null +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -0,0 +1,297 @@ +"""Edit note tool for Basic Memory MCP server.""" + +from typing import Optional + +from loguru import logger + +from basic_memory.mcp.async_client import client +from basic_memory.mcp.project_session import get_active_project +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.utils import call_patch +from basic_memory.schemas import EntityResponse + + +def _format_error_response( + error_message: str, + operation: str, + identifier: str, + find_text: Optional[str] = None, + expected_replacements: int = 1, +) -> str: + """Format helpful error responses for edit_note failures that guide the AI to retry successfully.""" + + # Entity not found errors + 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. + +## 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 + +## Alternative approach: +Use `write_note()` to create the note first, then edit it.""" + + # Find/replace specific errors + if operation == "find_replace": + if "Text to replace not found" in error_message: + return f"""# Edit Failed - Text Not Found + +The text '{find_text}' was not found in the note '{identifier}'. + +## Suggestions to try: +1. **Read the note first**: Use `read_note("{identifier}")` to see the current content +2. **Check for exact matches**: The search is case-sensitive and must match exactly +3. **Try a broader search**: Search for just part of the text you want to replace +4. **Use expected_replacements=0**: If you want to verify the text doesn't exist + +## Alternative approaches: +- Use `append` or `prepend` to add new content instead +- Use `replace_section` if you're trying to update a specific section""" + + if "Expected" in error_message and "occurrences" in error_message: + # Extract the actual count from error message if possible + import re + + match = re.search(r"found (\d+)", error_message) + actual_count = match.group(1) if match else "a different number of" + + return f"""# Edit Failed - Wrong Replacement Count + +Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}. + +## How to fix: +1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears +2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call +3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific + +## Example: +``` +edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count}) +```""" + + # Section replacement errors + if operation == "replace_section" and "Multiple sections" in error_message: + return f"""# Edit Failed - Duplicate Section Headers + +Multiple sections found with the same header in note '{identifier}'. + +## How to fix: +1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure +2. **Make headers unique**: Add more specific text to distinguish sections +3. **Use append instead**: Add content at the end rather than replacing a specific section + +## Alternative approach: +Use `find_replace` to update specific text within the duplicate sections.""" + + # Generic server/request errors + if ( + "Invalid request" in error_message or "malformed" in error_message.lower() + ): # pragma: no cover + return f"""# Edit Failed - Request Error + +There was a problem with the edit request to note '{identifier}': {error_message}. + +## Common causes and fixes: +1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists +2. **Invalid identifier format**: Try different identifier formats (title vs permalink) +3. **Empty or invalid content**: Check that your content is properly formatted +4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state + +## Troubleshooting steps: +1. Verify the note exists: `read_note("{identifier}")` +2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")` +3. Try again with the correct identifier from the search results""" + + # Fallback for other errors + return f"""# Edit Failed + +Error editing note '{identifier}': {error_message} + +## General troubleshooting: +1. **Verify the note exists**: Use `read_note("{identifier}")` to check +2. **Check your parameters**: Ensure all required parameters are provided correctly +3. **Read the note content first**: Use `read_note()` to understand the current structure +4. **Try a simpler operation**: Start with `append` if other operations fail + +## Need help? +- Use `search_notes()` to find notes +- Use `read_note()` to examine content before editing +- Check that identifiers, section headers, and find_text match exactly""" + + +@mcp.tool( + description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.", +) +async def edit_note( + identifier: str, + operation: str, + content: str, + section: Optional[str] = None, + find_text: Optional[str] = None, + expected_replacements: int = 1, + project: Optional[str] = None, +) -> str: + """Edit an existing markdown note in the knowledge base. + + This tool allows you to make targeted changes to existing notes without rewriting the entire content. + It supports various operations for different editing scenarios. + + Args: + identifier: The title, permalink, or memory:// URL of the note to edit + operation: The editing operation to perform: + - "append": Add content to the end of the note + - "prepend": Add content to the beginning of the note + - "find_replace": Replace occurrences of find_text with content + - "replace_section": Replace content under a specific markdown header + content: The content to add or use for replacement + section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation") + find_text: For find_replace operation - the text to find and replace + expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match) + project: Optional project name to delete from. If not provided, uses current active project. + + Returns: + A markdown formatted summary of the edit operation and resulting semantic content + + Examples: + # Add new content to end of note + edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y") + + # Add timestamp at beginning (frontmatter-aware) + edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n") + + # Update version number (single occurrence) + edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0") + + # Update version in multiple places with validation + edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3) + + # Replace text that appears multiple times - validate count first + edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5) + + # Replace implementation section + edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation") + + # 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 + + # Add new section to document + edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work") + + # Update status across document (expecting exactly 2 occurrences) + edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2) + + # Replace text in a file, specifying project name + edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project")) + + """ + active_project = get_active_project(project) + project_url = active_project.project_url + + logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation) + + # Validate operation + valid_operations = ["append", "prepend", "find_replace", "replace_section"] + if operation not in valid_operations: + raise ValueError( + f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}" + ) + + # Validate required parameters for specific operations + if operation == "find_replace" and not find_text: + raise ValueError("find_text parameter is required for find_replace operation") + if operation == "replace_section" and not section: + raise ValueError("section parameter is required for replace_section operation") + + # Use the PATCH endpoint to edit the entity + try: + # Prepare the edit request data + edit_data = { + "operation": operation, + "content": content, + } + + # Add optional parameters + if section: + edit_data["section"] = section + if find_text: + edit_data["find_text"] = find_text + if expected_replacements != 1: # Only send if different from default + edit_data["expected_replacements"] = str(expected_replacements) + + # Call the PATCH endpoint + url = f"{project_url}/knowledge/entities/{identifier}" + response = await call_patch(client, url, json=edit_data) + result = EntityResponse.model_validate(response.json()) + + # Format summary + summary = [ + f"# Edited note ({operation})", + f"project: {active_project.name}", + f"file_path: {result.file_path}", + f"permalink: {result.permalink}", + f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}", + ] + + # Add operation-specific details + if operation == "append": + lines_added = len(content.split("\n")) + summary.append(f"operation: Added {lines_added} lines to end of note") + elif operation == "prepend": + lines_added = len(content.split("\n")) + summary.append(f"operation: Added {lines_added} lines to beginning of note") + elif operation == "find_replace": + # For find_replace, we can't easily count replacements from here + # since we don't have the original content, but the server handled it + summary.append("operation: Find and replace operation completed") + elif operation == "replace_section": + summary.append(f"operation: Replaced content under section '{section}'") + + # Count observations by category (reuse logic from write_note) + categories = {} + if result.observations: + for obs in result.observations: + categories[obs.category] = categories.get(obs.category, 0) + 1 + + summary.append("\\n## Observations") + for category, count in sorted(categories.items()): + summary.append(f"- {category}: {count}") + + # Count resolved/unresolved relations + unresolved = 0 + resolved = 0 + if result.relations: + unresolved = sum(1 for r in result.relations if not r.to_id) + resolved = len(result.relations) - unresolved + + summary.append("\\n## Relations") + summary.append(f"- Resolved: {resolved}") + if unresolved: + summary.append(f"- Unresolved: {unresolved}") + + logger.info( + "MCP tool response", + tool="edit_note", + operation=operation, + permalink=result.permalink, + observations_count=len(result.observations), + relations_count=len(result.relations), + status_code=response.status_code, + ) + + return "\n".join(summary) + + except Exception as e: + logger.error(f"Error editing note: {e}") + return _format_error_response( + str(e), operation, identifier, find_text, expected_replacements + ) diff --git a/src/basic_memory/mcp/tools/list_directory.py b/src/basic_memory/mcp/tools/list_directory.py new file mode 100644 index 000000000..e7b811f4f --- /dev/null +++ b/src/basic_memory/mcp/tools/list_directory.py @@ -0,0 +1,154 @@ +"""List directory tool for Basic Memory MCP server.""" + +from typing import Optional + +from loguru import logger + +from basic_memory.mcp.async_client import client +from basic_memory.mcp.project_session import get_active_project +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.utils import call_get + + +@mcp.tool( + description="List directory contents with filtering and depth control.", +) +async def list_directory( + dir_name: str = "/", + depth: int = 1, + file_name_glob: Optional[str] = None, + project: Optional[str] = None, +) -> str: + """List directory contents from the knowledge base with optional filtering. + + This tool provides 'ls' functionality for browsing the knowledge base directory structure. + It can list immediate children or recursively explore subdirectories with depth control, + and supports glob pattern filtering for finding specific files. + + Args: + dir_name: Directory path to list (default: root "/") + Examples: "/", "/projects", "/research/ml" + depth: Recursion depth (1-10, default: 1 for immediate children only) + Higher values show subdirectory contents recursively + file_name_glob: Optional glob pattern for filtering file names + Examples: "*.md", "*meeting*", "project_*" + project: Optional project name to delete from. If not provided, uses current active project. + Returns: + Formatted listing of directory contents with file metadata + + Examples: + # List root directory contents + list_directory() + + # List specific folder + list_directory(dir_name="/projects") + + # Find all Python files + list_directory(file_name_glob="*.py") + + # Deep exploration of research folder + list_directory(dir_name="/research", depth=3) + + # Find meeting notes in projects folder + list_directory(dir_name="/projects", file_name_glob="*meeting*") + + # Find meeting notes in a specific project + list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project") + """ + active_project = get_active_project(project) + project_url = active_project.project_url + + # Prepare query parameters + params = { + "dir_name": dir_name, + "depth": str(depth), + } + if file_name_glob: + params["file_name_glob"] = file_name_glob + + logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'") + + # Call the API endpoint + response = await call_get( + client, + f"{project_url}/directory/list", + params=params, + ) + + nodes = response.json() + + if not nodes: + filter_desc = "" + if file_name_glob: + filter_desc = f" matching '{file_name_glob}'" + return f"No files found in directory '{dir_name}'{filter_desc}" + + # Format the results + output_lines = [] + if file_name_glob: + output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):") + else: + output_lines.append(f"Contents of '{dir_name}' (depth {depth}):") + output_lines.append("") + + # Group by type and sort + directories = [n for n in nodes if n["type"] == "directory"] + files = [n for n in nodes if n["type"] == "file"] + + # Sort by name + directories.sort(key=lambda x: x["name"]) + files.sort(key=lambda x: x["name"]) + + # Display directories first + for node in directories: + path_display = node["directory_path"] + output_lines.append(f"📁 {node['name']:<30} {path_display}") + + # Add separator if we have both directories and files + if directories and files: + output_lines.append("") + + # Display files with metadata + for node in files: + path_display = node["directory_path"] + title = node.get("title", "") + updated = node.get("updated_at", "") + + # Remove leading slash if present, requesting the file via read_note does not use the beginning slash' + if path_display.startswith("/"): + path_display = path_display[1:] + + # Format date if available + date_str = "" + if updated: + try: + from datetime import datetime + + dt = datetime.fromisoformat(updated.replace("Z", "+00:00")) + date_str = dt.strftime("%Y-%m-%d") + except Exception: # pragma: no cover + date_str = updated[:10] if len(updated) >= 10 else "" + + # Create formatted line + file_line = f"📄 {node['name']:<30} {path_display}" + if title and title != node["name"]: + file_line += f" | {title}" + if date_str: + file_line += f" | {date_str}" + + output_lines.append(file_line) + + # Add summary + output_lines.append("") + total_count = len(directories) + len(files) + summary_parts = [] + if directories: + summary_parts.append( + f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}" + ) + if files: + summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}") + + output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})") + + return "\n".join(output_lines) diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py new file mode 100644 index 000000000..77ab7a4a8 --- /dev/null +++ b/src/basic_memory/mcp/tools/move_note.py @@ -0,0 +1,87 @@ +"""Move note tool for Basic Memory MCP server.""" + +from typing import Optional + +from loguru import logger + +from basic_memory.mcp.async_client import client +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.utils import call_post +from basic_memory.mcp.project_session import get_active_project +from basic_memory.schemas import EntityResponse + + +@mcp.tool( + description="Move a note to a new location, updating database and maintaining links.", +) +async def move_note( + identifier: str, + destination_path: str, + project: Optional[str] = None, +) -> str: + """Move a note to a new file location within the same project. + + Args: + identifier: Entity identifier (title, permalink, or memory:// URL) + 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) + + Returns: + 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") + + Note: This operation moves notes within the specified project only. Moving notes + between different projects is not currently supported. + + The move operation: + - Updates the entity's file_path in the database + - Moves the physical file on the filesystem + - Optionally updates permalinks if configured + - Re-indexes the entity for search + - Maintains all observations and relations + """ + logger.debug(f"Moving note: {identifier} to {destination_path}") + + 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, + } + + # 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"", + ] + + # 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, + ) + + return result diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py new file mode 100644 index 000000000..d5206bf3b --- /dev/null +++ b/src/basic_memory/mcp/tools/project_management.py @@ -0,0 +1,300 @@ +"""Project management tools for Basic Memory MCP server. + +These tools allow users to switch between projects, list available projects, +and manage project context during conversations. +""" + +from fastmcp import Context +from loguru import logger + +from basic_memory.config import get_project_config +from basic_memory.mcp.async_client import client +from basic_memory.mcp.project_session import session, add_project_metadata +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete +from basic_memory.schemas import ProjectInfoResponse +from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest + + +@mcp.tool() +async def list_projects(ctx: Context | None = None) -> str: + """List all available projects with their status. + + Shows all Basic Memory projects that are available, indicating which one + is currently active and which is the default. + + Returns: + Formatted list of projects with status indicators + + Example: + list_projects() + """ + if ctx: # pragma: no cover + await ctx.info("Listing all available projects") + + # Get projects from API + response = await call_get(client, "/projects/projects") + project_list = ProjectList.model_validate(response.json()) + + current = session.get_current_project() + + result = "Available projects:\n" + + for project in project_list.projects: + indicators = [] + if project.name == current: + indicators.append("current") + if project.is_default: + indicators.append("default") + + if indicators: + result += f"• {project.name} ({', '.join(indicators)})\n" + else: + result += f"• {project.name}\n" + + return add_project_metadata(result, current) + + +@mcp.tool() +async def switch_project(project_name: str, ctx: Context | None = None) -> str: + """Switch to a different project context. + + Changes the active project context for all subsequent tool calls. + Shows a project summary after switching successfully. + + Args: + project_name: Name of the project to switch to + + Returns: + Confirmation message with project summary + + Example: + switch_project("work-notes") + switch_project("personal-journal") + """ + if ctx: # pragma: no cover + await ctx.info(f"Switching to project: {project_name}") + + current_project = session.get_current_project() + try: + # Validate project exists by getting project list + response = await call_get(client, "/projects/projects") + project_list = ProjectList.model_validate(response.json()) + + # Check if project exists + project_exists = any(p.name == project_name for p in project_list.projects) + if not project_exists: + available_projects = [p.name for p in project_list.projects] + return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}" + + # Switch to the project + session.set_current_project(project_name) + current_project = session.get_current_project() + project_config = get_project_config(current_project) + + # Get project info to show summary + try: + response = await call_get(client, f"{project_config.project_url}/project/info") + project_info = ProjectInfoResponse.model_validate(response.json()) + + result = f"✓ Switched to {project_name} project\n\n" + result += "Project Summary:\n" + result += f"• {project_info.statistics.total_entities} entities\n" + result += f"• {project_info.statistics.total_observations} observations\n" + result += f"• {project_info.statistics.total_relations} relations\n" + + except Exception as e: + # If we can't get project info, still confirm the switch + logger.warning(f"Could not get project info for {project_name}: {e}") + result = f"✓ Switched to {project_name} project\n\n" + result += "Project summary unavailable.\n" + + return add_project_metadata(result, project_name) + + except Exception as e: + logger.error(f"Error switching to project {project_name}: {e}") + # Revert to previous project on error + session.set_current_project(current_project) + raise e + + +@mcp.tool() +async def get_current_project(ctx: Context | None = None) -> str: + """Show the currently active project and basic stats. + + Displays which project is currently active and provides basic information + about it. + + Returns: + Current project name and basic statistics + + Example: + get_current_project() + """ + if ctx: # pragma: no cover + await ctx.info("Getting current project information") + + current_project = session.get_current_project() + project_config = get_project_config(current_project) + result = f"Current project: {current_project}\n\n" + + # get project stats + response = await call_get(client, f"{project_config.project_url}/project/info") + project_info = ProjectInfoResponse.model_validate(response.json()) + + result += f"• {project_info.statistics.total_entities} entities\n" + result += f"• {project_info.statistics.total_observations} observations\n" + result += f"• {project_info.statistics.total_relations} relations\n" + + default_project = session.get_default_project() + if current_project != default_project: + result += f"• Default project: {default_project}\n" + + return add_project_metadata(result, current_project) + + +@mcp.tool() +async def set_default_project(project_name: str, ctx: Context | None = None) -> str: + """Set default project in config. Requires restart to take effect. + + Updates the configuration to use a different default project. This change + only takes effect after restarting the Basic Memory server. + + Args: + project_name: Name of the project to set as default + + Returns: + Confirmation message about config update + + Example: + set_default_project("work-notes") + """ + if ctx: # pragma: no cover + await ctx.info(f"Setting default project to: {project_name}") + + # Call API to set default project + response = await call_put(client, f"/projects/{project_name}/default") + status_response = ProjectStatusResponse.model_validate(response.json()) + + result = f"✓ {status_response.message}\n\n" + result += "Restart Basic Memory for this change to take effect:\n" + result += "basic-memory mcp\n" + + if status_response.old_project: + result += f"\nPrevious default: {status_response.old_project.name}\n" + + return add_project_metadata(result, session.get_current_project()) + + +@mcp.tool() +async def create_project( + project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None +) -> str: + """Create a new Basic Memory project. + + Creates a new project with the specified name and path. The project directory + will be created if it doesn't exist. Optionally sets the new project as default. + + Args: + project_name: Name for the new project (must be unique) + project_path: File system path where the project will be stored + set_default: Whether to set this project as the default (optional, defaults to False) + + Returns: + Confirmation message with project details + + Example: + create_project("my-research", "~/Documents/research") + create_project("work-notes", "/home/user/work", set_default=True) + """ + if ctx: # pragma: no cover + await ctx.info(f"Creating project: {project_name} at {project_path}") + + # Create the project request + project_request = ProjectInfoRequest( + name=project_name, path=project_path, set_default=set_default + ) + + # Call API to create project + response = await call_post(client, "/projects/projects", json=project_request.model_dump()) + status_response = ProjectStatusResponse.model_validate(response.json()) + + result = f"✓ {status_response.message}\n\n" + + if status_response.new_project: + result += "Project Details:\n" + result += f"• Name: {status_response.new_project.name}\n" + result += f"• Path: {status_response.new_project.path}\n" + + if set_default: + result += "• Set as default project\n" + + result += "\nProject is now available for use.\n" + + # If project was set as default, update session + if set_default: + session.set_current_project(project_name) + + return add_project_metadata(result, session.get_current_project()) + + +@mcp.tool() +async def delete_project(project_name: str, ctx: Context | None = None) -> str: + """Delete a Basic Memory project. + + Removes a project from the configuration and database. This does NOT delete + the actual files on disk - only removes the project from Basic Memory's + configuration and database records. + + Args: + project_name: Name of the project to delete + + Returns: + Confirmation message about project deletion + + Example: + delete_project("old-project") + + Warning: + This action cannot be undone. The project will need to be re-added + to access its content through Basic Memory again. + """ + if ctx: # pragma: no cover + await ctx.info(f"Deleting project: {project_name}") + + current_project = session.get_current_project() + + # Check if trying to delete current project + if project_name == current_project: + raise ValueError( + f"Cannot delete the currently active project '{project_name}'. Switch to a different project first." + ) + + # Get project info before deletion to validate it exists + response = await call_get(client, "/projects/projects") + project_list = ProjectList.model_validate(response.json()) + + # Check if project exists + project_exists = any(p.name == project_name for p in project_list.projects) + if not project_exists: + available_projects = [p.name for p in project_list.projects] + raise ValueError( + f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}" + ) + + # Call API to delete project + response = await call_delete(client, f"/projects/{project_name}") + status_response = ProjectStatusResponse.model_validate(response.json()) + + result = f"✓ {status_response.message}\n\n" + + if status_response.old_project: + result += "Removed project details:\n" + result += f"• Name: {status_response.old_project.name}\n" + if hasattr(status_response.old_project, "path"): + result += f"• Path: {status_response.old_project.path}\n" + + result += "Files remain on disk but project is no longer tracked by Basic Memory.\n" + result += "Re-add the project to access its content again.\n" + + return add_project_metadata(result, session.get_current_project()) diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 8dae22b78..a39ecd96d 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -5,18 +5,19 @@ Files are read directly without any knowledge graph processing. """ +from typing import Optional +import base64 +import io + from loguru import logger +from PIL import Image as PILImage -from basic_memory.config import get_project_config from basic_memory.mcp.server import mcp from basic_memory.mcp.async_client import client from basic_memory.mcp.tools.utils import call_get +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas.memory import memory_url_path -import base64 -import io -from PIL import Image as PILImage - def calculate_target_params(content_length): """Calculate initial quality and size based on input file size""" @@ -145,7 +146,7 @@ def optimize_image(img, content_length, max_output_bytes=350000): @mcp.tool(description="Read a file's raw content by path or permalink") -async def read_content(path: str) -> dict: +async def read_content(path: str, project: Optional[str] = None) -> dict: """Read a file's raw content by path or permalink. This tool provides direct access to file content in the knowledge base, @@ -159,6 +160,7 @@ async def read_content(path: str) -> dict: - A regular file path (docs/example.md) - A memory URL (memory://docs/example) - A permalink (docs/example) + project: Optional project name to read from. If not provided, uses current active project. Returns: A dictionary with the file content and metadata: @@ -176,10 +178,14 @@ async def read_content(path: str) -> dict: # Read using memory URL content = await read_file("memory://docs/architecture") + + # Read from specific project + content = await read_content("docs/example.md", project="work-project") """ logger.info("Reading file", path=path) - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url url = memory_url_path(path) response = await call_get(client, f"{project_url}/resource/{url}") diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index a83f209ac..f62b13fdd 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -1,21 +1,24 @@ """Read note tool for Basic Memory MCP server.""" from textwrap import dedent +from typing import Optional from loguru import logger -from basic_memory.config import get_project_config from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.search import search_notes from basic_memory.mcp.tools.utils import call_get +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas.memory import memory_url_path @mcp.tool( description="Read a markdown note by title or permalink.", ) -async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: +async def read_note( + identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None +) -> str: """Read a markdown note from the knowledge base. This tool finds and retrieves a note by its title, permalink, or content search, @@ -27,6 +30,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: Can be a full memory:// URL, a permalink, a title, or search text 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 full markdown content of the note if found, or helpful guidance if not found. @@ -43,9 +47,13 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: # Read with pagination read_note("Project Updates", page=2, page_size=5) + + # Read from specific project + read_note("Meeting Notes", project="work-project") """ - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url # Get the file via REST API - first try direct permalink lookup entity_path = memory_url_path(identifier) @@ -66,7 +74,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: # Fallback 1: Try title search via API logger.info(f"Search title for: {identifier}") - title_results = await search_notes(query=identifier, search_type="title") + title_results = await search_notes(query=identifier, search_type="title", project=project) if title_results and title_results.results: result = title_results.results[0] # Get the first/best match @@ -90,7 +98,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: # 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") + text_results = await search_notes(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: diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index 267edb53e..4f8b1c3d9 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -1,13 +1,13 @@ """Recent activity tool for Basic Memory MCP server.""" -from typing import List, Union +from typing import List, Union, Optional from loguru import logger -from basic_memory.config import get_project_config from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_get +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import GraphContext from basic_memory.schemas.search import SearchItemType @@ -32,6 +32,7 @@ async def recent_activity( page: int = 1, page_size: int = 10, max_related: int = 10, + project: Optional[str] = None, ) -> GraphContext: """Get recent activity across the knowledge base. @@ -52,6 +53,7 @@ async def recent_activity( page: Page number of results to return (default: 1) page_size: Number of results to return per page (default: 10) max_related: Maximum number of related results to return (default: 10) + project: Optional project name to get activity from. If not provided, uses current active project. Returns: GraphContext containing: @@ -75,6 +77,9 @@ async def recent_activity( # Look back further with more context recent_activity(type="entity", depth=2, timeframe="2 weeks ago") + # Get activity from specific project + recent_activity(type="entity", project="work-project") + Notes: - Higher depth values (>3) may impact performance with large result sets - For focused queries, consider using build_context with a specific URI @@ -115,7 +120,8 @@ async def recent_activity( # Add validated types to params params["type"] = [t.value for t in validated_types] # pyright: ignore - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url response = await call_get( client, diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 69f7a29e3..9f388299d 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -4,10 +4,10 @@ from loguru import logger -from basic_memory.config import get_project_config from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_post +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse @@ -22,6 +22,7 @@ async def search_notes( types: Optional[List[str]] = None, entity_types: Optional[List[str]] = None, after_date: Optional[str] = None, + project: Optional[str] = None, ) -> SearchResponse: """Search across all content in the knowledge base. @@ -37,6 +38,7 @@ async def search_notes( types: Optional list of note types to search (e.g., ["note", "person"]) entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"]) after_date: Optional date filter for recent content (e.g., "1 week", "2d") + project: Optional project name to search in. If not provided, uses current active project. Returns: SearchResponse with results and pagination info @@ -80,6 +82,9 @@ async def search_notes( query="docs/meeting-*", search_type="permalink" ) + + # Search in specific project + results = await search_notes("meeting notes", project="work-project") """ # Create a SearchQuery object based on the parameters search_query = SearchQuery() @@ -104,7 +109,8 @@ async def search_notes( if after_date: search_query.after_date = after_date - project_url = get_project_config().project_url + active_project = get_active_project(project) + project_url = active_project.project_url logger.info(f"Searching for {search_query}") response = await call_post( diff --git a/src/basic_memory/mcp/tools/utils.py b/src/basic_memory/mcp/tools/utils.py index 44dc42a6a..76f5bc8cb 100644 --- a/src/basic_memory/mcp/tools/utils.py +++ b/src/basic_memory/mcp/tools/utils.py @@ -5,6 +5,7 @@ """ import typing +from typing import Optional from httpx import Response, URL, AsyncClient, HTTPStatusError from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT @@ -23,7 +24,9 @@ from mcp.server.fastmcp.exceptions import ToolError -def get_error_message(status_code: int, url: URL | str, method: str) -> str: +def get_error_message( + status_code: int, url: URL | str, method: str, msg: Optional[str] = None +) -> str: """Get a friendly error message based on the HTTP status code. Args: @@ -103,6 +106,7 @@ async def call_get( ToolError: If the request fails with an appropriate error message """ logger.debug(f"Calling GET '{url}' params: '{params}'") + error_message = None try: response = await client.get( url, @@ -120,7 +124,12 @@ async def call_get( # Handle different status codes differently status_code = response.status_code - error_message = get_error_message(status_code, url, "GET") + # get the message if available + response_data = response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] + else: + error_message = get_error_message(status_code, url, "PUT") # Log at appropriate level based on status code if 400 <= status_code < 500: @@ -138,8 +147,6 @@ async def call_get( return response # This line will never execute, but it satisfies the type checker # pragma: no cover except HTTPStatusError as e: - status_code = e.response.status_code - error_message = get_error_message(status_code, url, "GET") raise ToolError(error_message) from e @@ -183,6 +190,8 @@ async def call_put( ToolError: If the request fails with an appropriate error message """ logger.debug(f"Calling PUT '{url}'") + error_message = None + try: response = await client.put( url, @@ -204,7 +213,13 @@ async def call_put( # Handle different status codes differently status_code = response.status_code - error_message = get_error_message(status_code, url, "PUT") + + # get the message if available + response_data = response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] # pragma: no cover + else: + error_message = get_error_message(status_code, url, "PUT") # Log at appropriate level based on status code if 400 <= status_code < 500: @@ -221,9 +236,110 @@ async def call_put( response.raise_for_status() # Will always raise since we're in the error case return response # This line will never execute, but it satisfies the type checker # pragma: no cover + except HTTPStatusError as e: + raise ToolError(error_message) from e + + +async def call_patch( + client: AsyncClient, + url: URL | str, + *, + content: RequestContent | None = None, + data: RequestData | None = None, + files: RequestFiles | None = None, + json: typing.Any | None = None, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, +) -> Response: + """Make a PATCH request and handle errors appropriately. + + Args: + client: The HTTPX AsyncClient to use + url: The URL to request + content: Request content + data: Form data + files: Files to upload + json: JSON data + params: Query parameters + headers: HTTP headers + cookies: HTTP cookies + auth: Authentication + follow_redirects: Whether to follow redirects + timeout: Request timeout + extensions: HTTPX extensions + + Returns: + The HTTP response + + Raises: + ToolError: If the request fails with an appropriate error message + """ + logger.debug(f"Calling PATCH '{url}'") + try: + response = await client.patch( + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers=headers, + cookies=cookies, + auth=auth, + follow_redirects=follow_redirects, + timeout=timeout, + extensions=extensions, + ) + + if response.is_success: + return response + + # Handle different status codes differently + status_code = response.status_code + + # Try to extract specific error message from response body + try: + response_data = response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] + else: + error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover + except Exception: # pragma: no cover + error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover + + # Log at appropriate level based on status code + if 400 <= status_code < 500: + # Client errors: log as info except for 429 (Too Many Requests) + if status_code == 429: # pragma: no cover + logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}") + else: + logger.info(f"Client error: PATCH {url}: {error_message}") + else: # pragma: no cover + # Server errors: log as error + logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover + + # Raise a tool error with the friendly message + response.raise_for_status() # Will always raise since we're in the error case + return response # This line will never execute, but it satisfies the type checker # pragma: no cover + except HTTPStatusError as e: status_code = e.response.status_code - error_message = get_error_message(status_code, url, "PUT") + + # Try to extract specific error message from response body + try: + response_data = e.response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] + else: + error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover + except Exception: # pragma: no cover + error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover + raise ToolError(error_message) from e @@ -267,6 +383,7 @@ async def call_post( ToolError: If the request fails with an appropriate error message """ logger.debug(f"Calling POST '{url}'") + error_message = None try: response = await client.post( url=url, @@ -289,7 +406,12 @@ async def call_post( # Handle different status codes differently status_code = response.status_code - error_message = get_error_message(status_code, url, "POST") + # get the message if available + response_data = response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] + else: + error_message = get_error_message(status_code, url, "POST") # Log at appropriate level based on status code if 400 <= status_code < 500: @@ -307,8 +429,6 @@ async def call_post( return response # This line will never execute, but it satisfies the type checker # pragma: no cover except HTTPStatusError as e: - status_code = e.response.status_code - error_message = get_error_message(status_code, url, "POST") raise ToolError(error_message) from e @@ -344,6 +464,7 @@ async def call_delete( ToolError: If the request fails with an appropriate error message """ logger.debug(f"Calling DELETE '{url}'") + error_message = None try: response = await client.delete( url=url, @@ -361,7 +482,12 @@ async def call_delete( # Handle different status codes differently status_code = response.status_code - error_message = get_error_message(status_code, url, "DELETE") + # get the message if available + response_data = response.json() + if isinstance(response_data, dict) and "detail" in response_data: + error_message = response_data["detail"] # pragma: no cover + else: + error_message = get_error_message(status_code, url, "DELETE") # Log at appropriate level based on status code if 400 <= status_code < 500: @@ -379,6 +505,4 @@ async def call_delete( return response # This line will never execute, but it satisfies the type checker # pragma: no cover except HTTPStatusError as e: - status_code = e.response.status_code - error_message = get_error_message(status_code, url, "DELETE") raise ToolError(error_message) from e diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index 179ec656d..c9d06ee1e 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -1,16 +1,16 @@ """Write note tool for Basic Memory MCP server.""" -from typing import List, Union +from typing import List, Union, Optional from loguru import logger from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_put +from basic_memory.mcp.project_session import get_active_project from basic_memory.schemas import EntityResponse from basic_memory.schemas.base import Entity from basic_memory.utils import parse_tags -from basic_memory.config import get_project_config # Define TagType as a Union that can accept either a string or a list of strings or None TagType = Union[List[str], str, None] @@ -27,6 +27,7 @@ async def write_note( content: str, folder: str, tags=None, # Remove type hint completely to avoid schema issues + project: Optional[str] = None, ) -> str: """Write a markdown note to the knowledge base. @@ -56,6 +57,7 @@ async def write_note( folder: the folder where the file should be saved 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. Returns: A markdown formatted summary of the semantic content, including: @@ -65,12 +67,12 @@ async def write_note( - Relation counts (resolved/unresolved) - Tags if present """ - logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags) + logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}") # Process tags using the helper function tag_list = parse_tags(tags) # Create the entity request - metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None + metadata = {"tags": tag_list} if tag_list else None entity = Entity( title=title, folder=folder, @@ -79,11 +81,11 @@ async def write_note( content=content, entity_metadata=metadata, ) - project_url = get_project_config().project_url - print(f"project_url: {project_url}") + active_project = get_active_project(project) + project_url = active_project.project_url # Create or update via knowledge API - logger.debug("Creating entity via API", permalink=entity.permalink) + logger.debug(f"Creating entity via API permalink={entity.permalink}") url = f"{project_url}/knowledge/entities/{entity.permalink}" response = await call_put(client, url, json=entity.model_dump()) result = EntityResponse.model_validate(response.json()) @@ -125,15 +127,6 @@ async def write_note( # Log the response with structured data logger.info( - "MCP tool response", - tool="write_note", - action=action, - permalink=result.permalink, - observations_count=len(result.observations), - relations_count=len(result.relations), - resolved_relations=resolved, - unresolved_relations=unresolved, - status_code=response.status_code, + f"MCP tool response: tool=write_note action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}" ) - return "\n".join(summary) diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index 2e344708b..4de835121 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -130,26 +130,35 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str: For FTS5: - Special characters and phrases need to be quoted - Terms with spaces or special chars need quotes - - Boolean operators (AND, OR, NOT) and parentheses are preserved + - Boolean operators (AND, OR, NOT) are preserved for complex queries """ if "*" in term: return term - # Check for boolean operators - if present, return the term as is - boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"] + # 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 special characters that need quoting (excluding *) + # List of FTS5 special characters that need escaping/quoting special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"] # Check if term contains any special characters needs_quotes = any(c in term for c in special_chars) if needs_quotes: - # If the term already contains quotes, escape them and add a wildcard - term = term.replace('"', '""') - term = f'"{term}"*' + # 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}*" return term @@ -172,9 +181,8 @@ async def search( # Handle text search for title and content if search_text: - has_boolean = any( - op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"] - ) + # 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 @@ -189,9 +197,9 @@ async def search( # Handle title match search if title: - title_text = self._prepare_search_term(title.strip()) - params["text"] = title_text - conditions.append("title MATCH :text") + title_text = self._prepare_search_term(title.strip(), is_prefix=False) + params["title_text"] = title_text + conditions.append("title MATCH :title_text") # Handle permalink exact search if permalink: diff --git a/src/basic_memory/schemas/project_info.py b/src/basic_memory/schemas/project_info.py index e315ac5d5..2ac8053d1 100644 --- a/src/basic_memory/schemas/project_info.py +++ b/src/basic_memory/schemas/project_info.py @@ -107,7 +107,7 @@ class ProjectInfoResponse(BaseModel): system: SystemStatus = Field(description="System and service status information") -class ProjectSwitchRequest(BaseModel): +class ProjectInfoRequest(BaseModel): """Request model for switching projects.""" name: str = Field(..., description="Name of the project to switch to") @@ -177,27 +177,12 @@ class ProjectWatchStatus(BaseModel): ) -class ProjectStatusResponse(BaseModel): - """Response model for switching projects.""" - - message: str = Field(..., description="Status message about the project switch") - status: str = Field(..., description="Status of the switch (success or error)") - default: bool = Field(..., description="True if the project was set as the default") - old_project: Optional[ProjectWatchStatus] = Field( - None, description="Information about the project being switched from" - ) - new_project: Optional[ProjectWatchStatus] = Field( - None, description="Information about the project being switched to" - ) - - class ProjectItem(BaseModel): """Simple representation of a project.""" name: str path: str - is_default: bool - is_current: bool + is_default: bool = False class ProjectList(BaseModel): @@ -205,4 +190,17 @@ class ProjectList(BaseModel): projects: List[ProjectItem] default_project: str - current_project: str + + +class ProjectStatusResponse(BaseModel): + """Response model for switching projects.""" + + message: str = Field(..., description="Status message about the project switch") + status: str = Field(..., description="Status of the switch (success or error)") + default: bool = Field(..., description="True if the project was set as the default") + old_project: Optional[ProjectItem] = Field( + None, description="Information about the project being switched from" + ) + new_project: Optional[ProjectItem] = Field( + None, description="Information about the project being switched to" + ) diff --git a/src/basic_memory/schemas/request.py b/src/basic_memory/schemas/request.py index 3fa7a80c7..7283de7a5 100644 --- a/src/basic_memory/schemas/request.py +++ b/src/basic_memory/schemas/request.py @@ -1,9 +1,9 @@ """Request schemas for interacting with the knowledge graph.""" -from typing import List, Optional, Annotated +from typing import List, Optional, Annotated, Literal from annotated_types import MaxLen, MinLen -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from basic_memory.schemas.base import ( Relation, @@ -56,3 +56,57 @@ class GetEntitiesRequest(BaseModel): class CreateRelationsRequest(BaseModel): relations: List[Relation] + + +class EditEntityRequest(BaseModel): + """Request schema for editing an existing entity's content. + + This allows for targeted edits without requiring the full entity content. + Supports various operation types for different editing scenarios. + """ + + operation: Literal["append", "prepend", "find_replace", "replace_section"] + content: str + section: Optional[str] = None + find_text: Optional[str] = None + expected_replacements: int = 1 + + @field_validator("section") + @classmethod + def validate_section_for_replace_section(cls, v, info): + """Ensure section is provided for replace_section operation.""" + if info.data.get("operation") == "replace_section" and not v: + raise ValueError("section parameter is required for replace_section operation") + return v + + @field_validator("find_text") + @classmethod + def validate_find_text_for_find_replace(cls, v, info): + """Ensure find_text is provided for find_replace operation.""" + if info.data.get("operation") == "find_replace" and not v: + raise ValueError("find_text parameter is required for find_replace operation") + return v + + +class MoveEntityRequest(BaseModel): + """Request schema for moving an entity to a new file location. + + This allows moving notes to different paths while maintaining project + consistency and optionally updating permalinks based on configuration. + """ + + identifier: Annotated[str, MinLen(1), MaxLen(200)] + destination_path: Annotated[str, MinLen(1), MaxLen(500)] + project: Optional[str] = None + + @field_validator("destination_path") + @classmethod + def validate_destination_path(cls, v): + """Ensure destination path is relative and valid.""" + if v.startswith("/"): + raise ValueError("destination_path must be relative, not absolute") + if ".." in v: + raise ValueError("destination_path cannot contain '..' path components") + if not v.strip(): + raise ValueError("destination_path cannot be empty or whitespace only") + return v.strip() diff --git a/src/basic_memory/services/directory_service.py b/src/basic_memory/services/directory_service.py index 97dc0ebe4..be5aec36e 100644 --- a/src/basic_memory/services/directory_service.py +++ b/src/basic_memory/services/directory_service.py @@ -1,8 +1,9 @@ """Directory service for managing file directories and tree structure.""" +import fnmatch import logging import os -from typing import Dict +from typing import Dict, List, Optional from basic_memory.repository import EntityRepository from basic_memory.schemas.directory import DirectoryNode @@ -87,3 +88,80 @@ async def get_directory_tree(self) -> DirectoryNode: # Return the root node with its children return root_node + + async def list_directory( + self, + dir_name: str = "/", + depth: int = 1, + file_name_glob: Optional[str] = None, + ) -> List[DirectoryNode]: + """List directory contents with filtering and depth control. + + Args: + dir_name: Directory path to list (default: root "/") + depth: Recursion depth (1 = immediate children only) + file_name_glob: Glob pattern for filtering file names + + Returns: + List of DirectoryNode objects matching the criteria + """ + # Normalize directory path + if not dir_name.startswith("/"): + dir_name = f"/{dir_name}" + if dir_name != "/" and dir_name.endswith("/"): + dir_name = dir_name.rstrip("/") + + # Get the full directory tree + root_tree = await self.get_directory_tree() + + # Find the target directory node + target_node = self._find_directory_node(root_tree, dir_name) + if not target_node: + return [] + + # Collect nodes with depth and glob filtering + result = [] + self._collect_nodes_recursive(target_node, result, depth, file_name_glob, 0) + + return result + + def _find_directory_node( + self, root: DirectoryNode, target_path: str + ) -> Optional[DirectoryNode]: + """Find a directory node by path in the tree.""" + if root.directory_path == target_path: + return root + + for child in root.children: + if child.type == "directory": + found = self._find_directory_node(child, target_path) + if found: + return found + + return None + + def _collect_nodes_recursive( + self, + node: DirectoryNode, + result: List[DirectoryNode], + max_depth: int, + file_name_glob: Optional[str], + current_depth: int, + ) -> None: + """Recursively collect nodes with depth and glob filtering.""" + if current_depth >= max_depth: + return + + for child in node.children: + # Apply glob filtering + if file_name_glob and not fnmatch.fnmatch(child.name, file_name_glob): + continue + + # Add the child to results + result.append(child) + + # Recurse into subdirectories if we haven't reached max depth + if child.type == "directory" and current_depth < max_depth: + self._collect_nodes_recursive( + child, result, max_depth, file_name_glob, current_depth + 1 + ) diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 1b0e6ee84..6ca8c51b4 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -4,9 +4,12 @@ from typing import List, Optional, Sequence, Tuple, Union import frontmatter +import yaml from loguru import logger from sqlalchemy.exc import IntegrityError +from basic_memory.config import ProjectConfig, BasicMemoryConfig +from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter from basic_memory.markdown import EntityMarkdown from basic_memory.markdown.entity_parser import EntityParser from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown @@ -114,8 +117,29 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel: f"file for entity {schema.folder}/{schema.title} already exists: {file_path}" ) - # Get unique permalink - permalink = await self.resolve_permalink(schema.permalink or file_path) + # Parse content frontmatter to check for user-specified permalink + content_markdown = None + if schema.content and has_frontmatter(schema.content): + content_frontmatter = parse_frontmatter(schema.content) + if "permalink" in content_frontmatter: + # Create a minimal EntityMarkdown object for permalink resolution + from basic_memory.markdown.schemas import EntityFrontmatter + + frontmatter_metadata = { + "title": schema.title, + "type": schema.entity_type, + "permalink": content_frontmatter["permalink"], + } + frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata) + content_markdown = EntityMarkdown( + frontmatter=frontmatter_obj, + content="", # content not needed for permalink resolution + observations=[], + relations=[], + ) + + # Get unique permalink (prioritizing content frontmatter) + permalink = await self.resolve_permalink(file_path, content_markdown) schema._permalink = permalink post = await schema_to_markdown(schema) @@ -148,12 +172,47 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti # Read existing frontmatter from the file if it exists existing_markdown = await self.entity_parser.parse_file(file_path) + # Parse content frontmatter to check for user-specified permalink + content_markdown = None + if schema.content and has_frontmatter(schema.content): + content_frontmatter = parse_frontmatter(schema.content) + if "permalink" in content_frontmatter: + # Create a minimal EntityMarkdown object for permalink resolution + from basic_memory.markdown.schemas import EntityFrontmatter + + frontmatter_metadata = { + "title": schema.title, + "type": schema.entity_type, + "permalink": content_frontmatter["permalink"], + } + frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata) + content_markdown = EntityMarkdown( + frontmatter=frontmatter_obj, + content="", # content not needed for permalink resolution + observations=[], + relations=[], + ) + + # Check if we need to update the permalink based on content frontmatter + new_permalink = entity.permalink # Default to existing + if content_markdown and content_markdown.frontmatter.permalink: + # Resolve permalink with the new content frontmatter + resolved_permalink = await self.resolve_permalink(file_path, content_markdown) + if resolved_permalink != entity.permalink: + new_permalink = resolved_permalink + # Update the schema to use the new permalink + schema._permalink = new_permalink + # Create post with new content from schema post = await schema_to_markdown(schema) # Merge new metadata with existing metadata existing_markdown.frontmatter.metadata.update(post.metadata) + # Ensure the permalink in the metadata is the resolved one + if new_permalink != entity.permalink: + existing_markdown.frontmatter.metadata["permalink"] = new_permalink + # Create a new post with merged metadata merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata) @@ -325,3 +384,319 @@ async def update_entity_relations( continue return await self.repository.get_by_file_path(path) + + async def edit_entity( + self, + identifier: str, + operation: str, + content: str, + section: Optional[str] = None, + find_text: Optional[str] = None, + expected_replacements: int = 1, + ) -> EntityModel: + """Edit an existing entity's content using various operations. + + Args: + identifier: Entity identifier (permalink, title, etc.) + operation: The editing operation (append, prepend, find_replace, replace_section) + content: The content to add or use for replacement + section: For replace_section operation - the markdown header + find_text: For find_replace operation - the text to find and replace + expected_replacements: For find_replace operation - expected number of replacements (default: 1) + + Returns: + The updated entity model + + Raises: + EntityNotFoundError: If the entity cannot be found + ValueError: If required parameters are missing for the operation or replacement count doesn't match expected + """ + logger.debug(f"Editing entity: {identifier}, operation: {operation}") + + # Find the entity using the link resolver + entity = await self.link_resolver.resolve_link(identifier) + if not entity: + raise EntityNotFoundError(f"Entity not found: {identifier}") + + # Read the current file content + file_path = Path(entity.file_path) + current_content, _ = await self.file_service.read_file(file_path) + + # Apply the edit operation + new_content = self.apply_edit_operation( + current_content, operation, content, section, find_text, expected_replacements + ) + + # Write the updated content back to the file + checksum = await self.file_service.write_file(file_path, new_content) + + # Parse the updated file to get new observations/relations + entity_markdown = await self.entity_parser.parse_file(file_path) + + # Update entity and its relationships + entity = await self.update_entity_and_observations(file_path, entity_markdown) + await self.update_entity_relations(str(file_path), entity_markdown) + + # Set final checksum to match file + entity = await self.repository.update(entity.id, {"checksum": checksum}) + + return entity + + def apply_edit_operation( + self, + current_content: str, + operation: str, + content: str, + section: Optional[str] = None, + find_text: Optional[str] = None, + expected_replacements: int = 1, + ) -> str: + """Apply the specified edit operation to the current content.""" + + if operation == "append": + # Ensure proper spacing + if current_content and not current_content.endswith("\n"): + return current_content + "\n" + content + return current_content + content # pragma: no cover + + elif operation == "prepend": + # Handle frontmatter-aware prepending + return self._prepend_after_frontmatter(current_content, content) + + elif operation == "find_replace": + if not find_text: + raise ValueError("find_text is required for find_replace operation") + if not find_text.strip(): + raise ValueError("find_text cannot be empty or whitespace only") + + # Count actual occurrences + actual_count = current_content.count(find_text) + + # Validate count matches expected + if actual_count != expected_replacements: + if actual_count == 0: + raise ValueError(f"Text to replace not found: '{find_text}'") + else: + raise ValueError( + f"Expected {expected_replacements} occurrences of '{find_text}', " + f"but found {actual_count}" + ) + + return current_content.replace(find_text, content) + + elif operation == "replace_section": + if not section: + raise ValueError("section is required for replace_section operation") + if not section.strip(): + raise ValueError("section cannot be empty or whitespace only") + return self.replace_section_content(current_content, section, content) + + else: + raise ValueError(f"Unsupported operation: {operation}") + + def replace_section_content( + self, current_content: str, section_header: str, new_content: str + ) -> str: + """Replace content under a specific markdown section header. + + This method uses a simple, safe approach: when replacing a section, it only + replaces the immediate content under that header until it encounters the next + header of ANY level. This means: + + - Replacing "# Header" replaces content until "## Subsection" (preserves subsections) + - Replacing "## Section" replaces content until "### Subsection" (preserves subsections) + - More predictable and safer than trying to consume entire hierarchies + + Args: + current_content: The current markdown content + section_header: The section header to find and replace (e.g., "## Section Name") + new_content: The new content to replace the section with + + Returns: + The updated content with the section replaced + + Raises: + ValueError: If multiple sections with the same header are found + """ + # Normalize the section header (ensure it starts with #) + if not section_header.startswith("#"): + section_header = "## " + section_header + + # First pass: count matching sections to check for duplicates + lines = current_content.split("\n") + matching_sections = [] + + for i, line in enumerate(lines): + if line.strip() == section_header.strip(): + matching_sections.append(i) + + # Handle multiple sections error + if len(matching_sections) > 1: + raise ValueError( + f"Multiple sections found with header '{section_header}'. " + f"Section replacement requires unique headers." + ) + + # If no section found, append it + if len(matching_sections) == 0: + logger.info(f"Section '{section_header}' not found, appending to end of document") + separator = "\n\n" if current_content and not current_content.endswith("\n\n") else "" + return current_content + separator + section_header + "\n" + new_content + + # Replace the single matching section + result_lines = [] + section_line_idx = matching_sections[0] + + i = 0 + while i < len(lines): + line = lines[i] + + # Check if this is our target section header + if i == section_line_idx: + # Add the section header and new content + result_lines.append(line) + result_lines.append(new_content) + i += 1 + + # Skip the original section content until next header or end + while i < len(lines): + next_line = lines[i] + # Stop consuming when we hit any header (preserve subsections) + if next_line.startswith("#"): + # We found another header - continue processing from here + break + i += 1 + # Continue processing from the next header (don't increment i again) + continue + + # Add all other lines (including subsequent sections) + result_lines.append(line) + i += 1 + + return "\n".join(result_lines) + + def _prepend_after_frontmatter(self, current_content: str, content: str) -> str: + """Prepend content after frontmatter, preserving frontmatter structure.""" + + # Check if file has frontmatter + if has_frontmatter(current_content): + try: + # Parse and separate frontmatter from body + frontmatter_data = parse_frontmatter(current_content) + body_content = remove_frontmatter(current_content) + + # Prepend content to the body + if content and not content.endswith("\n"): + new_body = content + "\n" + body_content + else: + new_body = content + body_content + + # Reconstruct file with frontmatter + prepended body + yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True) + return f"---\n{yaml_fm}---\n\n{new_body.strip()}" + + except Exception as e: # pragma: no cover + logger.warning( + f"Failed to parse frontmatter during prepend: {e}" + ) # pragma: no cover + # Fall back to simple prepend if frontmatter parsing fails # pragma: no cover + + # No frontmatter or parsing failed - do simple prepend # pragma: no cover + if content and not content.endswith("\n"): # pragma: no cover + return content + "\n" + current_content # pragma: no cover + return content + current_content # pragma: no cover + + async def move_entity( + self, + identifier: str, + destination_path: str, + project_config: ProjectConfig, + app_config: BasicMemoryConfig, + ) -> EntityModel: + """Move entity to new location with database consistency. + + Args: + identifier: Entity identifier (title, permalink, or memory:// URL) + destination_path: New path relative to project root + project_config: Project configuration for file operations + app_config: App configuration for permalink update settings + + Returns: + Success message with move details + + Raises: + EntityNotFoundError: If the entity cannot be found + ValueError: If move operation fails due to validation or filesystem errors + """ + logger.debug(f"Moving entity: {identifier} to {destination_path}") + + # 1. Resolve identifier to entity + entity = await self.link_resolver.resolve_link(identifier) + if not entity: + raise EntityNotFoundError(f"Entity not found: {identifier}") + + current_path = entity.file_path + old_permalink = entity.permalink + + # 2. Validate destination path format first + if not destination_path or destination_path.startswith("/") or not destination_path.strip(): + raise ValueError(f"Invalid destination path: {destination_path}") + + # 3. Validate paths + source_file = project_config.home / current_path + destination_file = project_config.home / destination_path + + # Validate source exists + if not source_file.exists(): + raise ValueError(f"Source file not found: {current_path}") + + # Check if destination already exists + if destination_file.exists(): + raise ValueError(f"Destination already exists: {destination_path}") + + try: + # 4. Create destination directory if needed + destination_file.parent.mkdir(parents=True, exist_ok=True) + + # 5. Move physical file + source_file.rename(destination_file) + logger.info(f"Moved file: {current_path} -> {destination_path}") + + # 6. Prepare database updates + updates = {"file_path": destination_path} + + # 7. Update permalink if configured + if app_config.update_permalinks_on_move: + # Generate new permalink from destination path + new_permalink = await self.resolve_permalink(destination_path) + + # Update frontmatter with new permalink + await self.file_service.update_frontmatter( + destination_path, {"permalink": new_permalink} + ) + + updates["permalink"] = new_permalink + logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}") + + # 8. Recalculate checksum + new_checksum = await self.file_service.compute_checksum(destination_path) + updates["checksum"] = new_checksum + + # 9. Update database + updated_entity = await self.repository.update(entity.id, updates) + if not updated_entity: + raise ValueError(f"Failed to update entity in database: {entity.id}") + + return updated_entity + + except Exception as e: + # Rollback: try to restore original file location if move succeeded + if destination_file.exists() and not source_file.exists(): + try: + destination_file.rename(source_file) + logger.info(f"Rolled back file move: {destination_path} -> {current_path}") + except Exception as rollback_error: # pragma: no cover + logger.error(f"Failed to rollback file move: {rollback_error}") + + # Re-raise the original error with context + raise ValueError(f"Move failed: {str(e)}") from e diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index b28dc7ad2..b22c02a4c 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -94,8 +94,8 @@ async def exists(self, path: FilePath) -> bool: """ try: # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path - + path_obj = self.base_path / path if isinstance(path, str) else path + logger.debug(f"Checking file existence: path={path_obj}") if path_obj.is_absolute(): return path_obj.exists() else: @@ -121,7 +121,7 @@ async def write_file(self, path: FilePath, content: str) -> str: FileOperationError: If write fails """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj try: @@ -140,7 +140,7 @@ async def write_file(self, path: FilePath, content: str) -> str: # Compute and return checksum checksum = await file_utils.compute_checksum(content) - logger.debug("File write completed", path=str(full_path), checksum=checksum) + logger.debug(f"File write completed path={full_path}, {checksum=}") return checksum except Exception as e: @@ -164,7 +164,7 @@ async def read_file(self, path: FilePath) -> Tuple[str, str]: FileOperationError: If read fails """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj try: @@ -194,7 +194,7 @@ async def delete_file(self, path: FilePath) -> None: path: Path to delete (Path or string) """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj full_path.unlink(missing_ok=True) @@ -210,7 +210,7 @@ async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> s Checksum of updated file """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj return await file_utils.update_frontmatter(full_path, updates) @@ -227,7 +227,7 @@ async def compute_checksum(self, path: FilePath) -> str: FileError: If checksum computation fails """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj try: @@ -253,7 +253,7 @@ def file_stats(self, path: FilePath) -> stat_result: File statistics """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj # get file timestamps return full_path.stat() @@ -268,7 +268,7 @@ def content_type(self, path: FilePath) -> str: MIME type of the file """ # Convert string to Path if needed - path_obj = Path(path) if isinstance(path, str) else path + path_obj = self.base_path / path if isinstance(path, str) else path full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj # get file timestamps mime_type, _ = mimetypes.guess_type(full_path.name) diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 556e73ad6..ec8cfa1fd 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -15,10 +15,10 @@ class LinkResolver: Uses a combination of exact matching and search-based resolution: 1. Try exact permalink match (fastest) - 2. Try permalink pattern match (for wildcards) - 3. Try exact title match - 4. Fall back to search for fuzzy matching - 5. Generate new permalink if no match found + 2. Try exact title match + 3. Try exact file path match + 4. Try file path with .md extension (for folder/title patterns) + 5. Fall back to search for fuzzy matching """ def __init__(self, entity_repository: EntityRepository, search_service: SearchService): @@ -52,11 +52,19 @@ async def resolve_link(self, link_text: str, use_search: bool = True) -> Optiona logger.debug(f"Found entity with path: {found_path.file_path}") return found_path + # 4. Try file path with .md extension if not already present + if not clean_text.endswith(".md") and "/" in clean_text: + file_path_with_md = f"{clean_text}.md" + found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md) + if found_path_md: + logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}") + return found_path_md + # search if indicated if use_search and "*" not in clean_text: - # 3. Fall back to search for fuzzy matching on title + # 5. Fall back to search for fuzzy matching on title (use text search for prefix matching) results = await self.search_service.search( - query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]), + query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]), ) if results: diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 286bd9662..d9d631835 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -4,12 +4,13 @@ import os from datetime import datetime from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Sequence from loguru import logger from sqlalchemy import text -from basic_memory.config import ConfigManager, config, app_config +from basic_memory.config import config, app_config +from basic_memory.models import Project from basic_memory.repository.project_repository import ProjectRepository from basic_memory.schemas import ( ActivityMetrics, @@ -18,15 +19,17 @@ SystemStatus, ) from basic_memory.config import WATCH_STATUS_JSON - +from basic_memory.utils import generate_permalink +from basic_memory.config import config_manager class ProjectService: """Service for managing Basic Memory projects.""" - def __init__(self, repository: Optional[ProjectRepository] = None): + repository: ProjectRepository + + def __init__(self, repository: ProjectRepository): """Initialize the project service.""" super().__init__() - self.config_manager = ConfigManager() self.repository = repository @property @@ -36,7 +39,7 @@ def projects(self) -> Dict[str, str]: Returns: Dict mapping project names to their file paths """ - return self.config_manager.projects + return config_manager.projects @property def default_project(self) -> str: @@ -45,7 +48,7 @@ def default_project(self) -> str: Returns: The name of the default project """ - return self.config_manager.default_project + return config_manager.default_project @property def current_project(self) -> str: @@ -54,7 +57,14 @@ def current_project(self) -> str: Returns: The name of the current project """ - return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project) + return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project) + + async def list_projects(self) -> Sequence[Project]: + return await self.repository.find_all() + + async def get_project(self, name: str) -> Optional[Project]: + """Get the file path for a project by name.""" + return await self.repository.get_by_name(name) async def add_project(self, name: str, path: str) -> None: """Add a new project to the configuration and database. @@ -73,13 +83,13 @@ async def add_project(self, name: str, path: str) -> None: resolved_path = os.path.abspath(os.path.expanduser(path)) # First add to config file (this will validate the project doesn't exist) - self.config_manager.add_project(name, resolved_path) + project_config = config_manager.add_project(name, resolved_path) # Then add to database project_data = { "name": name, "path": resolved_path, - "permalink": name.lower().replace(" ", "-"), + "permalink": generate_permalink(project_config.name), "is_active": True, "is_default": False, } @@ -100,7 +110,7 @@ async def remove_project(self, name: str) -> None: raise ValueError("Repository is required for remove_project") # First remove from config (this will validate the project exists and is not default) - self.config_manager.remove_project(name) + config_manager.remove_project(name) # Then remove from database project = await self.repository.get_by_name(name) @@ -122,7 +132,7 @@ async def set_default_project(self, name: str) -> None: raise ValueError("Repository is required for set_default_project") # First update config file (this will validate the project exists) - self.config_manager.set_default_project(name) + config_manager.set_default_project(name) # Then update database project = await self.repository.get_by_name(name) @@ -150,7 +160,7 @@ async def synchronize_projects(self) -> None: # pragma: no cover db_projects_by_name = {p.name: p for p in db_projects} # Get all projects from configuration - config_projects = self.config_manager.projects + config_projects = config_manager.projects # Add projects that exist in config but not in DB for name, path in config_projects.items(): @@ -161,7 +171,7 @@ async def synchronize_projects(self) -> None: # pragma: no cover "path": path, "permalink": name.lower().replace(" ", "-"), "is_active": True, - "is_default": (name == self.config_manager.default_project), + "is_default": (name == config_manager.default_project), } await self.repository.create(project_data) @@ -169,16 +179,16 @@ async def synchronize_projects(self) -> None: # pragma: no cover for name, project in db_projects_by_name.items(): if name not in config_projects: logger.info(f"Adding project '{name}' to configuration") - self.config_manager.add_project(name, project.path) + config_manager.add_project(name, project.path) # Make sure default project is synchronized db_default = next((p for p in db_projects if p.is_default), None) - config_default = self.config_manager.default_project + config_default = config_manager.default_project if db_default and db_default.name != config_default: # Update config to match DB default logger.info(f"Updating default project in config to '{db_default.name}'") - self.config_manager.set_default_project(db_default.name) + config_manager.set_default_project(db_default.name) elif not db_default and config_default in db_projects_by_name: # Update DB to match config default logger.info(f"Updating default project in database to '{config_default}'") @@ -204,7 +214,7 @@ async def update_project( # pragma: no cover raise ValueError("Repository is required for update_project") # Validate project exists in config - if name not in self.config_manager.projects: + if name not in config_manager.projects: raise ValueError(f"Project '{name}' not found in configuration") # Get project from database @@ -218,10 +228,10 @@ async def update_project( # pragma: no cover resolved_path = os.path.abspath(os.path.expanduser(updated_path)) # Update in config - projects = self.config_manager.config.projects.copy() + projects = config_manager.config.projects.copy() projects[name] = resolved_path - self.config_manager.config.projects = projects - self.config_manager.save_config(self.config_manager.config) + config_manager.config.projects = projects + config_manager.save_config(config_manager.config) # Update in database project.path = resolved_path @@ -242,7 +252,7 @@ async def update_project( # pragma: no cover if active_projects: new_default = active_projects[0] await self.repository.set_as_default(new_default.id) - self.config_manager.set_default_project(new_default.name) + config_manager.set_default_project(new_default.name) logger.info( f"Changed default project to '{new_default.name}' as '{name}' was deactivated" ) @@ -274,11 +284,11 @@ async def get_project_info(self) -> ProjectInfoResponse: db_projects_by_name = {p.name: p for p in db_projects} # Get default project info - default_project = self.config_manager.default_project + default_project = config_manager.default_project # Convert config projects to include database info enhanced_projects = {} - for name, path in self.config_manager.projects.items(): + for name, path in config_manager.projects.items(): db_project = db_projects_by_name.get(name) enhanced_projects[name] = { "path": path, @@ -535,4 +545,4 @@ def get_system_status(self) -> SystemStatus: database_size=db_size_readable, watch_status=watch_status, timestamp=datetime.now(), - ) + ) \ No newline at end of file diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index 39761da02..b988522b6 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -1,5 +1,6 @@ """Service for search operations.""" +import ast from datetime import datetime from typing import List, Optional, Set @@ -117,6 +118,38 @@ def _generate_variants(text: str) -> Set[str]: return variants + def _extract_entity_tags(self, entity: Entity) -> List[str]: + """Extract tags from entity metadata for search indexing. + + Handles multiple tag formats: + - List format: ["tag1", "tag2"] + - String format: "['tag1', 'tag2']" or "[tag1, tag2]" + - Empty: [] or "[]" + + Returns a list of tag strings for search indexing. + """ + if not entity.entity_metadata or "tags" not in entity.entity_metadata: + return [] + + tags = entity.entity_metadata["tags"] + + # Handle list format (preferred) + if isinstance(tags, list): + return [str(tag) for tag in tags if tag] + + # Handle string format (legacy) + if isinstance(tags, str): + try: + # Parse string representation of list + parsed_tags = ast.literal_eval(tags) + if isinstance(parsed_tags, list): + return [str(tag) for tag in parsed_tags if tag] + except (ValueError, SyntaxError): + # If parsing fails, treat as single tag + return [tags] if tags.strip() else [] + + return [] # pragma: no cover + async def index_entity( self, entity: Entity, @@ -201,6 +234,11 @@ async def index_entity_markdown( content_stems.extend(self._generate_variants(entity.file_path)) + # Add entity tags from frontmatter to search content + entity_tags = self._extract_entity_tags(entity) + if entity_tags: + content_stems.extend(entity_tags) + entity_content_stems = "\n".join(p for p in content_stems if p and p.strip()) # Index entity @@ -286,3 +324,32 @@ async def delete_by_permalink(self, permalink: str): async def delete_by_entity_id(self, entity_id: int): """Delete an item from the search index.""" await self.repository.delete_by_entity_id(entity_id) + + async def handle_delete(self, entity: Entity): + """Handle complete entity deletion from search index including observations and relations. + + This replicates the logic from sync_service.handle_delete() to properly clean up + all search index entries for an entity and its related data. + """ + logger.debug( + f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, " + f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}" + ) + + # Clean up search index - same logic as sync_service.handle_delete() + permalinks = ( + [entity.permalink] + + [o.permalink for o in entity.observations] + + [r.permalink for r in entity.outgoing_relations] + ) + + logger.debug( + f"Deleting search index entries for entity_id={entity.id}, " + f"index_entries={len(permalinks)}" + ) + + for permalink in permalinks: + if permalink: + await self.delete_by_permalink(permalink) + else: + await self.delete_by_entity_id(entity.id) diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index 0e475cf79..797d0fbce 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -379,7 +379,7 @@ async def handle_move(self, old_path, new_path): updates = {"file_path": new_path} # If configured, also update permalink to match new path - if self.app_config.update_permalinks_on_move: + if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(new_path): # generate new permalink value new_permalink = await self.entity_service.resolve_permalink(new_path) @@ -505,4 +505,4 @@ async def scan_directory(self, directory: Path) -> ScanResult: f"duration_ms={duration_ms}" ) - return result + return result \ No newline at end of file diff --git a/test-int/conftest.py b/test-int/conftest.py new file mode 100644 index 000000000..1b7ff4470 --- /dev/null +++ b/test-int/conftest.py @@ -0,0 +1,231 @@ +""" +Shared fixtures for integration tests. + +Integration tests verify the complete flow: MCP Client → MCP Server → FastAPI → Database. +Unlike unit tests which use in-memory databases and mocks, integration tests use real SQLite +files and test the full application stack to ensure all components work together correctly. + +## Architecture + +The integration test setup creates this flow: + +``` +Test → MCP Client → MCP Server → HTTP Request (ASGITransport) → FastAPI App → Database + ↑ + Dependency overrides + point to test database +``` + +## Key Components + +1. **Real SQLite Database**: Uses `DatabaseType.FILESYSTEM` with actual SQLite files + in temporary directories instead of in-memory databases. + +2. **Shared Database Connection**: Both MCP server and FastAPI app use the same + database via dependency injection overrides. + +3. **Project Session Management**: Initializes the MCP project session with test + project configuration so tools know which project to operate on. + +4. **Search Index Initialization**: Creates the FTS5 search index tables that + the application requires for search functionality. + +5. **Global Configuration Override**: Modifies the global `basic_memory_app_config` + so MCP tools use test project settings instead of user configuration. + +## Usage + +Integration tests should include both `mcp_server` and `app` fixtures to ensure +the complete stack is wired correctly: + +```python +@pytest.mark.asyncio +async def test_my_mcp_tool(mcp_server, app): + async with Client(mcp_server) as client: + result = await client.call_tool("tool_name", {"param": "value"}) + # Assert on results... +``` + +The `app` fixture ensures FastAPI dependency overrides are active, and +`mcp_server` provides the MCP server with proper project session initialization. +""" +import os +from typing import AsyncGenerator +from unittest import mock +from unittest.mock import patch + +import pytest +import pytest_asyncio +from pathlib import Path + +from httpx import AsyncClient, ASGITransport + +import basic_memory.config +import basic_memory.mcp.project_session + +from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager +from basic_memory.db import engine_session_factory, DatabaseType +from basic_memory.models import Project +from basic_memory.repository.project_repository import ProjectRepository +from fastapi import FastAPI + +from basic_memory.api.app import app as fastapi_app +from basic_memory.deps import get_project_config, get_engine_factory, get_app_config + + +# Import MCP tools so they're available for testing +from basic_memory.mcp import tools # noqa: F401 + + +@pytest_asyncio.fixture(scope="function") +async def engine_factory(tmp_path): + """Create a SQLite file engine factory for integration testing.""" + db_path = tmp_path / "test.db" + async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as ( + engine, + session_maker, + ): + # Initialize database schema + from basic_memory.models.base import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine, session_maker + + +@pytest_asyncio.fixture(scope="function") +async def test_project(tmp_path, engine_factory) -> Project: + """Create a test project.""" + project_data = { + "name": "test-project", + "description": "Project used for integration tests", + "path": str(tmp_path), + "is_active": True, + "is_default": True, + } + + engine, session_maker = engine_factory + project_repository = ProjectRepository(session_maker) + project = await project_repository.create(project_data) + return project + +@pytest.fixture +def config_home(tmp_path, monkeypatch) -> Path: + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path + +@pytest.fixture(scope="function") +def app_config(config_home, test_project, tmp_path, monkeypatch) -> BasicMemoryConfig: + """Create test app configuration.""" + projects = {test_project.name: str(test_project.path)} + app_config = BasicMemoryConfig(env="test", projects=projects, default_project=test_project.name, update_permalinks_on_move=True) + + # Set the module app_config instance project list (like regular tests) + monkeypatch.setattr("basic_memory.config.app_config", app_config) + return app_config + +@pytest.fixture +def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> ConfigManager: + config_manager = ConfigManager() + # Update its paths to use the test directory + config_manager.config_dir = config_home / ".basic-memory" + config_manager.config_file = config_manager.config_dir / "config.json" + config_manager.config_dir.mkdir(parents=True, exist_ok=True) + + # Override the config directly instead of relying on disk load + config_manager.config = app_config + + # Ensure the config file is written to disk + config_manager.save_config(app_config) + + # Patch the config_manager in all locations where it's imported + monkeypatch.setattr("basic_memory.config.config_manager", config_manager) + monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager) + + return config_manager + +@pytest.fixture +def project_session(test_project: Project): + # initialize the project session with the test project + basic_memory.mcp.project_session.session.initialize(test_project.name) + + +@pytest.fixture(scope="function") +def project_config(test_project, monkeypatch): + """Create test project configuration.""" + + project_config = ProjectConfig( + name=test_project.name, + home=Path(test_project.path), + ) + + # override config module project config + monkeypatch.setattr("basic_memory.config.config", project_config) + + return project_config + + + +@pytest.fixture(scope="function") +def app(app_config, project_config, engine_factory, test_project, project_session, config_manager) -> FastAPI: + """Create test FastAPI application with single project.""" + + app = fastapi_app + app.dependency_overrides[get_project_config] = lambda: project_config + app.dependency_overrides[get_engine_factory] = lambda: engine_factory + app.dependency_overrides[get_app_config] = lambda: app_config + return app + + +@pytest_asyncio.fixture(scope="function") +async def search_service(engine_factory, test_project): + """Create and initialize search service for integration tests.""" + from basic_memory.repository.search_repository import SearchRepository + from basic_memory.repository.entity_repository import EntityRepository + from basic_memory.services.file_service import FileService + from basic_memory.services.search_service import SearchService + from basic_memory.markdown.markdown_processor import MarkdownProcessor + from basic_memory.markdown import EntityParser + + engine, session_maker = engine_factory + + # Create repositories + search_repository = SearchRepository(session_maker, project_id=test_project.id) + entity_repository = EntityRepository(session_maker, project_id=test_project.id) + + # Create file service + entity_parser = EntityParser(Path(test_project.path)) + markdown_processor = MarkdownProcessor(entity_parser) + file_service = FileService(Path(test_project.path), markdown_processor) + + # Create and initialize search service + service = SearchService(search_repository, entity_repository, file_service) + await service.init_search_index() + return service + + +@pytest.fixture(scope="function") +def mcp_server(app_config, search_service): + # Import mcp instance + from basic_memory.mcp.server import mcp as server + + # Import mcp tools to register them + import basic_memory.mcp.tools # noqa: F401 + + # Import prompts to register them + import basic_memory.mcp.prompts # noqa: F401 + + # Initialize project session with test project + from basic_memory.mcp.project_session import session + + session.initialize(app_config.default_project) + + return server + + +@pytest_asyncio.fixture(scope="function") +async def client(app: FastAPI) -> 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 \ No newline at end of file diff --git a/test-int/mcp/.coverage.Pauls-MacBook-Pro-2.local.66900.XDhpuELx b/test-int/mcp/.coverage.Pauls-MacBook-Pro-2.local.66900.XDhpuELx new file mode 100644 index 000000000..7b304ac04 Binary files /dev/null and b/test-int/mcp/.coverage.Pauls-MacBook-Pro-2.local.66900.XDhpuELx differ diff --git a/test-int/mcp/test_delete_note_integration.py b/test-int/mcp/test_delete_note_integration.py new file mode 100644 index 000000000..e15f9a24d --- /dev/null +++ b/test-int/mcp/test_delete_note_integration.py @@ -0,0 +1,422 @@ +""" +Integration tests for delete_note MCP tool. + +Tests the complete delete note workflow: MCP client -> MCP server -> FastAPI -> database +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_delete_note_by_title(mcp_server, app): + """Test deleting a note by its title.""" + + async with Client(mcp_server) as client: + # First create a note + await client.call_tool( + "write_note", + { + "title": "Note to Delete", + "folder": "test", + "content": "# Note to Delete\n\nThis note will be deleted.", + "tags": "test,delete", + }, + ) + + # Verify the note exists by reading it + read_result = await client.call_tool( + "read_note", + { + "identifier": "Note to Delete", + }, + ) + assert len(read_result) == 1 + assert "Note to Delete" in read_result[0].text + + # Delete the note by title + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "Note to Delete", + }, + ) + + # Should return True for successful deletion + assert len(delete_result) == 1 + assert delete_result[0].type == "text" + assert "true" in delete_result[0].text.lower() + + # Verify the note no longer exists + read_after_delete = await client.call_tool( + "read_note", + { + "identifier": "Note to Delete", + }, + ) + + # Should return helpful "Note Not Found" message instead of the actual note + assert len(read_after_delete) == 1 + 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 +async def test_delete_note_by_permalink(mcp_server, app): + """Test deleting a note by its permalink.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Permalink Delete Test", + "folder": "tests", + "content": "# Permalink Delete Test\n\nTesting deletion by permalink.", + "tags": "test,permalink", + }, + ) + + # Delete the note by permalink + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "tests/permalink-delete-test", + }, + ) + + # Should return True for successful deletion + assert len(delete_result) == 1 + assert "true" in delete_result[0].text.lower() + + # Verify the note no longer exists by searching + search_result = await client.call_tool( + "search_notes", + { + "query": "Permalink Delete Test", + }, + ) + + # Should have no results + assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text + + +@pytest.mark.asyncio +async def test_delete_note_with_observations_and_relations(mcp_server, app): + """Test deleting a note that has observations and relations.""" + + async with Client(mcp_server) as client: + # Create a complex note with observations and relations + complex_content = """# Project Management System + +This is a comprehensive project management system. + +## Observations +- [feature] Task tracking functionality +- [feature] User authentication system +- [tech] Built with Python and Flask +- [status] Currently in development + +## Relations +- depends_on [[Database Schema]] +- implements [[User Stories]] +- part_of [[Main Application]] + +The system handles multiple projects and users.""" + + await client.call_tool( + "write_note", + { + "title": "Project Management System", + "folder": "projects", + "content": complex_content, + "tags": "project,management,system", + }, + ) + + # Verify the note exists and has content + read_result = await client.call_tool( + "read_note", + { + "identifier": "Project Management System", + }, + ) + assert len(read_result) == 1 + result_text = read_result[0].text + assert "Task tracking functionality" in result_text + assert "depends_on" in result_text + + # Delete the complex note + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "projects/project-management-system", + }, + ) + + # Should return True for successful deletion + assert "true" in delete_result[0].text.lower() + + # Verify the note and all its components are deleted + read_after_delete_2 = await client.call_tool( + "read_note", + { + "identifier": "Project Management System", + }, + ) + + # Should return "Note Not Found" message + assert len(read_after_delete_2) == 1 + result_text = read_after_delete_2[0].text + assert "Note Not Found" in result_text + assert "Project Management System" in result_text + + +@pytest.mark.asyncio +async def test_delete_note_special_characters_in_title(mcp_server, app): + """Test deleting notes with special characters in the title.""" + + async with Client(mcp_server) as client: + # Create notes with special characters + special_titles = [ + "Note with spaces", + "Note-with-dashes", + "Note_with_underscores", + "Note (with parentheses)", + "Note & Symbols!", + ] + + # Create all the notes + for title in special_titles: + await client.call_tool( + "write_note", + { + "title": title, + "folder": "special", + "content": f"# {title}\n\nContent for {title}", + "tags": "special,characters", + }, + ) + + # Delete each note by title + for title in special_titles: + delete_result = await client.call_tool( + "delete_note", + { + "identifier": title, + }, + ) + + # Should return True for successful deletion + assert "true" in delete_result[0].text.lower(), f"Failed to delete note: {title}" + + # Verify the note is deleted + read_after_delete = await client.call_tool( + "read_note", + { + "identifier": title, + }, + ) + + # Should return "Note Not Found" message + assert len(read_after_delete) == 1 + result_text = read_after_delete[0].text + assert "Note Not Found" in result_text + assert title in result_text + + +@pytest.mark.asyncio +async def test_delete_nonexistent_note(mcp_server, app): + """Test attempting to delete a note that doesn't exist.""" + + async with Client(mcp_server) as client: + # Try to delete a note that doesn't exist + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "Nonexistent Note", + }, + ) + + # Should return False for unsuccessful deletion + assert len(delete_result) == 1 + assert "false" in delete_result[0].text.lower() + + +@pytest.mark.asyncio +async def test_delete_note_by_file_path(mcp_server, app): + """Test deleting a note using its file path.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "File Path Delete", + "folder": "docs", + "content": "# File Path Delete\n\nTesting deletion by file path.", + "tags": "test,filepath", + }, + ) + + # Try to delete using the file path (should work as an identifier) + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "docs/File Path Delete.md", + }, + ) + + # Should return True for successful deletion + assert "true" in delete_result[0].text.lower() + + # Verify deletion + read_after_delete = await client.call_tool( + "read_note", + { + "identifier": "File Path Delete", + }, + ) + + # Should return "Note Not Found" message + assert len(read_after_delete) == 1 + result_text = read_after_delete[0].text + assert "Note Not Found" in result_text + assert "File Path Delete" in result_text + + +@pytest.mark.asyncio +async def test_delete_note_case_insensitive(mcp_server, app): + """Test that note deletion is case insensitive for titles.""" + + async with Client(mcp_server) as client: + # Create a note with mixed case + await client.call_tool( + "write_note", + { + "title": "CamelCase Note Title", + "folder": "test", + "content": "# CamelCase Note Title\n\nTesting case sensitivity.", + "tags": "test,case", + }, + ) + + # Try to delete with different case + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "camelcase note title", + }, + ) + + # Should return True for successful deletion + assert "true" in delete_result[0].text.lower() + + +@pytest.mark.asyncio +async def test_delete_multiple_notes_sequentially(mcp_server, app): + """Test deleting multiple notes in sequence.""" + + async with Client(mcp_server) as client: + # Create multiple notes + note_titles = [ + "First Note", + "Second Note", + "Third Note", + "Fourth Note", + "Fifth Note", + ] + + for title in note_titles: + await client.call_tool( + "write_note", + { + "title": title, + "folder": "batch", + "content": f"# {title}\n\nContent for {title}", + "tags": "batch,test", + }, + ) + + # Delete all notes sequentially + for title in note_titles: + delete_result = await client.call_tool( + "delete_note", + { + "identifier": title, + }, + ) + + # Each deletion should be successful + assert "true" in delete_result[0].text.lower(), f"Failed to delete {title}" + + # Verify all notes are deleted by searching + search_result = await client.call_tool( + "search_notes", + { + "query": "batch", + }, + ) + + # Should have no results + assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text + + +@pytest.mark.asyncio +async def test_delete_note_with_unicode_content(mcp_server, app): + """Test deleting notes with Unicode content.""" + + async with Client(mcp_server) as client: + # Create a note with Unicode content + unicode_content = """# Unicode Test Note 🚀 + +This note contains various Unicode characters: +- Emojis: 🎉 🔥 ⚡ 💡 +- Languages: 测试中文 Tëst Übër +- Symbols: ♠♣♥♦ ←→↑↓ ∞≠≤≥ +- Math: ∑∏∂∇∆Ω + +## Observations +- [test] Unicode characters preserved ✓ +- [note] Emoji support working 🎯 + +## Relations +- supports [[Unicode Standards]] +- tested_with [[Various Languages]]""" + + await client.call_tool( + "write_note", + { + "title": "Unicode Test Note", + "folder": "unicode", + "content": unicode_content, + "tags": "unicode,test,emoji", + }, + ) + + # Delete the Unicode note + delete_result = await client.call_tool( + "delete_note", + { + "identifier": "Unicode Test Note", + }, + ) + + # Should return True for successful deletion + assert "true" in delete_result[0].text.lower() + + # Verify deletion + read_after_delete = await client.call_tool( + "read_note", + { + "identifier": "Unicode Test Note", + }, + ) + + # Should return "Note Not Found" message + assert len(read_after_delete) == 1 + result_text = read_after_delete[0].text + assert "Note Not Found" in result_text + assert "Unicode Test Note" in result_text diff --git a/test-int/mcp/test_edit_note_integration.py b/test-int/mcp/test_edit_note_integration.py new file mode 100644 index 000000000..3d14dd2db --- /dev/null +++ b/test-int/mcp/test_edit_note_integration.py @@ -0,0 +1,608 @@ +""" +Integration tests for edit_note MCP tool. + +Tests the complete edit note workflow: MCP client -> MCP server -> FastAPI -> database +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_edit_note_append_operation(mcp_server, app): + """Test appending content to an existing note.""" + + async with Client(mcp_server) as client: + # First create a note + await client.call_tool( + "write_note", + { + "title": "Append Test Note", + "folder": "test", + "content": "# Append Test Note\n\nOriginal content here.", + "tags": "test,append", + }, + ) + + # Test appending content + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Append Test Note", + "operation": "append", + "content": "\n\n## New Section\n\nThis content was appended.", + }, + ) + + # Should return successful edit summary + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (append)" in edit_text + assert "Added 5 lines to end of note" in edit_text + assert "test/append-test-note" in edit_text + + # Verify the content was actually appended + read_result = await client.call_tool( + "read_note", + { + "identifier": "Append Test Note", + }, + ) + + content = read_result[0].text + assert "Original content here." in content + assert "## New Section" in content + assert "This content was appended." in content + + +@pytest.mark.asyncio +async def test_edit_note_prepend_operation(mcp_server, app): + """Test prepending content to an existing note.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Prepend Test Note", + "folder": "test", + "content": "# Prepend Test Note\n\nExisting content.", + "tags": "test,prepend", + }, + ) + + # Test prepending content + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "test/prepend-test-note", + "operation": "prepend", + "content": "## Important Update\n\nThis was added at the top.\n\n", + }, + ) + + # Should return successful edit summary + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (prepend)" in edit_text + assert "Added 5 lines to beginning of note" in edit_text + + # Verify the content was prepended after frontmatter + read_result = await client.call_tool( + "read_note", + { + "identifier": "test/prepend-test-note", + }, + ) + + content = read_result[0].text + assert "## Important Update" in content + assert "This was added at the top." in content + assert "Existing content." in content + # Check that prepended content comes before existing content + prepend_pos = content.find("Important Update") + existing_pos = content.find("Existing content") + assert prepend_pos < existing_pos + + +@pytest.mark.asyncio +async def test_edit_note_find_replace_operation(mcp_server, app): + """Test find and replace operation on an existing note.""" + + async with Client(mcp_server) as client: + # Create a note with content to replace + await client.call_tool( + "write_note", + { + "title": "Find Replace Test", + "folder": "test", + "content": """# Find Replace Test + +This is version v1.0.0 of the system. + +## Notes +- The current version is v1.0.0 +- Next version will be v1.1.0 + +## Changes +v1.0.0 introduces new features.""", + "tags": "test,version", + }, + ) + + # Test find and replace operation (expecting 3 replacements) + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Find Replace Test", + "operation": "find_replace", + "content": "v1.2.0", + "find_text": "v1.0.0", + "expected_replacements": 3, + }, + ) + + # Should return successful edit summary + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (find_replace)" in edit_text + assert "Find and replace operation completed" in edit_text + + # Verify the replacements were made + read_result = await client.call_tool( + "read_note", + { + "identifier": "Find Replace Test", + }, + ) + + content = read_result[0].text + assert "v1.2.0" in content + assert "v1.0.0" not in content # Should be completely replaced + assert content.count("v1.2.0") == 3 # Should have exactly 3 occurrences + + +@pytest.mark.asyncio +async def test_edit_note_replace_section_operation(mcp_server, app): + """Test replacing content under a specific section header.""" + + async with Client(mcp_server) as client: + # Create a note with sections + await client.call_tool( + "write_note", + { + "title": "Section Replace Test", + "folder": "test", + "content": """# Section Replace Test + +## Overview +Original overview content. + +## Implementation +Old implementation details here. +This will be replaced. + +## Future Work +Some future work notes.""", + "tags": "test,section", + }, + ) + + # Test replacing section content + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "test/section-replace-test", + "operation": "replace_section", + "content": """New implementation approach using microservices. + +- Service A handles authentication +- Service B manages data processing +- Service C provides API endpoints + +All services communicate via message queues.""", + "section": "## Implementation", + }, + ) + + # Should return successful edit summary + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (replace_section)" in edit_text + assert "Replaced content under section '## Implementation'" in edit_text + + # Verify the section was replaced + read_result = await client.call_tool( + "read_note", + { + "identifier": "Section Replace Test", + }, + ) + + content = read_result[0].text + assert "New implementation approach using microservices" in content + assert "Old implementation details here" not in content + assert "Service A handles authentication" in content + # Other sections should remain unchanged + assert "Original overview content" in content + assert "Some future work notes" in content + + +@pytest.mark.asyncio +async def test_edit_note_with_observations_and_relations(mcp_server, app): + """Test editing a note that has observations and relations, and verify they're updated.""" + + async with Client(mcp_server) as client: + # Create a complex note with observations and relations + complex_content = """# API Documentation + +The API provides REST endpoints for data access. + +## Observations +- [feature] User authentication endpoints +- [tech] Built with FastAPI framework +- [status] Currently in beta testing + +## Relations +- implements [[Authentication System]] +- documented_in [[API Guide]] +- depends_on [[Database Schema]] + +## Endpoints +Current endpoints include user management.""" + + await client.call_tool( + "write_note", + { + "title": "API Documentation", + "folder": "docs", + "content": complex_content, + "tags": "api,docs", + }, + ) + + # Add new content with observations and relations + new_content = """ +## New Features +- [feature] Added payment processing endpoints +- [feature] Implemented rate limiting +- [security] Added OAuth2 authentication + +## Additional Relations +- integrates_with [[Payment Gateway]] +- secured_by [[OAuth2 Provider]]""" + + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "API Documentation", + "operation": "append", + "content": new_content, + }, + ) + + # Should return edit summary with observation and relation counts + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (append)" in edit_text + assert "## Observations" in edit_text + assert "## Relations" in edit_text + # Should have feature, tech, status, security categories + assert "feature:" in edit_text + assert "security:" in edit_text + assert "tech:" in edit_text + assert "status:" in edit_text + + # Verify the content was added and processed + read_result = await client.call_tool( + "read_note", + { + "identifier": "API Documentation", + }, + ) + + content = read_result[0].text + assert "Added payment processing endpoints" in content + assert "integrates_with [[Payment Gateway]]" in content + + +@pytest.mark.asyncio +async def test_edit_note_error_handling_note_not_found(mcp_server, app): + """Test error handling when trying to edit a non-existent note.""" + + async with Client(mcp_server) as client: + # Try to edit a note that doesn't exist + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Non-existent Note", + "operation": "append", + "content": "Some content to add", + }, + ) + + # 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 "Non-existent Note" in error_text + assert "search_notes(" in error_text + assert "Suggestions to try:" in error_text + + +@pytest.mark.asyncio +async def test_edit_note_error_handling_text_not_found(mcp_server, app): + """Test error handling when find_text is not found in the note.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Error Test Note", + "folder": "test", + "content": "# Error Test Note\n\nThis note has specific content.", + "tags": "test,error", + }, + ) + + # Try to replace text that doesn't exist + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Error Test Note", + "operation": "find_replace", + "content": "replacement text", + "find_text": "non-existent text", + }, + ) + + # Should return helpful error message + assert len(edit_result) == 1 + error_text = edit_result[0].text + assert "Edit Failed - Text Not Found" in error_text + assert "non-existent text" in error_text + assert "Error Test Note" in error_text + assert "read_note(" in error_text + + +@pytest.mark.asyncio +async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app): + """Test error handling when expected_replacements doesn't match actual occurrences.""" + + async with Client(mcp_server) as client: + # Create a note with specific repeated text + await client.call_tool( + "write_note", + { + "title": "Count Test Note", + "folder": "test", + "content": """# Count Test Note + +The word "test" appears here. +This is another test sentence. +Final test of the content.""", + "tags": "test,count", + }, + ) + + # Try to replace "test" but expect wrong count (should be 3, not 5) + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Count Test Note", + "operation": "find_replace", + "content": "example", + "find_text": "test", + "expected_replacements": 5, + }, + ) + + # Should return helpful error message about count mismatch + assert len(edit_result) == 1 + error_text = edit_result[0].text + assert "Edit Failed - Wrong Replacement Count" in error_text + assert "Expected 5 occurrences" in error_text + assert "test" in error_text + assert "expected_replacements=" in error_text + + +@pytest.mark.asyncio +async def test_edit_note_invalid_operation(mcp_server, app): + """Test error handling for invalid operation parameter.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Invalid Op Test", + "folder": "test", + "content": "# Invalid Op Test\n\nSome content.", + "tags": "test", + }, + ) + + # Try to use an invalid operation - this should raise a ToolError + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "edit_note", + { + "identifier": "Invalid Op Test", + "operation": "invalid_operation", + "content": "Some content", + }, + ) + + # Should contain information about invalid operation + error_message = str(exc_info.value) + assert "Invalid operation 'invalid_operation'" in error_message + assert "append, prepend, find_replace, replace_section" in error_message + + +@pytest.mark.asyncio +async def test_edit_note_missing_required_parameters(mcp_server, app): + """Test error handling when required parameters are missing.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Param Test Note", + "folder": "test", + "content": "# Param Test Note\n\nContent here.", + "tags": "test", + }, + ) + + # Try find_replace without find_text parameter - this should raise a ToolError + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "edit_note", + { + "identifier": "Param Test Note", + "operation": "find_replace", + "content": "replacement", + # Missing find_text parameter + }, + ) + + # Should contain information about missing parameter + error_message = str(exc_info.value) + assert "find_text parameter is required for find_replace operation" in error_message + + +@pytest.mark.asyncio +async def test_edit_note_special_characters_in_content(mcp_server, app): + """Test editing notes with special characters, Unicode, and markdown formatting.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Special Chars Test", + "folder": "test", + "content": "# Special Chars Test\n\nBasic content here.", + "tags": "test,unicode", + }, + ) + + # Add content with special characters and Unicode + special_content = """ +## Unicode Section 🚀 + +This section contains: +- Emojis: 🎉 💡 ⚡ 🔥 +- Languages: 测试中文 Tëst Übër +- Math symbols: ∑∏∂∇∆Ω ≠≤≥ ∞ +- Special markdown: `code` **bold** *italic* +- URLs: https://example.com/path?param=value&other=123 +- Code blocks: +```python +def test_function(): + return "Hello, 世界!" +``` + +## Observations +- [unicode] Unicode characters preserved ✓ +- [markdown] Formatting maintained 📝 + +## Relations +- documented_in [[Unicode Standards]]""" + + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Special Chars Test", + "operation": "append", + "content": special_content, + }, + ) + + # Should successfully handle special characters + assert len(edit_result) == 1 + edit_text = edit_result[0].text + assert "Edited note (append)" in edit_text + assert "## Observations" in edit_text + assert "unicode:" in edit_text + assert "markdown:" in edit_text + + # Verify the special content was added correctly + read_result = await client.call_tool( + "read_note", + { + "identifier": "Special Chars Test", + }, + ) + + content = read_result[0].text + assert "🚀" in content + assert "测试中文" in content + assert "∑∏∂∇∆Ω" in content + assert "def test_function():" in content + assert "[[Unicode Standards]]" in content + + +@pytest.mark.asyncio +async def test_edit_note_using_different_identifiers(mcp_server, app): + """Test editing notes using different identifier formats (title, permalink, folder/title).""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Identifier Test Note", + "folder": "docs", + "content": "# Identifier Test Note\n\nOriginal content.", + "tags": "test,identifier", + }, + ) + + # Test editing by title + edit_result1 = await client.call_tool( + "edit_note", + { + "identifier": "Identifier Test Note", # by title + "operation": "append", + "content": "\n\nEdited by title.", + }, + ) + assert "Edited note (append)" in edit_result1[0].text + + # Test editing by permalink + edit_result2 = await client.call_tool( + "edit_note", + { + "identifier": "docs/identifier-test-note", # by permalink + "operation": "append", + "content": "\n\nEdited by permalink.", + }, + ) + assert "Edited note (append)" in edit_result2[0].text + + # Test editing by folder/title format + edit_result3 = await client.call_tool( + "edit_note", + { + "identifier": "docs/Identifier Test Note", # by folder/title + "operation": "append", + "content": "\n\nEdited by folder/title.", + }, + ) + assert "Edited note (append)" in edit_result3[0].text + + # Verify all edits were applied + read_result = await client.call_tool( + "read_note", + { + "identifier": "docs/identifier-test-note", + }, + ) + + content = read_result[0].text + assert "Edited by title." in content + assert "Edited by permalink." in content + assert "Edited by folder/title." in content diff --git a/test-int/mcp/test_list_directory_integration.py b/test-int/mcp/test_list_directory_integration.py new file mode 100644 index 000000000..4da07b445 --- /dev/null +++ b/test-int/mcp/test_list_directory_integration.py @@ -0,0 +1,467 @@ +""" +Integration tests for list_directory MCP tool. + +Tests the complete list directory workflow: MCP client -> MCP server -> FastAPI -> database -> file system +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_list_directory_basic_operation(mcp_server, app): + """Test basic list_directory operation showing root contents.""" + + async with Client(mcp_server) as client: + # Create some test files and directories first + await client.call_tool( + "write_note", + { + "title": "Root Note", + "folder": "", # Root folder + "content": "# Root Note\n\nThis is in the root directory.", + "tags": "test,root", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Project Planning", + "folder": "projects", + "content": "# Project Planning\n\nPlanning document for projects.", + "tags": "planning,project", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Meeting Notes", + "folder": "meetings", + "content": "# Meeting Notes\n\nNotes from the meeting.", + "tags": "meeting,notes", + }, + ) + + # List root directory + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/", + "depth": 1, + }, + ) + + # Should return formatted directory listing + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show the structure + assert "Contents of '/' (depth 1):" in list_text + assert "📁 meetings" in list_text + assert "📁 projects" in list_text + assert "📄 Root Note.md" in list_text + assert "Root Note" in list_text # Title should be shown + assert "Total:" in list_text + assert "directories" in list_text + assert "file" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_specific_folder(mcp_server, app): + """Test listing contents of a specific folder.""" + + async with Client(mcp_server) as client: + # Create nested structure + await client.call_tool( + "write_note", + { + "title": "Task List", + "folder": "work", + "content": "# Task List\n\nWork tasks for today.", + "tags": "work,tasks", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Project Alpha", + "folder": "work/projects", + "content": "# Project Alpha\n\nAlpha project documentation.", + "tags": "project,alpha", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Daily Standup", + "folder": "work/meetings", + "content": "# Daily Standup\n\nStandup meeting notes.", + "tags": "meeting,standup", + }, + ) + + # List specific folder + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/work", + "depth": 1, + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show work folder contents + assert "Contents of '/work' (depth 1):" in list_text + assert "📁 meetings" in list_text + assert "📁 projects" in list_text + assert "📄 Task List.md" in list_text + assert "work/Task List.md" in list_text # Path should be shown without leading slash + + +@pytest.mark.asyncio +async def test_list_directory_with_depth(mcp_server, app): + """Test recursive directory listing with depth control.""" + + async with Client(mcp_server) as client: + # Create deep nested structure + await client.call_tool( + "write_note", + { + "title": "Deep Note", + "folder": "research/ml/algorithms/neural-networks", + "content": "# Deep Note\n\nDeep learning research.", + "tags": "research,ml,deep", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "ML Overview", + "folder": "research/ml", + "content": "# ML Overview\n\nMachine learning overview.", + "tags": "research,ml,overview", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Research Index", + "folder": "research", + "content": "# Research Index\n\nIndex of research topics.", + "tags": "research,index", + }, + ) + + # List with depth=3 to see nested structure + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/research", + "depth": 3, + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show nested structure within depth=3 + assert "Contents of '/research' (depth 3):" in list_text + assert "📁 ml" in list_text + assert "📄 Research Index.md" in list_text + assert "📄 ML Overview.md" in list_text + assert "📁 algorithms" in list_text # Should show nested dirs within depth + + +@pytest.mark.asyncio +async def test_list_directory_with_glob_pattern(mcp_server, app): + """Test directory listing with glob pattern filtering.""" + + async with Client(mcp_server) as client: + # Create files with different patterns + await client.call_tool( + "write_note", + { + "title": "Meeting 2025-01-15", + "folder": "meetings", + "content": "# Meeting 2025-01-15\n\nMonday meeting notes.", + "tags": "meeting,january", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Meeting 2025-01-22", + "folder": "meetings", + "content": "# Meeting 2025-01-22\n\nMonday meeting notes.", + "tags": "meeting,january", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Project Status", + "folder": "meetings", + "content": "# Project Status\n\nProject status update.", + "tags": "meeting,project", + }, + ) + + # List with glob pattern for meeting files + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/meetings", + "depth": 1, + "file_name_glob": "Meeting*", + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show only matching files + assert "Files in '/meetings' matching 'Meeting*' (depth 1):" in list_text + assert "📄 Meeting 2025-01-15.md" in list_text + assert "📄 Meeting 2025-01-22.md" in list_text + assert "Project Status" not in list_text # Should be filtered out + + +@pytest.mark.asyncio +async def test_list_directory_empty_directory(mcp_server, app): + """Test listing an empty directory.""" + + async with Client(mcp_server) as client: + # List non-existent/empty directory + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/empty", + "depth": 1, + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should indicate no files found + assert "No files found in directory '/empty'" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_glob_no_matches(mcp_server, app): + """Test glob pattern that matches no files.""" + + async with Client(mcp_server) as client: + # Create some files + await client.call_tool( + "write_note", + { + "title": "Document One", + "folder": "docs", + "content": "# Document One\n\nFirst document.", + "tags": "doc", + }, + ) + + # List with glob pattern that won't match + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/docs", + "depth": 1, + "file_name_glob": "*.py", # No Python files + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should indicate no matches for the pattern + assert "No files found in directory '/docs' matching '*.py'" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_various_file_types(mcp_server, app): + """Test listing directories with various file types and metadata display.""" + + async with Client(mcp_server) as client: + # Create files with different characteristics + await client.call_tool( + "write_note", + { + "title": "Simple Note", + "folder": "mixed", + "content": "# Simple Note\n\nA simple note.", + "tags": "simple", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Complex Document with Long Title", + "folder": "mixed", + "content": "# Complex Document with Long Title\n\nA more complex document.", + "tags": "complex,long", + }, + ) + + # List the mixed directory + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/mixed", + "depth": 1, + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show file names, paths, and titles + assert "📄 Simple Note.md" in list_text + assert "mixed/Simple Note.md" in list_text + assert "📄 Complex Document with Long Title.md" in list_text + assert "mixed/Complex Document with Long Title.md" in list_text + assert "Total: 2 items (2 files)" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_default_parameters(mcp_server, app): + """Test list_directory with default parameters (root, depth=1).""" + + async with Client(mcp_server) as client: + # Create some content + await client.call_tool( + "write_note", + { + "title": "Default Test", + "folder": "default-test", + "content": "# Default Test\n\nTesting default parameters.", + "tags": "default", + }, + ) + + # List with minimal parameters (should use defaults) + list_result = await client.call_tool( + "list_directory", + {}, # Use all defaults + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show root directory with depth 1 + assert "Contents of '/' (depth 1):" in list_text + assert "📁 default-test" in list_text + assert "Total:" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_deep_recursion(mcp_server, app): + """Test directory listing with maximum depth.""" + + async with Client(mcp_server) as client: + # Create very deep structure + await client.call_tool( + "write_note", + { + "title": "Level 5 Note", + "folder": "level1/level2/level3/level4/level5", + "content": "# Level 5 Note\n\nVery deep note.", + "tags": "deep,level5", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Level 3 Note", + "folder": "level1/level2/level3", + "content": "# Level 3 Note\n\nMid-level note.", + "tags": "medium,level3", + }, + ) + + # List with maximum depth (depth=10) + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/level1", + "depth": 10, # Maximum allowed depth + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show deep structure + assert "Contents of '/level1' (depth 10):" in list_text + assert "📁 level2" in list_text + assert "📄 Level 3 Note.md" in list_text + assert "📄 Level 5 Note.md" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_complex_glob_patterns(mcp_server, app): + """Test various glob patterns for file filtering.""" + + async with Client(mcp_server) as client: + # Create files with different naming patterns + await client.call_tool( + "write_note", + { + "title": "Project Alpha Plan", + "folder": "patterns", + "content": "# Project Alpha Plan\n\nAlpha planning.", + "tags": "project,alpha", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Project Beta Plan", + "folder": "patterns", + "content": "# Project Beta Plan\n\nBeta planning.", + "tags": "project,beta", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Meeting Minutes", + "folder": "patterns", + "content": "# Meeting Minutes\n\nMeeting notes.", + "tags": "meeting", + }, + ) + + # Test wildcard pattern + list_result = await client.call_tool( + "list_directory", + { + "dir_name": "/patterns", + "file_name_glob": "Project*", + }, + ) + + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show only Project files + assert "Project Alpha Plan.md" in list_text + assert "Project Beta Plan.md" in list_text + assert "Meeting Minutes" not in list_text + assert "matching 'Project*'" in list_text diff --git a/test-int/mcp/test_move_note_integration.py b/test-int/mcp/test_move_note_integration.py new file mode 100644 index 000000000..8cf3189b4 --- /dev/null +++ b/test-int/mcp/test_move_note_integration.py @@ -0,0 +1,515 @@ +""" +Integration tests for move_note MCP tool. + +Tests the complete move note workflow: MCP client -> MCP server -> FastAPI -> database -> file system +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_move_note_basic_operation(mcp_server, app): + """Test basic move note operation to a new folder.""" + + async with Client(mcp_server) as client: + # Create a note to move + await client.call_tool( + "write_note", + { + "title": "Move Test Note", + "folder": "source", + "content": "# Move Test Note\n\nThis note will be moved to a new location.", + "tags": "test,move", + }, + ) + + # Move the note to a new location + move_result = await client.call_tool( + "move_note", + { + "identifier": "Move Test Note", + "destination_path": "destination/moved-note.md", + }, + ) + + # Should return successful move message + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + assert "Move Test Note" in move_text + assert "destination/moved-note.md" in move_text + assert "📊 Database and search index updated" in move_text + + # Verify the note can be read from its new location + read_result = await client.call_tool( + "read_note", + { + "identifier": "destination/moved-note.md", + }, + ) + + content = read_result[0].text + assert "This note will be moved to a new location" in content + + # Verify the original location no longer works + read_original = await client.call_tool( + "read_note", + { + "identifier": "source/move-test-note.md", + }, + ) + + # Should return "Note Not Found" message + assert "Note Not Found" in read_original[0].text + + +@pytest.mark.asyncio +async def test_move_note_using_permalink(mcp_server, app): + """Test moving a note using its permalink as identifier.""" + + async with Client(mcp_server) as client: + # Create a note to move + await client.call_tool( + "write_note", + { + "title": "Permalink Move Test", + "folder": "test", + "content": "# Permalink Move Test\n\nMoving by permalink.", + "tags": "test,permalink", + }, + ) + + # Move using permalink + move_result = await client.call_tool( + "move_note", + { + "identifier": "test/permalink-move-test", + "destination_path": "archive/permalink-moved.md", + }, + ) + + # Should successfully move + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + assert "test/permalink-move-test" in move_text + assert "archive/permalink-moved.md" in move_text + + # Verify accessibility at new location + read_result = await client.call_tool( + "read_note", + { + "identifier": "archive/permalink-moved.md", + }, + ) + + assert "Moving by permalink" in read_result[0].text + + +@pytest.mark.asyncio +async def test_move_note_with_observations_and_relations(mcp_server, app): + """Test moving a note that contains observations and relations.""" + + async with Client(mcp_server) as client: + # Create complex note with observations and relations + complex_content = """# Complex Note + +This note has various structured content. + +## Observations +- [feature] Has structured observations +- [tech] Uses markdown format +- [status] Ready for move testing + +## Relations +- implements [[Auth System]] +- documented_in [[Move Guide]] +- depends_on [[File System]] + +## Content +This note demonstrates moving complex content.""" + + await client.call_tool( + "write_note", + { + "title": "Complex Note", + "folder": "complex", + "content": complex_content, + "tags": "test,complex,move", + }, + ) + + # Move the complex note + move_result = await client.call_tool( + "move_note", + { + "identifier": "Complex Note", + "destination_path": "moved/complex-note.md", + }, + ) + + # Should successfully move + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + assert "Complex Note" in move_text + assert "moved/complex-note.md" in move_text + + # Verify content preservation including structured data + read_result = await client.call_tool( + "read_note", + { + "identifier": "moved/complex-note.md", + }, + ) + + content = read_result[0].text + assert "Has structured observations" in content + assert "implements [[Auth System]]" in content + assert "## Observations" in content + assert "[feature]" in content # Should show original markdown observations + assert "## Relations" in content + + +@pytest.mark.asyncio +async def test_move_note_to_nested_directory(mcp_server, app): + """Test moving a note to a deeply nested directory structure.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Nested Move Test", + "folder": "root", + "content": "# Nested Move Test\n\nThis will be moved deep.", + "tags": "test,nested", + }, + ) + + # Move to a deep nested structure + move_result = await client.call_tool( + "move_note", + { + "identifier": "Nested Move Test", + "destination_path": "projects/2025/q2/work/nested-note.md", + }, + ) + + # Should successfully create directory structure and move + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + assert "Nested Move Test" in move_text + assert "projects/2025/q2/work/nested-note.md" in move_text + + # Verify accessibility + read_result = await client.call_tool( + "read_note", + { + "identifier": "projects/2025/q2/work/nested-note.md", + }, + ) + + assert "This will be moved deep" in read_result[0].text + + +@pytest.mark.asyncio +async def test_move_note_with_special_characters(mcp_server, app): + """Test moving notes with special characters in titles and paths.""" + + async with Client(mcp_server) as client: + # Create note with special characters + await client.call_tool( + "write_note", + { + "title": "Special (Chars) & Symbols", + "folder": "special", + "content": "# Special (Chars) & Symbols\n\nTesting special characters in move.", + "tags": "test,special", + }, + ) + + # Move to path with special characters + move_result = await client.call_tool( + "move_note", + { + "identifier": "Special (Chars) & Symbols", + "destination_path": "archive/special-chars-note.md", + }, + ) + + # Should handle special characters properly + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + assert "archive/special-chars-note.md" in move_text + + # Verify content preservation + read_result = await client.call_tool( + "read_note", + { + "identifier": "archive/special-chars-note.md", + }, + ) + + assert "Testing special characters in move" in read_result[0].text + + +@pytest.mark.asyncio +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", + }, + ) + + # 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 + ) + + +@pytest.mark.asyncio +async def test_move_note_error_handling_invalid_destination(mcp_server, app): + """Test error handling for invalid destination paths.""" + + async with Client(mcp_server) as client: + # Create a note to attempt moving + await client.call_tool( + "write_note", + { + "title": "Invalid Dest Test", + "folder": "test", + "content": "# Invalid Dest Test\n\nThis move should fail.", + "tags": "test,error", + }, + ) + + # 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", + }, + ) + + # 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 + ) + + +@pytest.mark.asyncio +async def test_move_note_error_handling_destination_exists(mcp_server, app): + """Test error handling when destination file already exists.""" + + async with Client(mcp_server) as client: + # Create source note + await client.call_tool( + "write_note", + { + "title": "Source Note", + "folder": "source", + "content": "# Source Note\n\nThis is the source.", + "tags": "test,source", + }, + ) + + # Create destination note that already exists at the exact path we'll try to move to + await client.call_tool( + "write_note", + { + "title": "Existing Note", + "folder": "destination", + "content": "# Existing Note\n\nThis already exists.", + "tags": "test,existing", + }, + ) + + # 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 + }, + ) + + # 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 + ) + + +@pytest.mark.asyncio +async def test_move_note_preserves_search_functionality(mcp_server, app): + """Test that moved notes remain searchable after move operation.""" + + async with Client(mcp_server) as client: + # Create a note with searchable content + await client.call_tool( + "write_note", + { + "title": "Searchable Note", + "folder": "original", + "content": """# Searchable Note + +This note contains unique search terms: +- quantum mechanics +- artificial intelligence +- machine learning algorithms + +## Features +- [technology] Advanced AI features +- [research] Quantum computing research + +## Relations +- relates_to [[AI Research]]""", + "tags": "search,test,move", + }, + ) + + # Verify note is searchable before move + search_before = await client.call_tool( + "search_notes", + { + "query": "quantum mechanics", + }, + ) + + assert len(search_before) > 0 + assert "Searchable Note" in search_before[0].text + + # Move the note + move_result = await client.call_tool( + "move_note", + { + "identifier": "Searchable Note", + "destination_path": "research/quantum-ai-note.md", + }, + ) + + assert len(move_result) == 1 + move_text = move_result[0].text + assert "✅ Note moved successfully" in move_text + + # Verify note is still searchable after move + search_after = await client.call_tool( + "search_notes", + { + "query": "quantum mechanics", + }, + ) + + assert len(search_after) > 0 + search_text = search_after[0].text + assert "quantum mechanics" in search_text + assert "research/quantum-ai-note.md" in search_text or "quantum-ai-note" in search_text + + # Verify search by new location works + search_by_path = await client.call_tool( + "search_notes", + { + "query": "research/quantum", + }, + ) + + assert len(search_by_path) > 0 + + +@pytest.mark.asyncio +async def test_move_note_using_different_identifier_formats(mcp_server, app): + """Test moving notes using different identifier formats (title, permalink, folder/title).""" + + async with Client(mcp_server) as client: + # Create notes for different identifier tests + await client.call_tool( + "write_note", + { + "title": "Title ID Note", + "folder": "test", + "content": "# Title ID Note\n\nMove by title.", + "tags": "test,identifier", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Permalink ID Note", + "folder": "test", + "content": "# Permalink ID Note\n\nMove by permalink.", + "tags": "test,identifier", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Folder Title Note", + "folder": "test", + "content": "# Folder Title Note\n\nMove by folder/title.", + "tags": "test,identifier", + }, + ) + + # Test moving by title + move1 = await client.call_tool( + "move_note", + { + "identifier": "Title ID Note", # by title + "destination_path": "moved/title-moved.md", + }, + ) + assert len(move1) == 1 + assert "✅ Note moved successfully" in move1[0].text + + # Test moving by permalink + move2 = await client.call_tool( + "move_note", + { + "identifier": "test/permalink-id-note", # by permalink + "destination_path": "moved/permalink-moved.md", + }, + ) + assert len(move2) == 1 + assert "✅ Note moved successfully" in move2[0].text + + # Test moving by folder/title format + move3 = await client.call_tool( + "move_note", + { + "identifier": "test/Folder Title Note", # by folder/title + "destination_path": "moved/folder-title-moved.md", + }, + ) + assert len(move3) == 1 + assert "✅ Note moved successfully" in move3[0].text + + # Verify all notes can be accessed at their new locations + read1 = await client.call_tool("read_note", {"identifier": "moved/title-moved.md"}) + assert "Move by title" in read1[0].text + + read2 = await client.call_tool("read_note", {"identifier": "moved/permalink-moved.md"}) + assert "Move by permalink" in read2[0].text + + read3 = await client.call_tool("read_note", {"identifier": "moved/folder-title-moved.md"}) + assert "Move by folder/title" in read3[0].text diff --git a/test-int/mcp/test_project_management_integration.py b/test-int/mcp/test_project_management_integration.py new file mode 100644 index 000000000..143928f80 --- /dev/null +++ b/test-int/mcp/test_project_management_integration.py @@ -0,0 +1,637 @@ +""" +Integration tests for project_management MCP tools. + +Tests the complete project management workflow: MCP client -> MCP server -> FastAPI -> project service +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_list_projects_basic_operation(mcp_server, app): + """Test basic list_projects operation showing available projects.""" + + async with Client(mcp_server) as client: + # List all available projects + list_result = await client.call_tool( + "list_projects", + {}, + ) + + # Should return formatted project list + assert len(list_result) == 1 + list_text = list_result[0].text + + # Should show available projects with status indicators + assert "Available projects:" in list_text + assert "test-project" in list_text # Our default test project + assert "(current, default)" in list_text or "(default)" in list_text + assert "Project: test-project" in list_text # Project metadata + + +@pytest.mark.asyncio +async def test_get_current_project_operation(mcp_server, app): + """Test get_current_project showing current project info.""" + + async with Client(mcp_server) as client: + # Create some test content first to have stats + await client.call_tool( + "write_note", + { + "title": "Test Note", + "folder": "test", + "content": "# Test Note\n\nTest content.\n\n- [feature] Test observation", + "tags": "test", + }, + ) + + # Get current project info + current_result = await client.call_tool( + "get_current_project", + {}, + ) + + assert len(current_result) == 1 + current_text = current_result[0].text + + # Should show current project and stats + assert "Current project: test-project" in current_text + assert "entities" in current_text + assert "observations" in current_text + assert "relations" in current_text + assert "Project: test-project" in current_text # Project metadata + + +@pytest.mark.asyncio +async def test_project_info_with_entities(mcp_server, app): + """Test that project info shows correct entity counts.""" + + async with Client(mcp_server) as client: + # Create multiple entities with observations and relations + await client.call_tool( + "write_note", + { + "title": "Entity One", + "folder": "stats", + "content": """# Entity One + +This is the first entity. + +## Observations +- [type] First entity type +- [status] Active entity + +## Relations +- relates_to [[Entity Two]] +- implements [[Some System]]""", + "tags": "entity,test", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Entity Two", + "folder": "stats", + "content": """# Entity Two + +This is the second entity. + +## Observations +- [type] Second entity type +- [priority] High priority + +## Relations +- depends_on [[Entity One]]""", + "tags": "entity,test", + }, + ) + + # Get current project info to see updated stats + current_result = await client.call_tool( + "get_current_project", + {}, + ) + + assert len(current_result) == 1 + current_text = current_result[0].text + + # Should show entity and observation counts + assert "Current project: test-project" in current_text + # Should show at least the entities we created + assert ( + "2 entities" in current_text or "3 entities" in current_text + ) # May include other entities from setup + # Should show observations from our entities + assert ( + "4 observations" in current_text + or "5 observations" in current_text + or "6 observations" in current_text + ) # Our 4 + possibly more from setup + + +@pytest.mark.asyncio +async def test_switch_project_not_found(mcp_server, app): + """Test switch_project with non-existent project shows error.""" + + async with Client(mcp_server) as client: + # Try to switch to non-existent project + switch_result = await client.call_tool( + "switch_project", + { + "project_name": "non-existent-project", + }, + ) + + assert len(switch_result) == 1 + switch_text = switch_result[0].text + + # Should show error message with available projects + assert "Error: Project 'non-existent-project' not found" in switch_text + assert "Available projects:" in switch_text + assert "test-project" in switch_text + + +@pytest.mark.asyncio +async def test_switch_project_to_test_project(mcp_server, app): + """Test switching to the currently active project.""" + + async with Client(mcp_server) as client: + # Switch to the same project (test-project) + switch_result = await client.call_tool( + "switch_project", + { + "project_name": "test-project", + }, + ) + + assert len(switch_result) == 1 + switch_text = switch_result[0].text + + # Should show successful switch + assert "✓ Switched to test-project project" in switch_text + assert "Project Summary:" in switch_text + assert "entities" in switch_text + assert "observations" in switch_text + assert "relations" in switch_text + assert "Project: test-project" in switch_text # Project metadata + + +@pytest.mark.asyncio +async def test_set_default_project_operation(mcp_server, app): + """Test set_default_project functionality.""" + + async with Client(mcp_server) as client: + # Get current project info (default) + current_result = await client.call_tool( + "get_current_project", + {}, + ) + + assert len(current_result) == 1 + current_text = current_result[0].text + + # Should show current project and stats + assert "Current project: test-project" in current_text + + # Set test-project as default (it likely already is, but test the operation) + default_result = await client.call_tool( + "set_default_project", + { + "project_name": "test-project", + }, + ) + + assert len(default_result) == 1 + default_text = default_result[0].text + + # Should show success message and restart instructions + assert "✓" in default_text # Success indicator + assert "test-project" in default_text + assert "Restart Basic Memory for this change to take effect" in default_text + assert "basic-memory mcp" in default_text + assert "Project: test-project" in default_text # Project metadata + + +@pytest.mark.asyncio +async def test_set_default_project_not_found(mcp_server, app): + """Test set_default_project with non-existent project.""" + + async with Client(mcp_server) as client: + # Try to set non-existent project as default + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "set_default_project", + { + "project_name": "non-existent-project", + }, + ) + + # Should show error about non-existent project + error_message = str(exc_info.value) + assert "set_default_project" in error_message + assert ( + "non-existent-project" in error_message + or "Invalid request" in error_message + or "Client error" in error_message + ) + + +@pytest.mark.asyncio +async def test_project_management_workflow(mcp_server, app): + """Test complete project management workflow.""" + + async with Client(mcp_server) as client: + # 1. Check current project + current_result = await client.call_tool("get_current_project", {}) + assert "test-project" in current_result[0].text + + # 2. List all projects + list_result = await client.call_tool("list_projects", {}) + assert "Available projects:" in list_result[0].text + assert "test-project" in list_result[0].text + + # 3. Switch to same project (should work) + switch_result = await client.call_tool("switch_project", {"project_name": "test-project"}) + assert "✓ Switched to test-project project" in switch_result[0].text + + # 4. Verify we're still on the same project + current_result2 = await client.call_tool("get_current_project", {}) + assert "Current project: test-project" in current_result2[0].text + + +@pytest.mark.asyncio +async def test_project_metadata_consistency(mcp_server, app): + """Test that all project management tools include consistent project metadata.""" + + async with Client(mcp_server) as client: + # Test all project management tools and verify they include project metadata + + # list_projects + list_result = await client.call_tool("list_projects", {}) + assert "Project: test-project" in list_result[0].text + + # get_current_project + current_result = await client.call_tool("get_current_project", {}) + assert "Project: test-project" in current_result[0].text + + # switch_project + switch_result = await client.call_tool("switch_project", {"project_name": "test-project"}) + assert "Project: test-project" in switch_result[0].text + + # set_default_project (skip since API not working in test env) + # default_result = await client.call_tool( + # "set_default_project", + # {"project_name": "test-project"} + # ) + # assert "Project: test-project" in default_result[0].text + + +@pytest.mark.asyncio +async def test_project_statistics_accuracy(mcp_server, app): + """Test that project statistics reflect actual content.""" + + async with Client(mcp_server) as client: + # Get initial stats + initial_result = await client.call_tool("get_current_project", {}) + initial_text = initial_result[0].text + assert initial_text is not None + + # Create a new entity + await client.call_tool( + "write_note", + { + "title": "Stats Test Note", + "folder": "stats-test", + "content": """# Stats Test Note + +Testing statistics accuracy. + +## Observations +- [test] This is a test observation +- [accuracy] Testing stats accuracy + +## Relations +- validates [[Project Statistics]]""", + "tags": "stats,test", + }, + ) + + # Get updated stats + updated_result = await client.call_tool("get_current_project", {}) + updated_text = updated_result[0].text + + # Should show project info with stats + assert "Current project: test-project" in updated_text + assert "entities" in updated_text + assert "observations" in updated_text + assert "relations" in updated_text + + # Stats should be reasonable (at least 1 entity, some observations) + import re + + entity_match = re.search(r"(\d+) entities", updated_text) + obs_match = re.search(r"(\d+) observations", updated_text) + + if entity_match: + entity_count = int(entity_match.group(1)) + assert entity_count >= 1, f"Should have at least 1 entity, got {entity_count}" + + if obs_match: + obs_count = int(obs_match.group(1)) + assert obs_count >= 2, f"Should have at least 2 observations, got {obs_count}" + + +@pytest.mark.asyncio +async def test_create_project_basic_operation(mcp_server, app): + """Test creating a new project with basic parameters.""" + + async with Client(mcp_server) as client: + # Create a new project + create_result = await client.call_tool( + "create_project", + { + "project_name": "test-new-project", + "project_path": "/tmp/test-new-project", + }, + ) + + assert len(create_result) == 1 + create_text = create_result[0].text + + # Should show success message and project details + assert "✓" in create_text # Success indicator + assert "test-new-project" in create_text + assert "Project Details:" in create_text + assert "Name: test-new-project" in create_text + assert "Path: /tmp/test-new-project" in create_text + assert "Project is now available for use" in create_text + assert "Project: test-project" in create_text # Should still show current project + + # Verify project appears in project list + list_result = await client.call_tool("list_projects", {}) + list_text = list_result[0].text + assert "test-new-project" in list_text + + +@pytest.mark.asyncio +async def test_create_project_with_default_flag(mcp_server, app): + """Test creating a project and setting it as default.""" + + async with Client(mcp_server) as client: + # Create a new project and set as default + create_result = await client.call_tool( + "create_project", + { + "project_name": "test-default-project", + "project_path": "/tmp/test-default-project", + "set_default": True, + }, + ) + + assert len(create_result) == 1 + create_text = create_result[0].text + + # Should show success and default flag + assert "✓" in create_text + assert "test-default-project" in create_text + assert "Set as default project" in create_text + assert "Project: test-default-project" in create_text # Should switch to new project + + # Verify we switched to the new project + current_result = await client.call_tool("get_current_project", {}) + current_text = current_result[0].text + assert "Current project: test-default-project" in current_text + + +@pytest.mark.asyncio +async def test_create_project_duplicate_name(mcp_server, app): + """Test creating a project with duplicate name shows error.""" + + async with Client(mcp_server) as client: + # First create a project + await client.call_tool( + "create_project", + { + "project_name": "duplicate-test", + "project_path": "/tmp/duplicate-test-1", + }, + ) + + # Try to create another project with same name + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "create_project", + { + "project_name": "duplicate-test", + "project_path": "/tmp/duplicate-test-2", + }, + ) + + # Should show error about duplicate name + error_message = str(exc_info.value) + assert "create_project" in error_message + assert ( + "duplicate-test" in error_message + or "already exists" in error_message + or "Invalid request" in error_message + ) + + +@pytest.mark.asyncio +async def test_delete_project_basic_operation(mcp_server, app): + """Test deleting a project that exists.""" + + async with Client(mcp_server) as client: + # First create a project to delete + await client.call_tool( + "create_project", + { + "project_name": "to-be-deleted", + "project_path": "/tmp/to-be-deleted", + }, + ) + + # Verify it exists + list_result = await client.call_tool("list_projects", {}) + assert "to-be-deleted" in list_result[0].text + + # Delete the project + delete_result = await client.call_tool( + "delete_project", + { + "project_name": "to-be-deleted", + }, + ) + + assert len(delete_result) == 1 + delete_text = delete_result[0].text + + # Should show success message + assert "✓" in delete_text + assert "to-be-deleted" in delete_text + assert "removed successfully" in delete_text + assert "Removed project details:" in delete_text + assert "Name: to-be-deleted" in delete_text + assert "Files remain on disk but project is no longer tracked" in delete_text + assert "Project: test-project" in delete_text # Should show current project + + # Verify project no longer appears in list + list_result_after = await client.call_tool("list_projects", {}) + assert "to-be-deleted" not in list_result_after[0].text + + +@pytest.mark.asyncio +async def test_delete_project_not_found(mcp_server, app): + """Test deleting a non-existent project shows error.""" + + async with Client(mcp_server) as client: + # Try to delete non-existent project + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "delete_project", + { + "project_name": "non-existent-project", + }, + ) + + # Should show error about non-existent project + error_message = str(exc_info.value) + assert "delete_project" in error_message + assert ( + "non-existent-project" in error_message + or "not found" in error_message + or "Invalid request" in error_message + ) + + +@pytest.mark.asyncio +async def test_delete_current_project_protection(mcp_server, app): + """Test that deleting the current project is prevented.""" + + async with Client(mcp_server) as client: + # Try to delete the current project (test-project) + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "delete_project", + { + "project_name": "test-project", + }, + ) + + # Should show error about deleting current project + error_message = str(exc_info.value) + assert "delete_project" in error_message + assert ( + "currently active" in error_message + or "test-project" in error_message + or "Switch to a different project" in error_message + ) + + +@pytest.mark.asyncio +async def test_project_lifecycle_workflow(mcp_server, app): + """Test complete project lifecycle: create, switch, use, delete.""" + + async with Client(mcp_server) as client: + project_name = "lifecycle-test" + project_path = "/tmp/lifecycle-test" + + # 1. Create new project + create_result = await client.call_tool( + "create_project", + { + "project_name": project_name, + "project_path": project_path, + }, + ) + assert "✓" in create_result[0].text + assert project_name in create_result[0].text + + # 2. Switch to the new project + switch_result = await client.call_tool( + "switch_project", + { + "project_name": project_name, + }, + ) + assert f"✓ Switched to {project_name} project" in switch_result[0].text + + # 3. Create content in the new project + await client.call_tool( + "write_note", + { + "title": "Lifecycle Test Note", + "folder": "test", + "content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing", + "tags": "lifecycle,test", + }, + ) + + # 4. Verify project stats show our content + current_result = await client.call_tool("get_current_project", {}) + current_text = current_result[0].text + assert f"Current project: {project_name}" in current_text + assert "entities" in current_text + + # 5. Switch back to original project + await client.call_tool( + "switch_project", + { + "project_name": "test-project", + }, + ) + + # 6. Delete the lifecycle test project + delete_result = await client.call_tool( + "delete_project", + { + "project_name": project_name, + }, + ) + assert "✓" in delete_result[0].text + assert f"{project_name}" in delete_result[0].text + assert "removed successfully" in delete_result[0].text + + # 7. Verify project is gone from list + list_result = await client.call_tool("list_projects", {}) + assert project_name not in list_result[0].text + + +@pytest.mark.asyncio +async def test_create_delete_project_edge_cases(mcp_server, app): + """Test edge cases for create and delete project operations.""" + + async with Client(mcp_server) as client: + # Test with special characters in project name (should be handled gracefully) + special_name = "test-project-with-dashes" + + # Create project with special characters + create_result = await client.call_tool( + "create_project", + { + "project_name": special_name, + "project_path": f"/tmp/{special_name}", + }, + ) + assert "✓" in create_result[0].text + assert special_name in create_result[0].text + + # Verify it appears in list + list_result = await client.call_tool("list_projects", {}) + assert special_name in list_result[0].text + + # Delete it + delete_result = await client.call_tool( + "delete_project", + { + "project_name": special_name, + }, + ) + assert "✓" in delete_result[0].text + assert special_name in delete_result[0].text + + # Verify it's gone + list_result_after = await client.call_tool("list_projects", {}) + assert special_name not in list_result_after[0].text diff --git a/test-int/mcp/test_read_content_integration.py b/test-int/mcp/test_read_content_integration.py new file mode 100644 index 000000000..55e7fcfed --- /dev/null +++ b/test-int/mcp/test_read_content_integration.py @@ -0,0 +1,367 @@ +""" +Integration tests for read_content MCP tool. + +Comprehensive tests covering text files, binary files, images, error cases, +and memory:// URL handling via the complete MCP client-server flow. +""" + +import json +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError + + +def parse_read_content_response(mcp_result): + """Helper function to parse read_content MCP response.""" + assert len(mcp_result) == 1 + assert mcp_result[0].type == "text" + return json.loads(mcp_result[0].text) + + +@pytest.mark.asyncio +async def test_read_content_markdown_file(mcp_server, app): + """Test reading a markdown file created by write_note.""" + + async with Client(mcp_server) as client: + # First create a note + await client.call_tool( + "write_note", + { + "title": "Content Test", + "folder": "test", + "content": "# Content Test\n\nThis is test content with **markdown**.", + "tags": "test,content", + }, + ) + + # Then read the raw file content + read_result = await client.call_tool( + "read_content", + { + "path": "test/Content Test.md", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + + assert response_data["type"] == "text" + assert response_data["content_type"] == "text/markdown; charset=utf-8" + assert response_data["encoding"] == "utf-8" + + content = response_data["text"] + + # Should contain the raw markdown with frontmatter + assert "# Content Test" in content + assert "This is test content with **markdown**." in content + assert "tags:" in content # frontmatter + assert "- test" in content # tags are in YAML list format + assert "- content" in content + + +@pytest.mark.asyncio +async def test_read_content_by_permalink(mcp_server, app): + """Test reading content using permalink instead of file path.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Permalink Test", + "folder": "docs", + "content": "# Permalink Test\n\nTesting permalink-based content reading.", + }, + ) + + # Read by permalink (without .md extension) + read_result = await client.call_tool( + "read_content", + { + "path": "docs/permalink-test", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + assert "# Permalink Test" in content + assert "Testing permalink-based content reading." in content + + +@pytest.mark.asyncio +async def test_read_content_memory_url(mcp_server, app): + """Test reading content using memory:// URL format.""" + + async with Client(mcp_server) as client: + # Create a note + await client.call_tool( + "write_note", + { + "title": "Memory URL Test", + "folder": "test", + "content": "# Memory URL Test\n\nTesting memory:// URL handling.", + "tags": "memory,url", + }, + ) + + # Read using memory:// URL + read_result = await client.call_tool( + "read_content", + { + "path": "memory://test/memory-url-test", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + assert "# Memory URL Test" in content + assert "Testing memory:// URL handling." in content + + +@pytest.mark.asyncio +async def test_read_content_unicode_file(mcp_server, app): + """Test reading content with unicode characters and emojis.""" + + async with Client(mcp_server) as client: + # Create a note with unicode content + unicode_content = ( + "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦\n\n测试中文内容" + ) + + await client.call_tool( + "write_note", + { + "title": "Unicode Content Test", + "folder": "test", + "content": unicode_content, + "tags": "unicode,emoji", + }, + ) + + # Read the content back + read_result = await client.call_tool( + "read_content", + { + "path": "test/Unicode Content Test.md", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + # All unicode content should be preserved + assert "🚀" in content + assert "🎉" in content + assert "♠♣♥♦" in content + assert "测试中文内容" in content + + +@pytest.mark.asyncio +async def test_read_content_complex_frontmatter(mcp_server, app): + """Test reading content with complex frontmatter and markdown.""" + + async with Client(mcp_server) as client: + # Create a note with complex content + complex_content = """--- +title: Complex Note +type: document +version: 1.0 +author: Test Author +metadata: + status: draft + priority: high +--- + +# Complex Note + +This note has complex frontmatter and various markdown elements. + +## Observations +- [tech] Uses YAML frontmatter +- [design] Structured content format + +## Relations +- related_to [[Other Note]] +- depends_on [[Framework]] + +Regular markdown content continues here.""" + + await client.call_tool( + "write_note", + { + "title": "Complex Note", + "folder": "docs", + "content": complex_content, + "tags": "complex,frontmatter", + }, + ) + + # Read the content back + read_result = await client.call_tool( + "read_content", + { + "path": "docs/Complex Note.md", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + # Should preserve all frontmatter and content structure + assert "version: 1.0" in content + assert "author: Test Author" in content + assert "status: draft" in content + assert "[tech] Uses YAML frontmatter" in content + assert "[[Other Note]]" in content + + +@pytest.mark.asyncio +async def test_read_content_missing_file(mcp_server, app): + """Test reading a file that doesn't exist.""" + + async with Client(mcp_server) as client: + try: + await client.call_tool( + "read_content", + { + "path": "nonexistent/file.md", + }, + ) + # Should not reach here - expecting an error + assert False, "Expected error for missing file" + except ToolError as e: + # Should get an appropriate error message + error_msg = str(e).lower() + assert "not found" in error_msg or "does not exist" in error_msg + + +@pytest.mark.asyncio +async def test_read_content_empty_file(mcp_server, app): + """Test reading an empty file.""" + + async with Client(mcp_server) as client: + # Create a note with minimal content + await client.call_tool( + "write_note", + { + "title": "Empty Test", + "folder": "test", + "content": "", # Empty content + }, + ) + + # Read the content back + read_result = await client.call_tool( + "read_content", + { + "path": "test/Empty Test.md", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + # Should still have frontmatter even with empty content + assert "title: Empty Test" in content + assert "permalink: test/empty-test" in content + + +@pytest.mark.asyncio +async def test_read_content_large_file(mcp_server, app): + """Test reading a file with substantial content.""" + + async with Client(mcp_server) as client: + # Create a note with substantial content + large_content = "# Large Content Test\n\n" + + # Add multiple sections with substantial text + for i in range(10): + large_content += f""" +## Section {i + 1} + +This is section {i + 1} with substantial content. Lorem ipsum dolor sit amet, +consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et +dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation. + +- [note] This is observation {i + 1} +- related_to [[Section {i}]] + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident. + +""" + + await client.call_tool( + "write_note", + { + "title": "Large Content Note", + "folder": "test", + "content": large_content, + "tags": "large,content,test", + }, + ) + + # Read the content back + read_result = await client.call_tool( + "read_content", + { + "path": "test/Large Content Note.md", + }, + ) + + # Parse the response + response_data = parse_read_content_response(read_result) + content = response_data["text"] + + # Should contain all sections + assert "Section 1" in content + assert "Section 10" in content + assert "Lorem ipsum" in content + assert len(content) > 1000 # Should be substantial + + +@pytest.mark.asyncio +async def test_read_content_special_characters_in_filename(mcp_server, app): + """Test reading files with special characters in the filename.""" + + async with Client(mcp_server) as client: + # Create notes with special characters in titles + test_cases = [ + ("File with spaces", "test"), + ("File-with-dashes", "test"), + ("File_with_underscores", "test"), + ("File (with parentheses)", "test"), + ("File & Symbols!", "test"), + ] + + for title, folder in test_cases: + await client.call_tool( + "write_note", + { + "title": title, + "folder": folder, + "content": f"# {title}\n\nContent for {title}", + }, + ) + + # Read the content back using the exact filename + read_result = await client.call_tool( + "read_content", + { + "path": f"{folder}/{title}.md", + }, + ) + + assert len(read_result) == 1 + assert read_result[0].type == "text" + content = read_result[0].text + + assert f"# {title}" in content + assert f"Content for {title}" in content diff --git a/test-int/mcp/test_read_note_integration.py b/test-int/mcp/test_read_note_integration.py new file mode 100644 index 000000000..318893b59 --- /dev/null +++ b/test-int/mcp/test_read_note_integration.py @@ -0,0 +1,46 @@ +""" +Integration tests for read_note MCP tool. + +Tests the full flow: MCP client -> MCP server -> FastAPI -> database +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_read_note_after_write(mcp_server, app): + """Test read_note after write_note using real database.""" + + async with Client(mcp_server) as client: + # First write a note + write_result = await client.call_tool( + "write_note", + { + "title": "Test Note", + "folder": "test", + "content": "# Test Note\n\nThis is test content.", + "tags": "test,integration", + }, + ) + + assert len(write_result) == 1 + assert write_result[0].type == "text" + assert "Test Note.md" in write_result[0].text + + # Then read it back + read_result = await client.call_tool( + "read_note", + { + "identifier": "Test Note", + }, + ) + + assert len(read_result) == 1 + assert read_result[0].type == "text" + result_text = read_result[0].text + + # Should contain the note content and metadata + assert "# Test Note" in result_text + assert "This is test content." in result_text + assert "test/test-note" in result_text # permalink diff --git a/test-int/mcp/test_search_integration.py b/test-int/mcp/test_search_integration.py new file mode 100644 index 000000000..1ca78744a --- /dev/null +++ b/test-int/mcp/test_search_integration.py @@ -0,0 +1,468 @@ +""" +Integration tests for search_notes MCP tool. + +Comprehensive tests covering search functionality using the complete +MCP client-server flow with real databases. +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_search_basic_text_search(mcp_server, app): + """Test basic text search functionality.""" + + async with Client(mcp_server) as client: + # Create test notes for searching + await client.call_tool( + "write_note", + { + "title": "Python Programming Guide", + "folder": "docs", + "content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.", + "tags": "python,programming", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Flask Web Development", + "folder": "docs", + "content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.", + "tags": "python,flask,web", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "JavaScript Basics", + "folder": "docs", + "content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.", + "tags": "javascript,programming", + }, + ) + + # Search for Python-related content + search_result = await client.call_tool( + "search_notes", + { + "query": "Python", + }, + ) + + assert len(search_result) == 1 + assert search_result[0].type == "text" + + # Parse the response (it should be a SearchResponse) + result_text = search_result[0].text + assert "Python Programming Guide" in result_text + assert "Flask Web Development" in result_text + assert "JavaScript Basics" not in result_text + + +@pytest.mark.asyncio +async def test_search_boolean_operators(mcp_server, app): + """Test boolean search operators (AND, OR, NOT).""" + + async with Client(mcp_server) as client: + # Create test notes + await client.call_tool( + "write_note", + { + "title": "Python Flask Tutorial", + "folder": "tutorials", + "content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.", + "tags": "python,flask,tutorial", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Python Django Guide", + "folder": "tutorials", + "content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.", + "tags": "python,django,web", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "React JavaScript", + "folder": "tutorials", + "content": "# React JavaScript\n\nBuilding frontend applications with React.", + "tags": "javascript,react,frontend", + }, + ) + + # Test AND operator + search_result = await client.call_tool( + "search_notes", + { + "query": "Python AND Flask", + }, + ) + + result_text = search_result[0].text + assert "Python Flask Tutorial" in result_text + assert "Python Django Guide" not in result_text + assert "React JavaScript" not in result_text + + # Test OR operator + search_result = await client.call_tool( + "search_notes", + { + "query": "Flask OR Django", + }, + ) + + result_text = search_result[0].text + assert "Python Flask Tutorial" in result_text + assert "Python Django Guide" in result_text + assert "React JavaScript" not in result_text + + # Test NOT operator + search_result = await client.call_tool( + "search_notes", + { + "query": "Python NOT Django", + }, + ) + + result_text = search_result[0].text + assert "Python Flask Tutorial" in result_text + assert "Python Django Guide" not in result_text + + +@pytest.mark.asyncio +async def test_search_title_only(mcp_server, app): + """Test searching in titles only.""" + + async with Client(mcp_server) as client: + # Create test notes + await client.call_tool( + "write_note", + { + "title": "Database Design", + "folder": "docs", + "content": "# Database Design\n\nThis covers SQL and database concepts.", + "tags": "database,sql", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Web Development", + "folder": "docs", + "content": "# Web Development\n\nDatabase integration in web applications.", + "tags": "web,development", + }, + ) + + # Search for "database" in titles only + search_result = await client.call_tool( + "search_notes", + { + "query": "Database", + "search_type": "title", + }, + ) + + result_text = search_result[0].text + assert "Database Design" in result_text + assert "Web Development" not in result_text # Has "database" in content but not title + + +@pytest.mark.asyncio +async def test_search_permalink_exact(mcp_server, app): + """Test exact permalink search.""" + + async with Client(mcp_server) as client: + # Create test notes + await client.call_tool( + "write_note", + { + "title": "API Documentation", + "folder": "api", + "content": "# API Documentation\n\nComplete API reference guide.", + "tags": "api,docs", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "API Testing", + "folder": "testing", + "content": "# API Testing\n\nHow to test REST APIs.", + "tags": "api,testing", + }, + ) + + # Search for exact permalink + search_result = await client.call_tool( + "search_notes", + { + "query": "api/api-documentation", + "search_type": "permalink", + }, + ) + + result_text = search_result[0].text + assert "API Documentation" in result_text + assert "API Testing" not in result_text + + +@pytest.mark.asyncio +async def test_search_permalink_pattern(mcp_server, app): + """Test permalink pattern search with wildcards.""" + + async with Client(mcp_server) as client: + # Create test notes in different folders + await client.call_tool( + "write_note", + { + "title": "Meeting Notes January", + "folder": "meetings", + "content": "# Meeting Notes January\n\nJanuary team meeting notes.", + "tags": "meetings,january", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Meeting Notes February", + "folder": "meetings", + "content": "# Meeting Notes February\n\nFebruary team meeting notes.", + "tags": "meetings,february", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Project Notes", + "folder": "projects", + "content": "# Project Notes\n\nGeneral project documentation.", + "tags": "projects,notes", + }, + ) + + # Search for all meeting notes using pattern + search_result = await client.call_tool( + "search_notes", + { + "query": "meetings/*", + "search_type": "permalink", + }, + ) + + result_text = search_result[0].text + assert "Meeting Notes January" in result_text + assert "Meeting Notes February" in result_text + assert "Project Notes" not in result_text + + +@pytest.mark.asyncio +async def test_search_entity_type_filter(mcp_server, app): + """Test filtering search results by entity type.""" + + async with Client(mcp_server) as client: + # Create a note with observations and relations + content_with_observations = """# Development Process + +This describes our development workflow. + +## Observations +- [process] We use Git for version control +- [tool] We use VS Code as our editor + +## Relations +- uses [[Git]] +- part_of [[Development Workflow]] + +Regular content about development practices.""" + + await client.call_tool( + "write_note", + { + "title": "Development Process", + "folder": "processes", + "content": content_with_observations, + "tags": "development,process", + }, + ) + + # Search for "development" in entities only + search_result = await client.call_tool( + "search_notes", + { + "query": "development", + "entity_types": ["entity"], + }, + ) + + result_text = search_result[0].text + # Should find the main entity but filter out observations/relations + assert "Development Process" in result_text + + +@pytest.mark.asyncio +async def test_search_pagination(mcp_server, app): + """Test search result pagination.""" + + async with Client(mcp_server) as client: + # Create multiple notes to test pagination + for i in range(15): + await client.call_tool( + "write_note", + { + "title": f"Test Note {i + 1:02d}", + "folder": "test", + "content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.", + "tags": "test,pagination", + }, + ) + + # Search with pagination (page 1, page_size 5) + search_result = await client.call_tool( + "search_notes", + { + "query": "test", + "page": 1, + "page_size": 5, + }, + ) + + result_text = search_result[0].text + # Should contain 5 results and pagination info + assert '"current_page": 1' in result_text + assert '"page_size": 5' in result_text + + # Search page 2 + search_result = await client.call_tool( + "search_notes", + { + "query": "test", + "page": 2, + "page_size": 5, + }, + ) + + result_text = search_result[0].text + assert '"current_page": 2' in result_text + + +@pytest.mark.asyncio +async def test_search_no_results(mcp_server, app): + """Test search with no matching results.""" + + async with Client(mcp_server) as client: + # Create a test note + await client.call_tool( + "write_note", + { + "title": "Sample Note", + "folder": "test", + "content": "# Sample Note\n\nThis is a sample note for testing.", + "tags": "sample,test", + }, + ) + + # Search for something that doesn't exist + search_result = await client.call_tool( + "search_notes", + { + "query": "nonexistent", + }, + ) + + result_text = search_result[0].text + assert '"results": []' in result_text or '"results":[]' in result_text + + +@pytest.mark.asyncio +async def test_search_complex_boolean_query(mcp_server, app): + """Test complex boolean queries with grouping.""" + + async with Client(mcp_server) as client: + # Create test notes + await client.call_tool( + "write_note", + { + "title": "Python Web Development", + "folder": "tutorials", + "content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.", + "tags": "python,web,development", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Python Data Science", + "folder": "tutorials", + "content": "# Python Data Science\n\nData analysis and machine learning with Python.", + "tags": "python,data,science", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "JavaScript Web Development", + "folder": "tutorials", + "content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.", + "tags": "javascript,web,development", + }, + ) + + # Complex boolean query: (Python OR JavaScript) AND web + search_result = await client.call_tool( + "search_notes", + { + "query": "(Python OR JavaScript) AND web", + }, + ) + + result_text = search_result[0].text + assert "Python Web Development" in result_text + assert "JavaScript Web Development" in result_text + assert "Python Data Science" not in result_text # Has Python but not web + + +@pytest.mark.asyncio +async def test_search_case_insensitive(mcp_server, app): + """Test that search is case insensitive.""" + + async with Client(mcp_server) as client: + # Create test note + await client.call_tool( + "write_note", + { + "title": "Machine Learning Guide", + "folder": "guides", + "content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.", + "tags": "ML,AI", + }, + ) + + # Search with different cases + search_cases = ["machine", "MACHINE", "Machine", "learning", "LEARNING"] + + for search_term in search_cases: + search_result = await client.call_tool( + "search_notes", + { + "query": search_term, + }, + ) + + result_text = search_result[0].text + assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}" diff --git a/test-int/mcp/test_write_note_integration.py b/test-int/mcp/test_write_note_integration.py new file mode 100644 index 000000000..8f504a6c8 --- /dev/null +++ b/test-int/mcp/test_write_note_integration.py @@ -0,0 +1,284 @@ +""" +Integration tests for write_note MCP tool. + +Comprehensive tests covering all scenarios including note creation, content formatting, +tag handling, error conditions, and edge cases from bug reports. +""" + +from textwrap import dedent + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_write_note_basic_creation(mcp_server, app): + """Test creating a simple note with basic content.""" + + async with Client(mcp_server) as client: + result = await client.call_tool( + "write_note", + { + "title": "Simple Note", + "folder": "basic", + "content": "# Simple Note\n\nThis is a simple note for testing.", + "tags": "simple,test", + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: basic/Simple Note.md" in response_text + assert "permalink: basic/simple-note" in response_text + assert "## Tags" in response_text + assert "- simple, test" in response_text + + +@pytest.mark.asyncio +async def test_write_note_no_tags(mcp_server, app): + """Test creating a note without tags.""" + + async with Client(mcp_server) as client: + result = await client.call_tool( + "write_note", + { + "title": "No Tags Note", + "folder": "test", + "content": "Just some plain text without tags.", + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: test/No Tags Note.md" in response_text + assert "permalink: test/no-tags-note" in response_text + # Should not have tags section when no tags provided + + +@pytest.mark.asyncio +async def test_write_note_update_existing(mcp_server, app): + """Test updating an existing note.""" + + async with Client(mcp_server) as client: + # Create initial note + result1 = await client.call_tool( + "write_note", + { + "title": "Update Test", + "folder": "test", + "content": "# Update Test\n\nOriginal content.", + "tags": "original", + }, + ) + + assert "# Created note" in result1[0].text + + # Update the same note + result2 = await client.call_tool( + "write_note", + { + "title": "Update Test", + "folder": "test", + "content": "# Update Test\n\nUpdated content with changes.", + "tags": "updated,modified", + }, + ) + + assert len(result2) == 1 + assert result2[0].type == "text" + response_text = result2[0].text + + assert "# Updated note" in response_text + assert "file_path: test/Update Test.md" in response_text + assert "permalink: test/update-test" in response_text + assert "- updated, modified" in response_text + + +@pytest.mark.asyncio +async def test_write_note_tag_array(mcp_server, app): + """Test creating a note with tag array (Issue #38 regression test).""" + + async with Client(mcp_server) as client: + # This reproduces the exact bug from Issue #38 + result = await client.call_tool( + "write_note", + { + "title": "Array Tags Test", + "folder": "test", + "content": "Testing tag array handling", + "tags": ["python", "testing", "integration", "mcp"], + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: test/Array Tags Test.md" in response_text + assert "permalink: test/array-tags-test" in response_text + assert "## Tags" in response_text + assert "python" in response_text + + +@pytest.mark.asyncio +async def test_write_note_custom_permalink(mcp_server, app): + """Test custom permalink handling (Issue #93 regression test).""" + + async with Client(mcp_server) as client: + content_with_custom_permalink = dedent(""" + --- + permalink: custom/my-special-permalink + --- + + # Custom Permalink Note + + This note has a custom permalink in frontmatter. + + - [note] Testing custom permalink preservation + """).strip() + + result = await client.call_tool( + "write_note", + { + "title": "Custom Permalink Note", + "folder": "notes", + "content": content_with_custom_permalink, + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: notes/Custom Permalink Note.md" in response_text + assert "permalink: custom/my-special-permalink" in response_text + + +@pytest.mark.asyncio +async def test_write_note_unicode_content(mcp_server, app): + """Test handling unicode content including emojis.""" + + async with Client(mcp_server) as client: + unicode_content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦\n\n- [note] Testing unicode handling 测试" + + result = await client.call_tool( + "write_note", + { + "title": "Unicode Test 🌟", + "folder": "test", + "content": unicode_content, + "tags": "unicode,emoji,测试", + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: test/Unicode Test 🌟.md" in response_text + # Permalink should be sanitized + assert "permalink: test/unicode-test" in response_text + assert "## Tags" in response_text + + +@pytest.mark.asyncio +async def test_write_note_complex_content_with_observations_relations(mcp_server, app): + """Test creating note with complex content including observations and relations.""" + + async with Client(mcp_server) as client: + complex_content = dedent(""" + # Complex Note + + This note demonstrates the full knowledge format. + + ## Observations + - [tech] Uses Python and FastAPI + - [design] Follows MCP protocol specification + - [note] Integration tests are comprehensive + + ## Relations + - implements [[MCP Protocol]] + - depends_on [[FastAPI Framework]] + - tested_by [[Integration Tests]] + + ## Additional Content + + Some more regular markdown content here. + """).strip() + + result = await client.call_tool( + "write_note", + { + "title": "Complex Knowledge Note", + "folder": "knowledge", + "content": complex_content, + "tags": "complex,knowledge,relations", + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: knowledge/Complex Knowledge Note.md" in response_text + assert "permalink: knowledge/complex-knowledge-note" in response_text + + # Should show observation and relation counts + assert "## Observations" in response_text + assert "tech: 1" in response_text + assert "design: 1" in response_text + assert "note: 1" in response_text + + assert "## Relations" in response_text + # Should show outgoing relations + + assert "## Tags" in response_text + assert "complex, knowledge, relations" in response_text + + +@pytest.mark.asyncio +async def test_write_note_preserve_frontmatter(mcp_server, app): + """Test that custom frontmatter is preserved when updating notes.""" + + async with Client(mcp_server) as client: + content_with_frontmatter = dedent(""" + --- + title: Frontmatter Note + type: note + version: 1.0 + author: Test Author + status: draft + --- + + # Frontmatter Note + + This note has custom frontmatter that should be preserved. + """).strip() + + result = await client.call_tool( + "write_note", + { + "title": "Frontmatter Note", + "folder": "test", + "content": content_with_frontmatter, + "tags": "frontmatter,preservation", + }, + ) + + assert len(result) == 1 + assert result[0].type == "text" + response_text = result[0].text + + assert "# Created note" in response_text + assert "file_path: test/Frontmatter Note.md" in response_text + assert "permalink: test/frontmatter-note" in response_text diff --git a/tests/.coverage.Pauls-MacBook-Pro-2.local.28077.XqMfGOxx b/tests/.coverage.Pauls-MacBook-Pro-2.local.28077.XqMfGOxx new file mode 100644 index 000000000..7b304ac04 Binary files /dev/null and b/tests/.coverage.Pauls-MacBook-Pro-2.local.28077.XqMfGOxx differ diff --git a/tests/api/.coverage.Pauls-MacBook-Pro-2.local.60974.XPpBfqqx b/tests/api/.coverage.Pauls-MacBook-Pro-2.local.60974.XPpBfqqx new file mode 100644 index 000000000..7b304ac04 Binary files /dev/null and b/tests/api/.coverage.Pauls-MacBook-Pro-2.local.60974.XPpBfqqx differ diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 3e43fecc3..0b39b877e 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -7,16 +7,17 @@ from fastapi import FastAPI from httpx import AsyncClient, ASGITransport -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.models import Project @pytest_asyncio.fixture -async def app(test_project, test_config, engine_factory) -> FastAPI: +async def app(test_config, engine_factory, app_config) -> FastAPI: """Create FastAPI test application.""" from basic_memory.api.app import app - app.dependency_overrides[get_project_config] = lambda: test_config + app.dependency_overrides[get_app_config] = lambda: app_config + app.dependency_overrides[get_project_config] = lambda: test_config.project_config app.dependency_overrides[get_engine_factory] = lambda: engine_factory return app @@ -36,4 +37,4 @@ def project_url(test_project: Project) -> str: """ # Make sure this matches what's in tests/conftest.py for test_project creation # The permalink should be generated from "Test Project Context" - return f"/{test_project.permalink}" + return f"/{test_project.permalink}" \ No newline at end of file diff --git a/tests/api/test_directory_router.py b/tests/api/test_directory_router.py index 1ac202d04..e9e050af3 100644 --- a/tests/api/test_directory_router.py +++ b/tests/api/test_directory_router.py @@ -125,3 +125,155 @@ async def test_get_directory_tree_mocked(client, project_url): assert folder2["directory_path"] == "/test/folder2" assert folder2["type"] == "directory" assert folder2["children"] == [] + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_default(test_graph, client, project_url): + """Test the list_directory endpoint with default parameters.""" + # Call the endpoint with default parameters + response = await client.get(f"{project_url}/directory/list") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Should return a list + assert isinstance(data, list) + + # With test_graph, should return the "test" directory + assert len(data) == 1 + assert data[0]["name"] == "test" + assert data[0]["type"] == "directory" + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_specific_path(test_graph, client, project_url): + """Test the list_directory endpoint with specific directory path.""" + # Call the endpoint with /test directory + response = await client.get(f"{project_url}/directory/list?dir_name=/test") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Should return list of files in test directory + assert isinstance(data, list) + assert len(data) == 5 + + # All should be files (no subdirectories in test_graph) + for item in data: + assert item["type"] == "file" + assert item["name"].endswith(".md") + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_with_glob(test_graph, client, project_url): + """Test the list_directory endpoint with glob filtering.""" + # Call the endpoint with glob filter + response = await client.get( + f"{project_url}/directory/list?dir_name=/test&file_name_glob=*Connected*" + ) + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Should return only Connected Entity files + assert isinstance(data, list) + assert len(data) == 2 + + file_names = {item["name"] for item in data} + assert file_names == {"Connected Entity 1.md", "Connected Entity 2.md"} + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_with_depth(test_graph, client, project_url): + """Test the list_directory endpoint with depth control.""" + # Test depth=1 (default) + response_depth_1 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=1") + assert response_depth_1.status_code == 200 + data_depth_1 = response_depth_1.json() + assert len(data_depth_1) == 1 # Just the test directory + + # Test depth=2 (should include files in test directory) + response_depth_2 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=2") + assert response_depth_2.status_code == 200 + data_depth_2 = response_depth_2.json() + assert len(data_depth_2) == 6 # test directory + 5 files + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_nonexistent_path(test_graph, client, project_url): + """Test the list_directory endpoint with nonexistent directory.""" + # Call the endpoint with nonexistent directory + response = await client.get(f"{project_url}/directory/list?dir_name=/nonexistent") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Should return empty list + assert isinstance(data, list) + assert len(data) == 0 + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_validation_errors(client, project_url): + """Test the list_directory endpoint with invalid parameters.""" + # Test depth too low + response = await client.get(f"{project_url}/directory/list?depth=0") + assert response.status_code == 422 # Validation error + + # Test depth too high + response = await client.get(f"{project_url}/directory/list?depth=11") + assert response.status_code == 422 # Validation error + + +@pytest.mark.asyncio +async def test_list_directory_endpoint_mocked(client, project_url): + """Test the list_directory endpoint with mocked service.""" + # Create mock directory nodes + mock_nodes = [ + DirectoryNode( + name="folder1", + directory_path="/folder1", + type="directory", + ), + DirectoryNode( + name="file1.md", + directory_path="/file1.md", + file_path="file1.md", + type="file", + title="File 1", + permalink="file-1", + ), + ] + + # Patch the directory service + with patch( + "basic_memory.services.directory_service.DirectoryService.list_directory", + return_value=mock_nodes, + ): + # Call the endpoint + response = await client.get(f"{project_url}/directory/list?dir_name=/test") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Check structure matches our mock + assert isinstance(data, list) + assert len(data) == 2 + + # Check directory + folder = next(item for item in data if item["type"] == "directory") + assert folder["name"] == "folder1" + assert folder["directory_path"] == "/folder1" + + # Check file + file_item = next(item for item in data if item["type"] == "file") + assert file_item["name"] == "file1.md" + assert file_item["directory_path"] == "/file1.md" + assert file_item["file_path"] == "file1.md" + assert file_item["title"] == "File 1" + assert file_item["permalink"] == "file-1" diff --git a/tests/api/test_importer_router.py b/tests/api/test_importer_router.py index 0a9ab28bb..42e97220a 100644 --- a/tests/api/test_importer_router.py +++ b/tests/api/test_importer_router.py @@ -144,7 +144,7 @@ async def create_test_upload_file(tmp_path, content): @pytest.mark.asyncio async def test_import_chatgpt( - test_config, client: AsyncClient, tmp_path, chatgpt_json_content, file_service, project_url + project_config, client: AsyncClient, tmp_path, chatgpt_json_content, file_service, project_url ): """Test importing ChatGPT conversations.""" # Create a test file diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index 1e9ff7f18..8877d43c1 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -530,3 +530,711 @@ async def test_update_entity_search_index(client: AsyncClient, project_url): results = search_response.json()["results"] assert len(results) == 1 assert results[0]["permalink"] == entity.permalink + + +# PATCH edit entity endpoint tests + + +@pytest.mark.asyncio +async def test_edit_entity_append(client: AsyncClient, project_url): + """Test appending content to an entity via PATCH endpoint.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "Original content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit entity with append operation + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "append", "content": "Appended content"}, + ) + if response.status_code != 200: + print(f"PATCH failed with status {response.status_code}") + print(f"Response content: {response.text}") + assert response.status_code == 200 + updated = response.json() + + # Verify content was appended by reading the file + response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true") + file_content = response.text + assert "Original content" in file_content + assert "Appended content" in file_content + assert file_content.index("Original content") < file_content.index("Appended content") + + +@pytest.mark.asyncio +async def test_edit_entity_prepend(client: AsyncClient, project_url): + """Test prepending content to an entity via PATCH endpoint.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "Original content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit entity with prepend operation + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "prepend", "content": "Prepended content"}, + ) + if response.status_code != 200: + print(f"PATCH prepend failed with status {response.status_code}") + print(f"Response content: {response.text}") + assert response.status_code == 200 + updated = response.json() + + # Verify the entire file content structure + response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true") + file_content = response.text + + # Expected content with frontmatter preserved and content prepended to body + expected_content = """--- +title: Test Note +type: note +permalink: test/test-note +--- + +Prepended content +Original content""" + + assert file_content.strip() == expected_content.strip() + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace(client: AsyncClient, project_url): + """Test find and replace operation via PATCH endpoint.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "This is old content that needs updating", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit entity with find_replace operation + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "find_replace", "content": "new content", "find_text": "old content"}, + ) + assert response.status_code == 200 + updated = response.json() + + # Verify content was replaced + response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true") + file_content = response.text + assert "old content" not in file_content + assert "This is new content that needs updating" in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_with_expected_replacements( + client: AsyncClient, project_url +): + """Test find and replace with expected_replacements parameter.""" + # Create test entity with repeated text + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Sample Note", + "folder": "docs", + "entity_type": "note", + "content": "The word banana appears here. Another banana word here.", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit entity with find_replace operation, expecting 2 replacements + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={ + "operation": "find_replace", + "content": "apple", + "find_text": "banana", + "expected_replacements": 2, + }, + ) + assert response.status_code == 200 + updated = response.json() + + # Verify both instances were replaced + response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true") + file_content = response.text + assert "The word apple appears here. Another apple word here." in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section(client: AsyncClient, project_url): + """Test replacing a section via PATCH endpoint.""" + # Create test entity with sections + content = """# Main Title + +## Section 1 +Original section 1 content + +## Section 2 +Original section 2 content""" + + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Sample Note", + "folder": "docs", + "entity_type": "note", + "content": content, + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit entity with replace_section operation + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={ + "operation": "replace_section", + "content": "New section 1 content", + "section": "## Section 1", + }, + ) + assert response.status_code == 200 + updated = response.json() + + # Verify section was replaced + response = await client.get(f"{project_url}/resource/{updated['permalink']}?content=true") + file_content = response.text + assert "New section 1 content" in file_content + assert "Original section 1 content" not in file_content + assert "Original section 2 content" in file_content # Other sections preserved + + +@pytest.mark.asyncio +async def test_edit_entity_not_found(client: AsyncClient, project_url): + """Test editing a non-existent entity returns 400.""" + response = await client.patch( + f"{project_url}/knowledge/entities/non-existent", + json={"operation": "append", "content": "content"}, + ) + assert response.status_code == 400 + assert "Entity not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_edit_entity_invalid_operation(client: AsyncClient, project_url): + """Test editing with invalid operation returns 400.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "Original content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Try invalid operation + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "invalid_operation", "content": "content"}, + ) + assert response.status_code == 422 + assert "invalid_operation" in response.json()["detail"][0]["input"] + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_missing_find_text(client: AsyncClient, project_url): + """Test find_replace without find_text returns 400.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "Original content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Try find_replace without find_text + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "find_replace", "content": "new content"}, + ) + assert response.status_code == 400 + assert "find_text is required" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_missing_section(client: AsyncClient, project_url): + """Test replace_section without section parameter returns 400.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "Original content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Try replace_section without section + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "replace_section", "content": "new content"}, + ) + assert response.status_code == 400 + assert "section is required" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_not_found(client: AsyncClient, project_url): + """Test find_replace when text is not found returns 400.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Test Note", + "folder": "test", + "entity_type": "note", + "content": "This is some content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Try to replace text that doesn't exist + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "find_replace", "content": "new content", "find_text": "nonexistent"}, + ) + assert response.status_code == 400 + assert "Text to replace not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_wrong_expected_count(client: AsyncClient, project_url): + """Test find_replace with wrong expected_replacements count returns 400.""" + # Create test entity with repeated text + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Sample Note", + "folder": "docs", + "entity_type": "note", + "content": "The word banana appears here. Another banana word here.", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Try to replace with wrong expected count + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={ + "operation": "find_replace", + "content": "replacement", + "find_text": "banana", + "expected_replacements": 1, # Wrong - there are actually 2 + }, + ) + assert response.status_code == 400 + assert "Expected 1 occurrences" in response.json()["detail"] + assert "but found 2" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_edit_entity_search_reindex(client: AsyncClient, project_url): + """Test that edited entities are reindexed for search.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "Search Test", + "folder": "test", + "entity_type": "note", + "content": "Original searchable content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Edit the entity + response = await client.patch( + f"{project_url}/knowledge/entities/{entity['permalink']}", + json={"operation": "append", "content": " with unique zebra marker"}, + ) + assert response.status_code == 200 + + # Search should find the new content + search_response = await client.post( + f"{project_url}/search/", + json={"text": "zebra marker", "entity_types": ["entity"]}, + ) + results = search_response.json()["results"] + assert len(results) == 1 + assert results[0]["permalink"] == entity["permalink"] + + +# Move entity endpoint tests + + +@pytest.mark.asyncio +async def test_move_entity_success(client: AsyncClient, project_url): + """Test successfully moving an entity to a new location.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "TestNote", + "folder": "source", + "entity_type": "note", + "content": "Test content", + }, + ) + assert response.status_code == 200 + entity = response.json() + original_permalink = entity["permalink"] + + # Move entity + move_data = { + "identifier": original_permalink, + "destination_path": "target/MovedNote.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + response_model = EntityResponse.model_validate(response.json()) + assert response_model.file_path == "target/MovedNote.md" + + # Verify original entity no longer exists + response = await client.get(f"{project_url}/knowledge/entities/{original_permalink}") + assert response.status_code == 404 + + # Verify entity exists at new location + response = await client.get(f"{project_url}/knowledge/entities/target/moved-note") + assert response.status_code == 200 + moved_entity = response.json() + assert moved_entity["file_path"] == "target/MovedNote.md" + assert moved_entity["permalink"] == "target/moved-note" + + # Verify file content using resource endpoint + response = await client.get(f"{project_url}/resource/target/moved-note?content=true") + assert response.status_code == 200 + file_content = response.text + assert "Test content" in file_content + + +@pytest.mark.asyncio +async def test_move_entity_with_folder_creation(client: AsyncClient, project_url): + """Test moving entity creates necessary folders.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "TestNote", + "folder": "", + "entity_type": "note", + "content": "Test content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Move to deeply nested path + move_data = { + "identifier": entity["permalink"], + "destination_path": "deeply/nested/folder/MovedNote.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + + # Verify entity exists at new location + response = await client.get(f"{project_url}/knowledge/entities/deeply/nested/folder/moved-note") + assert response.status_code == 200 + moved_entity = response.json() + assert moved_entity["file_path"] == "deeply/nested/folder/MovedNote.md" + + +@pytest.mark.asyncio +async def test_move_entity_with_observations_and_relations(client: AsyncClient, project_url): + """Test moving entity preserves observations and relations.""" + # Create test entity with complex content + content = """# Complex Entity + +## Observations +- [note] Important observation #tag1 +- [feature] Key feature #feature +- relation to [[SomeOtherEntity]] +- depends on [[Dependency]] + +Some additional content.""" + + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "ComplexEntity", + "folder": "source", + "entity_type": "note", + "content": content, + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Verify original observations and relations + assert len(entity["observations"]) == 2 + assert len(entity["relations"]) == 2 + + # Move entity + move_data = { + "identifier": entity["permalink"], + "destination_path": "target/MovedComplex.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + + # Verify moved entity preserves data + response = await client.get(f"{project_url}/knowledge/entities/target/moved-complex") + assert response.status_code == 200 + moved_entity = response.json() + + # Check observations preserved + assert len(moved_entity["observations"]) == 2 + obs_categories = {obs["category"] for obs in moved_entity["observations"]} + assert obs_categories == {"note", "feature"} + + # Check relations preserved + assert len(moved_entity["relations"]) == 2 + rel_types = {rel["relation_type"] for rel in moved_entity["relations"]} + assert rel_types == {"relation to", "depends on"} + + # Verify file content preserved + response = await client.get(f"{project_url}/resource/target/moved-complex?content=true") + assert response.status_code == 200 + file_content = response.text + assert "Important observation #tag1" in file_content + assert "[[SomeOtherEntity]]" in file_content + + +@pytest.mark.asyncio +async def test_move_entity_search_reindexing(client: AsyncClient, project_url): + """Test that moved entities are properly reindexed for search.""" + # Create searchable entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "SearchableNote", + "folder": "source", + "entity_type": "note", + "content": "Unique searchable elephant content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Move entity + move_data = { + "identifier": entity["permalink"], + "destination_path": "target/MovedSearchable.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + + # Search should find entity at new location + search_response = await client.post( + f"{project_url}/search/", + json={"text": "elephant", "entity_types": [SearchItemType.ENTITY.value]}, + ) + results = search_response.json()["results"] + assert len(results) == 1 + assert results[0]["permalink"] == "target/moved-searchable" + + +@pytest.mark.asyncio +async def test_move_entity_not_found(client: AsyncClient, project_url): + """Test moving non-existent entity returns 400 error.""" + move_data = { + "identifier": "non-existent-entity", + "destination_path": "target/SomeFile.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 400 + assert "Entity not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_move_entity_invalid_destination_path(client: AsyncClient, project_url): + """Test moving entity with invalid destination path.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "TestNote", + "folder": "", + "entity_type": "note", + "content": "Test content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Test various invalid paths + invalid_paths = [ + "/absolute/path.md", # Absolute path + "../parent/path.md", # Parent directory + "", # Empty string + " ", # Whitespace only + ] + + for invalid_path in invalid_paths: + move_data = { + "identifier": entity["permalink"], + "destination_path": invalid_path, + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 422 # Validation error + + +@pytest.mark.asyncio +async def test_move_entity_destination_exists(client: AsyncClient, project_url): + """Test moving entity to existing destination returns error.""" + # Create source entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "SourceNote", + "folder": "source", + "entity_type": "note", + "content": "Source content", + }, + ) + assert response.status_code == 200 + source_entity = response.json() + + # Create destination entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "DestinationNote", + "folder": "target", + "entity_type": "note", + "content": "Destination content", + }, + ) + assert response.status_code == 200 + + # Try to move source to existing destination + move_data = { + "identifier": source_entity["permalink"], + "destination_path": "target/DestinationNote.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_move_entity_missing_identifier(client: AsyncClient, project_url): + """Test move request with missing identifier.""" + move_data = { + "destination_path": "target/SomeFile.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 422 # Validation error + + +@pytest.mark.asyncio +async def test_move_entity_missing_destination(client: AsyncClient, project_url): + """Test move request with missing destination path.""" + move_data = { + "identifier": "some-entity", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 422 # Validation error + + +@pytest.mark.asyncio +async def test_move_entity_by_file_path(client: AsyncClient, project_url): + """Test moving entity using file path as identifier.""" + # Create test entity + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "TestNote", + "folder": "source", + "entity_type": "note", + "content": "Test content", + }, + ) + assert response.status_code == 200 + entity = response.json() + + # Move using file path as identifier + move_data = { + "identifier": entity["file_path"], + "destination_path": "target/MovedByPath.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + + # Verify entity exists at new location + response = await client.get(f"{project_url}/knowledge/entities/target/moved-by-path") + assert response.status_code == 200 + moved_entity = response.json() + assert moved_entity["file_path"] == "target/MovedByPath.md" + + +@pytest.mark.asyncio +async def test_move_entity_by_title(client: AsyncClient, project_url): + """Test moving entity using title as identifier.""" + # Create test entity with unique title + response = await client.post( + f"{project_url}/knowledge/entities", + json={ + "title": "UniqueTestTitle", + "folder": "source", + "entity_type": "note", + "content": "Test content", + }, + ) + assert response.status_code == 200 + + # Move using title as identifier + move_data = { + "identifier": "UniqueTestTitle", + "destination_path": "target/MovedByTitle.md", + } + response = await client.post(f"{project_url}/knowledge/move", json=move_data) + assert response.status_code == 200 + + # Verify entity exists at new location + response = await client.get(f"{project_url}/knowledge/entities/target/moved-by-title") + assert response.status_code == 200 + moved_entity = response.json() + assert moved_entity["file_path"] == "target/MovedByTitle.md" + assert moved_entity["title"] == "UniqueTestTitle" diff --git a/tests/api/test_project_router.py b/tests/api/test_project_router.py index a1d91719f..37bb8f368 100644 --- a/tests/api/test_project_router.py +++ b/tests/api/test_project_router.py @@ -7,7 +7,7 @@ @pytest.mark.asyncio -async def test_get_project_info_endpoint(test_graph, client, test_config, project_url): +async def test_get_project_info_endpoint(test_graph, client, project_config, project_url): """Test the project-info endpoint returns correctly structured data.""" # Set up some test data in the database @@ -51,7 +51,7 @@ async def test_get_project_info_endpoint(test_graph, client, test_config, projec @pytest.mark.asyncio -async def test_get_project_info_content(test_graph, client, test_config, project_url): +async def test_get_project_info_content(test_graph, client, project_config, project_url): """Test that project-info contains actual data from the test database.""" # Call the endpoint response = await client.get(f"{project_url}/project/info") @@ -77,7 +77,7 @@ async def test_get_project_info_content(test_graph, client, test_config, project @pytest.mark.asyncio -async def test_get_project_info_watch_status(test_graph, client, test_config, project_url): +async def test_get_project_info_watch_status(test_graph, client, project_config, project_url): """Test that project-info correctly handles watch status.""" # Create a mock watch status file mock_watch_status = { @@ -111,10 +111,10 @@ async def test_get_project_info_watch_status(test_graph, client, test_config, pr @pytest.mark.asyncio -async def test_list_projects_endpoint(test_graph, client, test_config, project_url): +async def test_list_projects_endpoint(test_config, test_graph, client, project_config, project_url): """Test the list projects endpoint returns correctly structured data.""" # Call the endpoint - response = await client.get(f"{project_url}/project/projects") + response = await client.get("/projects/projects") # Verify response assert response.status_code == 200 @@ -123,7 +123,6 @@ async def test_list_projects_endpoint(test_graph, client, test_config, project_u # Check that the response contains expected fields assert "projects" in data assert "default_project" in data - assert "current_project" in data # Check that projects is a list assert isinstance(data["projects"], list) @@ -137,14 +136,63 @@ async def test_list_projects_endpoint(test_graph, client, test_config, project_u assert "name" in project assert "path" in project assert "is_default" in project - assert "is_current" in project - - # Current project should be marked - current_project = next((p for p in data["projects"] if p["is_current"]), None) - assert current_project is not None - assert current_project["name"] == data["current_project"] # Default project should be marked default_project = next((p for p in data["projects"] if p["is_default"]), None) assert default_project is not None assert default_project["name"] == data["default_project"] + + +@pytest.mark.asyncio +async def test_remove_project_endpoint(test_config, client, project_service): + """Test the remove project endpoint.""" + # First create a test project to remove + test_project_name = "test-remove-project" + await project_service.add_project(test_project_name, "/tmp/test-remove-project") + + # Verify it exists + project = await project_service.get_project(test_project_name) + assert project is not None + + # Remove the project + response = await client.delete(f"/projects/{test_project_name}") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Check response structure + assert "message" in data + assert "status" in data + assert data["status"] == "success" + assert "old_project" in data + assert data["old_project"]["name"] == test_project_name + + # Verify project is actually removed + removed_project = await project_service.get_project(test_project_name) + assert removed_project is None + + +@pytest.mark.asyncio +async def test_set_default_project_endpoint(test_config, client, project_service): + """Test the set default project endpoint.""" + # Create a test project to set as default + test_project_name = "test-default-project" + await project_service.add_project(test_project_name, "/tmp/test-default-project") + + # Set it as default + response = await client.put(f"/projects/{test_project_name}/default") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Check response structure + assert "message" in data + assert "status" in data + assert data["status"] == "success" + assert "new_project" in data + assert data["new_project"]["name"] == test_project_name + + # Verify it's actually set as default + assert project_service.default_project == test_project_name diff --git a/tests/api/test_project_router_operations.py b/tests/api/test_project_router_operations.py index b26f9c59a..01dc67025 100644 --- a/tests/api/test_project_router_operations.py +++ b/tests/api/test_project_router_operations.py @@ -30,7 +30,7 @@ async def test_get_project_info_additional(client, test_graph, project_url): async def test_project_list_additional(client, project_url): """Test additional fields in the project list endpoint.""" # Call the endpoint - response = await client.get(f"{project_url}/project/projects") + response = await client.get("/projects/projects") # Verify response assert response.status_code == 200 diff --git a/tests/api/test_resource_router.py b/tests/api/test_resource_router.py index 2fbc7a74d..893286538 100644 --- a/tests/api/test_resource_router.py +++ b/tests/api/test_resource_router.py @@ -10,11 +10,11 @@ @pytest.mark.asyncio -async def test_get_resource_content(client, test_config, entity_repository, project_url): +async def test_get_resource_content(client, project_config, entity_repository, project_url): """Test getting content by permalink.""" # Create a test file content = "# Test Content\n\nThis is a test file." - test_file = Path(test_config.home) / "test" / "test.md" + test_file = Path(project_config.home) / "test" / "test.md" test_file.parent.mkdir(parents=True, exist_ok=True) test_file.write_text(content) @@ -39,11 +39,11 @@ async def test_get_resource_content(client, test_config, entity_repository, proj @pytest.mark.asyncio -async def test_get_resource_pagination(client, test_config, entity_repository, project_url): +async def test_get_resource_pagination(client, project_config, entity_repository, project_url): """Test getting content by permalink with pagination.""" # Create a test file content = "# Test Content\n\nThis is a test file." - test_file = Path(test_config.home) / "test" / "test.md" + test_file = Path(project_config.home) / "test" / "test.md" test_file.parent.mkdir(parents=True, exist_ok=True) test_file.write_text(content) @@ -70,11 +70,11 @@ async def test_get_resource_pagination(client, test_config, entity_repository, p @pytest.mark.asyncio -async def test_get_resource_by_title(client, test_config, entity_repository, project_url): +async def test_get_resource_by_title(client, project_config, entity_repository, project_url): """Test getting content by permalink.""" # Create a test file content = "# Test Content\n\nThis is a test file." - test_file = Path(test_config.home) / "test" / "test.md" + test_file = Path(project_config.home) / "test" / "test.md" test_file.parent.mkdir(parents=True, exist_ok=True) test_file.write_text(content) @@ -105,7 +105,7 @@ async def test_get_resource_missing_entity(client, project_url): @pytest.mark.asyncio -async def test_get_resource_missing_file(client, test_config, entity_repository, project_url): +async def test_get_resource_missing_file(client, project_config, entity_repository, project_url): """Test 404 when file doesn't exist.""" # Create entity referencing non-existent file entity = await entity_repository.create( @@ -126,7 +126,7 @@ async def test_get_resource_missing_file(client, test_config, entity_repository, @pytest.mark.asyncio -async def test_get_resource_observation(client, test_config, entity_repository, project_url): +async def test_get_resource_observation(client, project_config, entity_repository, project_url): """Test getting content by observation permalink.""" # Create entity content = "# Test Content\n\n- [note] an observation." @@ -164,7 +164,7 @@ async def test_get_resource_observation(client, test_config, entity_repository, @pytest.mark.asyncio -async def test_get_resource_entities(client, test_config, entity_repository, project_url): +async def test_get_resource_entities(client, project_config, entity_repository, project_url): """Test getting content by permalink match.""" # Create entity content1 = "# Test Content\n" @@ -213,7 +213,7 @@ async def test_get_resource_entities(client, test_config, entity_repository, pro @pytest.mark.asyncio async def test_get_resource_entities_pagination( - client, test_config, entity_repository, project_url + client, project_config, entity_repository, project_url ): """Test getting content by permalink match.""" # Create entity @@ -264,7 +264,7 @@ async def test_get_resource_entities_pagination( @pytest.mark.asyncio -async def test_get_resource_relation(client, test_config, entity_repository, project_url): +async def test_get_resource_relation(client, project_config, entity_repository, project_url): """Test getting content by relation permalink.""" # Create entity content1 = "# Test Content\n" @@ -314,7 +314,7 @@ async def test_get_resource_relation(client, test_config, entity_repository, pro @pytest.mark.asyncio async def test_put_resource_new_file( - client, test_config, entity_repository, search_repository, project_url + client, project_config, entity_repository, search_repository, project_url ): """Test creating a new file via PUT.""" # Test data @@ -335,7 +335,7 @@ async def test_put_resource_new_file( } # Make sure the file doesn't exist yet - full_path = Path(test_config.home) / file_path + full_path = Path(project_config.home) / file_path if full_path.exists(): full_path.unlink() @@ -352,7 +352,7 @@ async def test_put_resource_new_file( assert "size" in response_data # Verify file was created - full_path = Path(test_config.home) / file_path + full_path = Path(project_config.home) / file_path assert full_path.exists() # Verify file content @@ -371,11 +371,11 @@ async def test_put_resource_new_file( @pytest.mark.asyncio -async def test_put_resource_update_existing(client, test_config, entity_repository, project_url): +async def test_put_resource_update_existing(client, project_config, entity_repository, project_url): """Test updating an existing file via PUT.""" # Create an initial file and entity file_path = "visualizations/update-test.canvas" - full_path = Path(test_config.home) / file_path + full_path = Path(project_config.home) / file_path full_path.parent.mkdir(parents=True, exist_ok=True) initial_data = { diff --git a/tests/cli/.coverage.Pauls-MacBook-Pro-2.local.63666.XDIUQNrx b/tests/cli/.coverage.Pauls-MacBook-Pro-2.local.63666.XDIUQNrx new file mode 100644 index 000000000..7b304ac04 Binary files /dev/null and b/tests/cli/.coverage.Pauls-MacBook-Pro-2.local.63666.XDIUQNrx differ diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9fcb67ec5..fbe8ec989 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -6,14 +6,15 @@ from httpx import AsyncClient, ASGITransport 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 -@pytest_asyncio.fixture -def app(test_config, engine_factory) -> FastAPI: +@pytest_asyncio.fixture(autouse=True) +async def app(app_config, project_config, engine_factory, test_config) -> FastAPI: """Create test FastAPI application.""" app = fastapi_app - app.dependency_overrides[get_project_config] = lambda: test_config + 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 @@ -26,5 +27,6 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: @pytest.fixture -def cli_env(test_config, client): - pass +def cli_env(project_config, client, test_config): + """Set up CLI environment with correct project session.""" + return {"project_config": project_config, "client": client} \ No newline at end of file diff --git a/tests/cli/test_auth_commands.py b/tests/cli/test_auth_commands.py index 60c9233cf..2a9f25b4c 100644 --- a/tests/cli/test_auth_commands.py +++ b/tests/cli/test_auth_commands.py @@ -218,7 +218,7 @@ async def register_client_side_effect(client_info): mock_provider_class.assert_called_once_with(issuer_url="https://custom-issuer.com") # Should exit early due to client not found - assert "Error: Client not found after registration" in result.stdout + assert "Error: Client not found after registration" in result.stderr def test_test_auth_client_not_found(self, runner, mock_provider): """Test OAuth test flow when client is not found after registration.""" @@ -236,7 +236,7 @@ async def register_client_side_effect(client_info): result = runner.invoke(auth_app, ["test-auth"]) assert result.exit_code == 0 # Command completes but with error message - assert "Error: Client not found after registration" in result.stdout + assert "Error: Client not found after registration" in result.stderr def test_test_auth_no_auth_code_in_url(self, runner, mock_provider): """Test OAuth test flow when no auth code in URL.""" @@ -265,7 +265,7 @@ async def register_client_side_effect(client_info): result = runner.invoke(auth_app, ["test-auth"]) assert result.exit_code == 0 - assert "Error: No authorization code in URL" in result.stdout + assert "Error: No authorization code in URL" in result.stderr def test_test_auth_invalid_auth_code(self, runner, mock_provider): """Test OAuth test flow when authorization code is invalid.""" @@ -295,7 +295,7 @@ async def register_client_side_effect(client_info): result = runner.invoke(auth_app, ["test-auth"]) assert result.exit_code == 0 - assert "Error: Invalid authorization code" in result.stdout + assert "Error: Invalid authorization code" in result.stderr def test_test_auth_invalid_access_token(self, runner, mock_provider): """Test OAuth test flow when access token validation fails.""" @@ -336,7 +336,7 @@ async def register_client_side_effect(client_info): assert result.exit_code == 0 assert "Access token: test-access-token" in result.stdout - assert "Error: Invalid access token" in result.stdout + assert "Error: Invalid access token" in result.stderr def test_test_auth_exception_handling(self, runner, mock_provider): """Test OAuth test flow exception handling.""" diff --git a/tests/cli/test_cli_tools.py b/tests/cli/test_cli_tools.py index ee73e5a96..e00d73351 100644 --- a/tests/cli/test_cli_tools.py +++ b/tests/cli/test_cli_tools.py @@ -56,7 +56,7 @@ async def setup_test_note(entity_service, search_service) -> AsyncGenerator[dict } -def test_write_note(cli_env, test_config): +def test_write_note(cli_env, project_config): """Test write_note command with basic arguments.""" result = runner.invoke( tool_app, @@ -78,7 +78,7 @@ def test_write_note(cli_env, test_config): assert "permalink" in result.stdout -def test_write_note_with_tags(cli_env, test_config): +def test_write_note_with_tags(cli_env, project_config): """Test write_note command with tags.""" result = runner.invoke( tool_app, @@ -103,7 +103,7 @@ def test_write_note_with_tags(cli_env, test_config): assert "tag1, tag2" in result.stdout or "tag1" in result.stdout and "tag2" in result.stdout -def test_write_note_from_stdin(cli_env, test_config, monkeypatch): +def test_write_note_from_stdin(cli_env, project_config, monkeypatch): """Test write_note command reading from stdin. This test requires minimal mocking of stdin to simulate piped input. @@ -135,7 +135,7 @@ def test_write_note_from_stdin(cli_env, test_config, monkeypatch): assert "permalink" in result.stdout -def test_write_note_content_param_priority(cli_env, test_config): +def test_write_note_content_param_priority(cli_env, project_config): """Test that content parameter has priority over stdin.""" stdin_content = "This content from stdin should NOT be used" param_content = "This explicit content parameter should be used" @@ -167,7 +167,7 @@ def test_write_note_content_param_priority(cli_env, test_config): assert "Created" in result.stdout or "Updated" in result.stdout -def test_write_note_no_content(cli_env, test_config): +def test_write_note_no_content(cli_env, project_config): """Test error handling when no content is provided.""" # Mock stdin to appear as a terminal, not a pipe with patch("sys.stdin.isatty", return_value=True): @@ -417,19 +417,19 @@ def test_continue_conversation_no_results(cli_env): @patch("basic_memory.services.initialization.initialize_database") -def test_ensure_migrations_functionality(mock_initialize_database, test_config, monkeypatch): +def test_ensure_migrations_functionality(mock_initialize_database, project_config, monkeypatch): """Test the database initialization functionality.""" from basic_memory.services.initialization import ensure_initialization # Call the function - ensure_initialization(test_config) + ensure_initialization(project_config) # The underlying asyncio.run should call our mocked function mock_initialize_database.assert_called_once() @patch("basic_memory.services.initialization.initialize_database") -def test_ensure_migrations_handles_errors(mock_initialize_database, test_config, monkeypatch): +def test_ensure_migrations_handles_errors(mock_initialize_database, project_config, monkeypatch): """Test that initialization handles errors gracefully.""" from basic_memory.services.initialization import ensure_initialization @@ -437,6 +437,6 @@ def test_ensure_migrations_handles_errors(mock_initialize_database, test_config, mock_initialize_database.side_effect = Exception("Test error") # Call the function - should not raise exception - ensure_initialization(test_config) + ensure_initialization(project_config) - # We're just making sure it doesn't crash by calling it + # We're just making sure it doesn't crash by calling it \ No newline at end of file diff --git a/tests/cli/test_project_commands.py b/tests/cli/test_project_commands.py index 4bf97def3..f64cf120a 100644 --- a/tests/cli/test_project_commands.py +++ b/tests/cli/test_project_commands.py @@ -14,9 +14,7 @@ def test_project_list_command(mock_run, cli_env): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "projects": [ - {"name": "test", "path": "/path/to/test", "is_default": True, "is_current": True} - ], + "projects": [{"name": "test", "path": "/path/to/test", "is_default": True}], "default_project": "test", "current_project": "test", } @@ -29,28 +27,6 @@ def test_project_list_command(mock_run, cli_env): assert result.exit_code == 0 -@patch("basic_memory.cli.commands.project.asyncio.run") -def test_project_current_command(mock_run, cli_env): - """Test the 'project current' command with mocked API.""" - # Mock the API response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "projects": [ - {"name": "test", "path": "/path/to/test", "is_default": True, "is_current": True} - ], - "default_project": "test", - "current_project": "test", - } - mock_run.return_value = mock_response - - runner = CliRunner() - result = runner.invoke(cli_app, ["project", "current"]) - - # Just verify it runs without exception - assert result.exit_code == 0 - - @patch("basic_memory.cli.commands.project.asyncio.run") def test_project_add_command(mock_run, cli_env): """Test the 'project add' command with mocked API.""" @@ -153,7 +129,6 @@ def test_project_failure_exits_with_error(mock_run, cli_env): list_result = runner.invoke(cli_app, ["project", "list"]) add_result = runner.invoke(cli_app, ["project", "add", "test-project", "/path/to/project"]) remove_result = runner.invoke(cli_app, ["project", "remove", "test-project"]) - current_result = runner.invoke(cli_app, ["project", "current"]) default_result = runner.invoke(cli_app, ["project", "default", "test-project"]) # All should exit with code 1 and show error message @@ -167,8 +142,5 @@ def test_project_failure_exits_with_error(mock_run, cli_env): assert remove_result.exit_code == 1 assert "Error removing project" in remove_result.output - assert current_result.exit_code == 1 - assert "Error getting current project" in current_result.output - assert default_result.exit_code == 1 assert "Error setting default project" in default_result.output diff --git a/tests/cli/test_project_info.py b/tests/cli/test_project_info.py index 9a75294c1..5a94fd529 100644 --- a/tests/cli/test_project_info.py +++ b/tests/cli/test_project_info.py @@ -8,13 +8,14 @@ from basic_memory.config import config -def test_info_stats_command(cli_env, test_graph): - """Test the 'info stats' command with default output.""" +def test_info_stats_command(cli_env, test_graph, project_session): + """Test the 'project info' command with default output.""" runner = CliRunner() # Run the command result = runner.invoke(cli_app, ["project", "info"]) + # Verify exit code assert result.exit_code == 0 @@ -22,11 +23,9 @@ def test_info_stats_command(cli_env, test_graph): assert "Basic Memory Project Info" in result.stdout -def test_info_stats_json(cli_env, test_graph, app_config, test_project): - """Test the 'info stats --json' command for JSON output.""" +def test_info_stats_json(cli_env, test_graph, project_session): + """Test the 'project info --json' command for JSON output.""" runner = CliRunner() - config.name = test_project.name - config.home = test_project.path # Run the command with --json flag result = runner.invoke(cli_app, ["project", "info", "--json"]) @@ -38,4 +37,4 @@ def test_info_stats_json(cli_env, test_graph, app_config, test_project): output = json.loads(result.stdout) # Verify JSON structure matches our sample data - assert output["project_name"] == test_project.name + assert output["default_project"] == "test-project" diff --git a/tests/cli/test_status.py b/tests/cli/test_status.py index ef3a5afa2..f8dd41578 100644 --- a/tests/cli/test_status.py +++ b/tests/cli/test_status.py @@ -17,7 +17,7 @@ runner = CliRunner() -def test_status_command(tmp_path, app_config, test_config, test_project): +def test_status_command(tmp_path, app_config, project_config, test_project): """Test CLI status command.""" config.home = tmp_path config.name = test_project.name diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py index e536e47fc..7862ac8e0 100644 --- a/tests/cli/test_sync.py +++ b/tests/cli/test_sync.py @@ -71,14 +71,14 @@ def test_display_detailed_sync_results_with_changes(): @pytest.mark.asyncio -async def test_run_sync_basic(sync_service, test_config, test_project): +async def test_run_sync_basic(sync_service, project_config, test_project): """Test basic sync operation.""" # Set up test environment - config.home = test_config.home + config.home = project_config.home config.name = test_project.name # Create test files - test_file = test_config.home / "test.md" + test_file = project_config.home / "test.md" test_file.write_text("""--- title: Test --- @@ -89,9 +89,9 @@ async def test_run_sync_basic(sync_service, test_config, test_project): await run_sync(verbose=True) -def test_sync_command(sync_service, test_config, test_project): +def test_sync_command(sync_service, project_config, test_project): """Test the sync command.""" - config.home = test_config.home + config.home = project_config.home config.name = test_project.name result = runner.invoke(app, ["sync", "--verbose"]) diff --git a/tests/conftest.py b/tests/conftest.py index 4f96b6f2d..cfe819fa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,21 @@ """Common test fixtures.""" - +import os +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from textwrap import dedent from typing import AsyncGenerator +from unittest import mock +from unittest.mock import patch import pytest import pytest_asyncio from loguru import logger from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +import basic_memory.mcp.project_session from basic_memory import db -from basic_memory.config import ProjectConfig, BasicMemoryConfig +from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager from basic_memory.db import DatabaseType from basic_memory.markdown import EntityParser from basic_memory.markdown.markdown_processor import MarkdownProcessor @@ -34,7 +38,6 @@ from basic_memory.services.search_service import SearchService from basic_memory.sync.sync_service import SyncService from basic_memory.sync.watch_service import WatchService -from basic_memory.config import app_config as basic_memory_app_config # noqa: F401 @pytest.fixture @@ -46,27 +49,103 @@ def anyio_backend(): def project_root() -> Path: return Path(__file__).parent.parent - @pytest.fixture -def app_config(test_config: ProjectConfig, monkeypatch) -> BasicMemoryConfig: - projects = {test_config.name: str(test_config.home)} - app_config = BasicMemoryConfig(env="test", projects=projects, default_project=test_config.name) +def config_home(tmp_path, monkeypatch) -> Path: + # Patch HOME environment variable for the duration of the test + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path + +@pytest.fixture(scope="function", autouse=True) +def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig: + """Create test app configuration.""" + # Create a basic config without depending on test_project to avoid circular dependency + projects = {"test-project": str(config_home)} + app_config = BasicMemoryConfig(env="test", projects=projects, default_project="test-project", update_permalinks_on_move=True) + + + # Patch the module app_config instance for the duration of the test + monkeypatch.setattr("basic_memory.config.app_config", app_config) + return app_config - # set the module app_config instance project list - basic_memory_app_config.projects = projects - basic_memory_app_config.default_project = test_config.name +@pytest.fixture(autouse=True) +def config_manager(app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch) -> ConfigManager: + # Create a new ConfigManager that uses the test home directory + config_manager = ConfigManager() + # Update its paths to use the test directory + config_manager.config_dir = config_home / ".basic-memory" + config_manager.config_file = config_manager.config_dir / "config.json" + config_manager.config_dir.mkdir(parents=True, exist_ok=True) + + # Override the config directly instead of relying on disk load + config_manager.config = app_config + + # Ensure the config file is written to disk + config_manager.save_config(app_config) + + # Patch the config_manager in all locations where it's imported + monkeypatch.setattr("basic_memory.config.config_manager", config_manager) + monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager) + # Mock get_project_config to return test project config for test-project, fallback for others + def mock_get_project_config(project_name=None): + if project_name == "test-project" or project_name is None: + return project_config + # For any other project name, return a default config pointing to test location + fallback_config = ProjectConfig(name=project_name or "main", home=Path(config_home)) + return fallback_config + monkeypatch.setattr("basic_memory.mcp.project_session.get_project_config", mock_get_project_config) + + # Patch the project config that CLI commands import (only modules that actually import config) + monkeypatch.setattr("basic_memory.cli.commands.project.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.sync.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.status.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.import_memory_json.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.import_claude_projects.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.import_claude_conversations.config", project_config) + monkeypatch.setattr("basic_memory.cli.commands.import_chatgpt.config", project_config) + return config_manager + +@pytest.fixture(autouse=True) +def project_session(test_project: Project): + # initialize the project session with the test project + basic_memory.mcp.project_session.session.initialize(test_project.name) + # Explicitly set current project as well to ensure it's used + basic_memory.mcp.project_session.session.set_current_project(test_project.name) + return basic_memory.mcp.project_session.session + + +@pytest.fixture(scope="function", autouse=True) +def project_config(test_project, monkeypatch): + """Create test project configuration.""" + + project_config = ProjectConfig( + name=test_project.name, + home=Path(test_project.path), + ) - return app_config + # Patch the config module project config for the duration of the test + monkeypatch.setattr("basic_memory.config.config", project_config) + return project_config -@pytest.fixture -def test_config(tmp_path) -> ProjectConfig: - """Test configuration using in-memory DB.""" - config = ProjectConfig(name="test-project", home=tmp_path) +@dataclass +class TestConfig: + config_home: Path + project_config: ProjectConfig + app_config: BasicMemoryConfig + config_manager: ConfigManager - (tmp_path / config.home.name).mkdir(parents=True, exist_ok=True) - logger.info(f"project config home: {config.home}") - return config +@pytest.fixture +def test_config(config_home, project_config, app_config, config_manager) -> TestConfig: + """All test configuration fixtures""" + + @dataclass + class TestConfig: + config_home: Path + project_config: ProjectConfig + app_config: BasicMemoryConfig + config_manager: ConfigManager + + return TestConfig(config_home, project_config, app_config, config_manager) @pytest_asyncio.fixture(scope="function") @@ -127,17 +206,18 @@ async def project_repository( @pytest_asyncio.fixture(scope="function") -async def test_project(test_config, project_repository: ProjectRepository) -> Project: +async def test_project(config_home, engine_factory) -> Project: """Create a test project to be used as context for other repositories.""" project_data = { - "name": test_config.name, + "name": "test-project", "description": "Project used as context for tests", - "path": str(test_config.home), + "path": str(config_home), "is_active": True, "is_default": True, # Explicitly set as the default project } + engine, session_maker = engine_factory + project_repository = ProjectRepository(session_maker) project = await project_repository.create(project_data) - logger.info(f"Created test project with permalink: {project.permalink}") return project @@ -165,9 +245,11 @@ async def entity_service( @pytest.fixture -def file_service(test_config: ProjectConfig, markdown_processor: MarkdownProcessor) -> FileService: +def file_service( + project_config: ProjectConfig, markdown_processor: MarkdownProcessor +) -> FileService: """Create FileService instance.""" - return FileService(test_config.home, markdown_processor) + return FileService(project_config.home, markdown_processor) @pytest.fixture @@ -183,9 +265,9 @@ def link_resolver(entity_repository: EntityRepository, search_service: SearchSer @pytest.fixture -def entity_parser(test_config): +def entity_parser(project_config): """Create parser instance.""" - return EntityParser(test_config.home) + return EntityParser(project_config.home) @pytest_asyncio.fixture @@ -211,7 +293,7 @@ async def sync_service( @pytest_asyncio.fixture -async def directory_service(entity_repository, test_config) -> DirectoryService: +async def directory_service(entity_repository, project_config) -> DirectoryService: """Create directory service for testing.""" return DirectoryService( entity_repository=entity_repository, @@ -275,7 +357,6 @@ async def full_entity(sample_entity, entity_repository, file_service, entity_ser title="Search_Entity", folder="test", entity_type="test", - project=entity_repository.project_id, content=dedent(""" ## Observations - [tech] Tech note @@ -307,7 +388,6 @@ async def test_graph( title="Deeper Entity", entity_type="deeper", folder="test", - project=entity_repository.project_id, content=dedent(""" # Deeper Entity """), @@ -319,7 +399,6 @@ async def test_graph( title="Deep Entity", entity_type="deep", folder="test", - project=entity_repository.project_id, content=dedent(""" # Deep Entity - deeper_connection [[Deeper Entity]] @@ -332,7 +411,6 @@ async def test_graph( title="Connected Entity 2", entity_type="test", folder="test", - project=entity_repository.project_id, content=dedent(""" # Connected Entity 2 - deep_connection [[Deep Entity]] @@ -345,7 +423,6 @@ async def test_graph( title="Connected Entity 1", entity_type="test", folder="test", - project=entity_repository.project_id, content=dedent(""" # Connected Entity 1 - [note] Connected 1 note @@ -359,7 +436,6 @@ async def test_graph( title="Root", entity_type="test", folder="test", - project=entity_repository.project_id, content=dedent(""" # Root Entity - [note] Root note 1 @@ -393,7 +469,7 @@ def watch_service(app_config: BasicMemoryConfig, project_repository) -> WatchSer @pytest.fixture -def test_files(test_config, project_root) -> dict[str, Path]: +def test_files(project_config, project_root) -> dict[str, Path]: """Copy test files into the project directory. Returns a dict mapping file names to their paths in the project dir. @@ -411,7 +487,7 @@ def test_files(test_config, project_root) -> dict[str, Path]: content = src_path.read_bytes() # Create destination path and ensure parent dirs exist - dest_path = test_config.home / src_path.name + dest_path = project_config.home / src_path.name dest_path.parent.mkdir(parents=True, exist_ok=True) # Write file @@ -422,7 +498,7 @@ def test_files(test_config, project_root) -> dict[str, Path]: @pytest_asyncio.fixture -async def synced_files(sync_service, test_config, test_files): +async def synced_files(sync_service, project_config, test_files): # Initial sync - should create forward reference - await sync_service.sync(test_config.home) - return test_files + await sync_service.sync(project_config.home) + return test_files \ No newline at end of file diff --git a/tests/edit_file_test.py b/tests/edit_file_test.py deleted file mode 100644 index 2c7293c98..000000000 --- a/tests/edit_file_test.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Test file for experimenting with edit_file.""" - - -def function_one(): - """First test function.""" - print("Hello from function one") - - # Some code here - x = 1 + 2 - return x - - -def function_two(): - """Second test function.""" - print("Hello from function two") - - # Some more code - y = 3 * 4 - return y diff --git a/tests/markdown/test_entity_parser.py b/tests/markdown/test_entity_parser.py index 4feff7628..c252025c0 100644 --- a/tests/markdown/test_entity_parser.py +++ b/tests/markdown/test_entity_parser.py @@ -41,9 +41,9 @@ def valid_entity_content(): @pytest.mark.asyncio -async def test_parse_complete_file(test_config, entity_parser, valid_entity_content): +async def test_parse_complete_file(project_config, entity_parser, valid_entity_content): """Test parsing a complete entity file with all features.""" - test_file = test_config.home / "test_entity.md" + test_file = project_config.home / "test_entity.md" test_file.write_text(valid_entity_content) entity = await entity_parser.parse_file(test_file) @@ -95,7 +95,7 @@ async def test_parse_complete_file(test_config, entity_parser, valid_entity_cont @pytest.mark.asyncio -async def test_parse_minimal_file(test_config, entity_parser): +async def test_parse_minimal_file(project_config, entity_parser): """Test parsing a minimal valid entity file.""" content = dedent(""" --- @@ -112,7 +112,7 @@ async def test_parse_minimal_file(test_config, entity_parser): - references [[Other Entity]] """) - test_file = test_config.home / "minimal.md" + test_file = project_config.home / "minimal.md" test_file.write_text(content) entity = await entity_parser.parse_file(test_file) @@ -127,7 +127,7 @@ async def test_parse_minimal_file(test_config, entity_parser): @pytest.mark.asyncio -async def test_error_handling(test_config, entity_parser): +async def test_error_handling(project_config, entity_parser): """Test error handling.""" # Missing file @@ -135,7 +135,7 @@ async def test_error_handling(test_config, entity_parser): await entity_parser.parse_file(Path("nonexistent.md")) # Invalid file encoding - test_file = test_config.home / "binary.md" + test_file = project_config.home / "binary.md" with open(test_file, "wb") as f: f.write(b"\x80\x81") # Invalid UTF-8 with pytest.raises(UnicodeDecodeError): @@ -143,7 +143,7 @@ async def test_error_handling(test_config, entity_parser): @pytest.mark.asyncio -async def test_parse_file_without_section_headers(test_config, entity_parser): +async def test_parse_file_without_section_headers(project_config, entity_parser): """Test parsing a minimal valid entity file.""" content = dedent(""" --- @@ -163,7 +163,7 @@ async def test_parse_file_without_section_headers(test_config, entity_parser): - references [[Other Entity]] """) - test_file = test_config.home / "minimal.md" + test_file = project_config.home / "minimal.md" test_file.write_text(content) entity = await entity_parser.parse_file(test_file) @@ -218,7 +218,7 @@ def test_parse_empty_content(): @pytest.mark.asyncio -async def test_parse_file_with_absolute_path(test_config, entity_parser): +async def test_parse_file_with_absolute_path(project_config, entity_parser): """Test parsing a file with an absolute path.""" content = dedent(""" --- @@ -232,7 +232,7 @@ async def test_parse_file_with_absolute_path(test_config, entity_parser): """) # Create a test file in the project directory - test_file = test_config.home / "absolute_path_test.md" + test_file = project_config.home / "absolute_path_test.md" test_file.write_text(content) # Get the absolute path to the test file diff --git a/tests/mcp/.coverage.Pauls-MacBook-Pro-2.local.63904.XiAZuuhx b/tests/mcp/.coverage.Pauls-MacBook-Pro-2.local.63904.XiAZuuhx new file mode 100644 index 000000000..7b304ac04 Binary files /dev/null and b/tests/mcp/.coverage.Pauls-MacBook-Pro-2.local.63904.XiAZuuhx differ diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index c31a50c42..bedf9be20 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -13,22 +13,24 @@ from basic_memory.services.search_service import SearchService from basic_memory.mcp.server import mcp as mcp_server +from basic_memory.config import app_config as basic_memory_app_config # noqa: F401 -@pytest.fixture + +@pytest.fixture(scope="function") def mcp() -> FastMCP: return mcp_server -@pytest.fixture -def app(app_config, test_config, engine_factory, monkeypatch) -> FastAPI: +@pytest.fixture(scope="function") +def app(app_config, project_config, engine_factory, project_session, config_manager) -> FastAPI: """Create test FastAPI application.""" app = fastapi_app - app.dependency_overrides[get_project_config] = lambda: test_config + app.dependency_overrides[get_project_config] = lambda: project_config app.dependency_overrides[get_engine_factory] = lambda: engine_factory return app -@pytest_asyncio.fixture +@pytest_asyncio.fixture(scope="function") async def client(app: FastAPI) -> 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: diff --git a/tests/mcp/test_tool_project_info.py b/tests/mcp/test_resource_project_info.py similarity index 94% rename from tests/mcp/test_tool_project_info.py rename to tests/mcp/test_resource_project_info.py index 171303860..34178ad07 100644 --- a/tests/mcp/test_tool_project_info.py +++ b/tests/mcp/test_resource_project_info.py @@ -5,7 +5,7 @@ import pytest from httpx import Response -from basic_memory.mcp.tools.project_info import project_info +from basic_memory.mcp.resources.project_info import project_info from basic_memory.schemas import ( ProjectInfoResponse, ) @@ -94,7 +94,7 @@ async def test_project_info_tool(): # Mock the call_get function with patch( - "basic_memory.mcp.tools.project_info.call_get", return_value=mock_response + "basic_memory.mcp.resources.project_info.call_get", return_value=mock_response ) as mock_call_get: # Call the function result = await project_info() @@ -133,7 +133,9 @@ async def test_project_info_tool(): async def test_project_info_error_handling(): """Test that the project_info tool handles errors gracefully.""" # Mock call_get to raise an exception - with patch("basic_memory.mcp.tools.project_info.call_get", side_effect=Exception("Test error")): + with patch( + "basic_memory.mcp.resources.project_info.call_get", side_effect=Exception("Test error") + ): # Verify that the exception propagates with pytest.raises(Exception) as excinfo: await project_info() diff --git a/tests/mcp/test_tool_canvas.py b/tests/mcp/test_tool_canvas.py index c818bb7b9..0206068a2 100644 --- a/tests/mcp/test_tool_canvas.py +++ b/tests/mcp/test_tool_canvas.py @@ -9,7 +9,7 @@ @pytest.mark.asyncio -async def test_create_canvas(app, test_config): +async def test_create_canvas(app, project_config): """Test creating a new canvas file. Should: @@ -42,7 +42,7 @@ async def test_create_canvas(app, test_config): assert "The canvas is ready to open in Obsidian" in result # Verify file was created - file_path = Path(test_config.home) / folder / f"{title}.canvas" + file_path = Path(project_config.home) / folder / f"{title}.canvas" assert file_path.exists() # Verify content is correct @@ -52,7 +52,7 @@ async def test_create_canvas(app, test_config): @pytest.mark.asyncio -async def test_create_canvas_with_extension(app, test_config): +async def test_create_canvas_with_extension(app, project_config): """Test creating a canvas file with .canvas extension already in the title.""" # Test data nodes = [ @@ -77,7 +77,7 @@ async def test_create_canvas_with_extension(app, test_config): assert "Created: visualizations/extension-test.canvas" in result # Verify file exists with correct name (shouldn't have double extension) - file_path = Path(test_config.home) / folder / title + file_path = Path(project_config.home) / folder / title assert file_path.exists() # Verify content @@ -86,7 +86,7 @@ async def test_create_canvas_with_extension(app, test_config): @pytest.mark.asyncio -async def test_update_existing_canvas(app, test_config): +async def test_update_existing_canvas(app, project_config): """Test updating an existing canvas file.""" # First create a canvas nodes = [ @@ -108,7 +108,7 @@ async def test_update_existing_canvas(app, test_config): await canvas(nodes=nodes, edges=edges, title=title, folder=folder) # Verify file exists - file_path = Path(test_config.home) / folder / f"{title}.canvas" + file_path = Path(project_config.home) / folder / f"{title}.canvas" assert file_path.exists() # Now update with new content @@ -140,7 +140,7 @@ async def test_update_existing_canvas(app, test_config): @pytest.mark.asyncio -async def test_create_canvas_with_nested_folders(app, test_config): +async def test_create_canvas_with_nested_folders(app, project_config): """Test creating a canvas in nested folders that don't exist yet.""" # Test data nodes = [ @@ -165,13 +165,13 @@ async def test_create_canvas_with_nested_folders(app, test_config): assert "Created: visualizations/nested/folders/nested-test.canvas" in result # Verify folders and file were created - file_path = Path(test_config.home) / folder / f"{title}.canvas" + file_path = Path(project_config.home) / folder / f"{title}.canvas" assert file_path.exists() assert file_path.parent.exists() @pytest.mark.asyncio -async def test_create_canvas_complex_content(app, test_config): +async def test_create_canvas_complex_content(app, project_config): """Test creating a canvas with complex content structures.""" # Test data - more complex structure with all node types nodes = [ @@ -237,7 +237,7 @@ async def test_create_canvas_complex_content(app, test_config): folder = "visualizations" # Create a test file that we're referencing - test_file_path = Path(test_config.home) / "test/test-file.md" + test_file_path = Path(project_config.home) / "test/test-file.md" test_file_path.parent.mkdir(parents=True, exist_ok=True) test_file_path.write_text("# Test File\nThis is referenced by the canvas") @@ -248,7 +248,7 @@ async def test_create_canvas_complex_content(app, test_config): assert "Created: visualizations/complex-test.canvas" in result # Verify file was created - file_path = Path(test_config.home) / folder / f"{title}.canvas" + file_path = Path(project_config.home) / folder / f"{title}.canvas" assert file_path.exists() # Verify content is correct with all complex structures diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py new file mode 100644 index 000000000..c68b2a64d --- /dev/null +++ b/tests/mcp/test_tool_edit_note.py @@ -0,0 +1,359 @@ +"""Tests for the edit_note MCP tool.""" + +import pytest + +from basic_memory.mcp.tools.edit_note import edit_note +from basic_memory.mcp.tools.write_note import write_note + + +@pytest.mark.asyncio +async def test_edit_note_append_operation(client): + """Test appending content to an existing note.""" + # Create initial note + await write_note( + title="Test Note", + folder="test", + content="# Test Note\nOriginal content here.", + ) + + # Append content + result = await edit_note( + identifier="test/test-note", + operation="append", + content="\n## New Section\nAppended content here.", + ) + + assert isinstance(result, str) + assert "Edited note (append)" in result + assert "file_path: test/Test Note.md" in result + assert "permalink: test/test-note" in result + assert "Added 3 lines to end of note" in result + + +@pytest.mark.asyncio +async def test_edit_note_prepend_operation(client): + """Test prepending content to an existing note.""" + # Create initial note + await write_note( + title="Meeting Notes", + folder="meetings", + content="# Meeting Notes\nExisting content.", + ) + + # Prepend content + result = await edit_note( + identifier="meetings/meeting-notes", + operation="prepend", + content="## 2025-05-25 Update\nNew meeting notes.\n", + ) + + assert isinstance(result, str) + assert "Edited note (prepend)" in result + assert "file_path: meetings/Meeting Notes.md" in result + assert "permalink: meetings/meeting-notes" in result + assert "Added 3 lines to beginning of note" in result + + +@pytest.mark.asyncio +async def test_edit_note_find_replace_operation(client): + """Test find and replace operation.""" + # Create initial note with version info + await write_note( + 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( + identifier="config/config-document", + operation="find_replace", + content="v0.13.0", + find_text="v0.12.0", + expected_replacements=2, + ) + + assert isinstance(result, str) + assert "Edited note (find_replace)" in result + assert "file_path: config/Config Document.md" in result + assert "operation: Find and replace operation completed" in result + + +@pytest.mark.asyncio +async def test_edit_note_replace_section_operation(client): + """Test replacing content under a specific section.""" + # Create initial note with sections + await write_note( + 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( + identifier="specs/api-specification", + operation="replace_section", + content="New implementation approach using FastAPI.\nImproved error handling.\n", + section="## Implementation", + ) + + assert isinstance(result, str) + assert "Edited note (replace_section)" in result + assert "file_path: specs/API Specification.md" in result + assert "Replaced content under section '## Implementation'" in result + + +@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( + identifier="nonexistent/note", operation="append", content="Some content" + ) + + assert isinstance(result, str) + assert "# Edit Failed" in result + assert "search_notes" in result # Should suggest searching + assert "read_note" in result # Should suggest reading to verify + + +@pytest.mark.asyncio +async def test_edit_note_invalid_operation(client): + """Test using an invalid operation.""" + # Create a note first + await write_note( + 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") + + assert "Invalid operation 'invalid_op'" in str(exc_info.value) + + +@pytest.mark.asyncio +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( + title="Test Note", + folder="test", + content="# Test\nContent here.", + ) + + with pytest.raises(ValueError) as exc_info: + await edit_note( + identifier="test/test-note", operation="find_replace", content="replacement" + ) + + assert "find_text parameter is required for find_replace operation" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_edit_note_replace_section_missing_section(client): + """Test replace_section operation without section parameter.""" + # Create a note first + await write_note( + title="Test Note", + folder="test", + content="# Test\nContent here.", + ) + + with pytest.raises(ValueError) as exc_info: + await edit_note( + identifier="test/test-note", operation="replace_section", content="new content" + ) + + assert "section parameter is required for replace_section operation" in str(exc_info.value) + + +@pytest.mark.asyncio +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( + title="Document", + folder="docs", + content="# Document\n\n## Existing Section\nSome content here.", + ) + + # Try to replace non-existent section + result = await edit_note( + identifier="docs/document", + operation="replace_section", + content="New section content here.\n", + section="## New Section", + ) + + assert isinstance(result, str) + assert "Edited note (replace_section)" in result + assert "file_path: docs/Document.md" in result + # Should succeed - the section gets appended if it doesn't exist + + +@pytest.mark.asyncio +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( + 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( + identifier="features/feature-spec", + operation="append", + content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]", + ) + + assert isinstance(result, str) + assert "Edited note (append)" in result + assert "## Observations" in result + assert "## Relations" in result + + +@pytest.mark.asyncio +async def test_edit_note_identifier_variations(client): + """Test that various identifier formats work.""" + # Create a note + await write_note( + title="Test Document", + folder="docs", + content="# Test Document\nOriginal content.", + ) + + # Test different identifier formats + identifiers_to_test = [ + "docs/test-document", # permalink + "Test Document", # title + "docs/Test Document", # folder/title + ] + + for identifier in identifiers_to_test: + result = await edit_note( + identifier=identifier, operation="append", content=f"\n## Update via {identifier}" + ) + + assert isinstance(result, str) + assert "Edited note (append)" in result + assert "file_path: docs/Test Document.md" in result + + +@pytest.mark.asyncio +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( + 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( + identifier="test/test-note", + operation="find_replace", + content="replacement", + find_text="nonexistent_text", + ) + + assert isinstance(result, str) + assert "# Edit Failed - Text Not Found" in result + assert "read_note" in result # Should suggest reading the note first + assert "Alternative approaches" in result # Should suggest alternatives + + +@pytest.mark.asyncio +async def test_edit_note_empty_content_operations(client): + """Test operations with empty content.""" + # Create initial note + await write_note( + 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="") + + assert isinstance(result, str) + assert "Edited note (append)" in result + # Should still work, just adding empty content + + +@pytest.mark.asyncio +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( + 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( + identifier="config/config-document", + operation="find_replace", + content="v0.13.0", + find_text="v0.12.0", + expected_replacements=1, # Wrong! There are actually 2 occurrences + ) + + assert isinstance(result, str) + assert "# Edit Failed - Wrong Replacement Count" in result + assert "Expected 1 occurrences" in result + assert "but found 2" in result + assert "Update expected_replacements" in result # Should suggest the fix + assert "expected_replacements=2" in result # Should suggest the exact fix + + +@pytest.mark.asyncio +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( + 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( + identifier="docs/sample-note", + operation="replace_section", + content="New content", + section="## Section 1", + ) + + assert isinstance(result, str) + assert "# Edit Failed - Duplicate Section Headers" in result + assert "Multiple sections found" in result + assert "read_note" in result # Should suggest reading the note first + assert "Make headers unique" in result # Should suggest making headers unique + + +@pytest.mark.asyncio +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( + 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( + identifier="test/test-note", + operation="find_replace", + content="replacement", + find_text=" ", # whitespace only + ) + + assert isinstance(result, str) + assert "# Edit Failed" in result + # Should contain helpful guidance about the error diff --git a/tests/mcp/test_tool_list_directory.py b/tests/mcp/test_tool_list_directory.py new file mode 100644 index 000000000..69112ee57 --- /dev/null +++ b/tests/mcp/test_tool_list_directory.py @@ -0,0 +1,212 @@ +"""Tests for the list_directory MCP tool.""" + +import pytest + +from basic_memory.mcp.tools.list_directory import list_directory +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() + + assert isinstance(result, str) + assert "No files found in directory '/'" in result + + +@pytest.mark.asyncio +async def test_list_directory_with_test_graph(client, test_graph): + """Test listing directory with test_graph fixture.""" + # test_graph provides: + # /test/Connected Entity 1.md + # /test/Connected Entity 2.md + # /test/Deep Entity.md + # /test/Deeper Entity.md + # /test/Root.md + + # List root directory + result = await list_directory() + + assert isinstance(result, str) + assert "Contents of '/' (depth 1):" in result + assert "📁 test" in result + assert "Total: 1 items (1 directory)" in result + + +@pytest.mark.asyncio +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") + + assert isinstance(result, str) + assert "Contents of '/test' (depth 1):" in result + assert "📄 Connected Entity 1.md" in result + assert "📄 Connected Entity 2.md" in result + assert "📄 Deep Entity.md" in result + assert "📄 Deeper Entity.md" in result + assert "📄 Root.md" in result + assert "Total: 5 items (5 files)" in result + + +@pytest.mark.asyncio +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*") + + assert isinstance(result, str) + assert "Files in '/test' matching '*Connected*' (depth 1):" in result + assert "📄 Connected Entity 1.md" in result + assert "📄 Connected Entity 2.md" in result + # Should not contain other files + assert "Deep Entity.md" not in result + assert "Deeper Entity.md" not in result + assert "Root.md" not in result + assert "Total: 2 items (2 files)" in result + + +@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") + + assert isinstance(result, str) + assert "Files in '/test' matching '*.md' (depth 1):" in result + # All files in test_graph are markdown files + assert "📄 Connected Entity 1.md" in result + assert "📄 Connected Entity 2.md" in result + assert "📄 Deep Entity.md" in result + assert "📄 Deeper Entity.md" in result + assert "📄 Root.md" in result + assert "Total: 5 items (5 files)" in result + + +@pytest.mark.asyncio +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) + + assert isinstance(result_depth_1, str) + assert "Contents of '/' (depth 1):" in result_depth_1 + assert "📁 test" in result_depth_1 + 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) + + assert isinstance(result_depth_2, str) + assert "Contents of '/' (depth 2):" in result_depth_2 + assert "📁 test" in result_depth_2 + assert "📄 Connected Entity 1.md" in result_depth_2 + assert "📄 Connected Entity 2.md" in result_depth_2 + assert "📄 Deep Entity.md" in result_depth_2 + assert "📄 Deeper Entity.md" in result_depth_2 + assert "📄 Root.md" in result_depth_2 + assert "Total: 6 items (1 directory, 5 files)" in result_depth_2 + + +@pytest.mark.asyncio +async def test_list_directory_nonexistent_path(client, test_graph): + """Test listing nonexistent directory.""" + result = await list_directory(dir_name="/nonexistent") + + assert isinstance(result, str) + assert "No files found in directory '/nonexistent'" in result + + +@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") + + assert isinstance(result, str) + assert "No files found in directory '/test' matching '*.xyz'" in result + + +@pytest.mark.asyncio +async def test_list_directory_with_created_notes(client): + """Test listing directory with dynamically created notes.""" + # Create some test notes + await write_note( + title="Project Planning", + folder="projects", + content="# Project Planning\nThis is about planning projects.", + tags=["planning", "project"], + ) + + await write_note( + title="Meeting Notes", + folder="projects", + content="# Meeting Notes\nNotes from the meeting.", + tags=["meeting", "notes"], + ) + + await write_note( + title="Research Document", + folder="research", + content="# Research\nSome research findings.", + tags=["research"], + ) + + # List root directory + result_root = await list_directory() + + assert isinstance(result_root, str) + assert "Contents of '/' (depth 1):" in result_root + assert "📁 projects" in result_root + assert "📁 research" in result_root + assert "Total: 2 items (2 directories)" in result_root + + # List projects directory + result_projects = await list_directory(dir_name="/projects") + + assert isinstance(result_projects, str) + assert "Contents of '/projects' (depth 1):" in result_projects + assert "📄 Project Planning.md" in result_projects + assert "📄 Meeting Notes.md" in result_projects + 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*") + + assert isinstance(result_meeting, str) + assert "Files in '/projects' matching '*Meeting*' (depth 1):" in result_meeting + assert "📄 Meeting Notes.md" in result_meeting + assert "Project Planning.md" not in result_meeting + assert "Total: 1 items (1 file)" in result_meeting + + +@pytest.mark.asyncio +async def test_list_directory_path_normalization(client, test_graph): + """Test that various path formats work correctly.""" + # Test various equivalent path formats + paths_to_test = ["/test", "test", "/test/", "test/"] + + for path in paths_to_test: + result = await list_directory(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 + + +@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") + + assert isinstance(result, str) + # Should show file names + assert "📄 Connected Entity 1.md" in result + assert "📄 Connected Entity 2.md" in result + + # Should show directory paths + assert "test/Connected Entity 1.md" in result + assert "test/Connected Entity 2.md" in result + + # Files should be listed after directories (but no directories in this case) + lines = result.split("\n") + file_lines = [line for line in lines if "📄" in line] + assert len(file_lines) == 5 # All 5 files from test_graph diff --git a/tests/mcp/test_tool_move_note.py b/tests/mcp/test_tool_move_note.py new file mode 100644 index 000000000..e8c36fdbe --- /dev/null +++ b/tests/mcp/test_tool_move_note.py @@ -0,0 +1,439 @@ +"""Tests for the move_note MCP tool.""" + +import pytest + +from basic_memory.mcp.tools.move_note import move_note +from basic_memory.mcp.tools.write_note import write_note +from basic_memory.mcp.tools.read_note import read_note + + +@pytest.mark.asyncio +async def test_move_note_success(app, client): + """Test successfully moving a note to a new location.""" + # Create initial note + await write_note( + title="Test Note", + folder="source", + content="# Test Note\nOriginal content here.", + ) + + # Move note + result = await move_note( + 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") + 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") + assert "# Test Note" in content + assert "Original content here" in content + assert "permalink: target/moved-note" in content + + +@pytest.mark.asyncio +async def test_move_note_with_folder_creation(client): + """Test moving note creates necessary folders.""" + # Create initial note + await write_note( + title="Deep Note", + folder="", + content="# Deep Note\nContent in root folder.", + ) + + # Move to deeply nested path + result = await move_note( + identifier="deep-note", + destination_path="deeply/nested/folder/DeepNote.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify note exists at new location + content = await read_note("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): + """Test moving note preserves observations and relations.""" + # Create note with complex semantic content + await write_note( + title="Complex Entity", + folder="source", + content="""# Complex Entity + +## Observations +- [note] Important observation #tag1 +- [feature] Key feature #feature + +## Relations +- relation to [[SomeOtherEntity]] +- depends on [[Dependency]] + +Some additional content. + """, + ) + + # Move note + result = await move_note( + identifier="source/complex-entity", + destination_path="target/MovedComplex.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify moved note preserves all content + content = await read_note("target/moved-complex") + assert "Important observation #tag1" in content + assert "Key feature #feature" in content + assert "[[SomeOtherEntity]]" in content + assert "[[Dependency]]" in content + assert "Some additional content" in content + + +@pytest.mark.asyncio +async def test_move_note_by_title(client): + """Test moving note using title as identifier.""" + # Create note with unique title + await write_note( + title="UniqueTestTitle", + folder="source", + content="# UniqueTestTitle\nTest content.", + ) + + # Move using title as identifier + result = await move_note( + identifier="UniqueTestTitle", + destination_path="target/MovedByTitle.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify note exists at new location + content = await read_note("target/moved-by-title") + assert "# UniqueTestTitle" in content + assert "Test content" in content + + +@pytest.mark.asyncio +async def test_move_note_by_file_path(client): + """Test moving note using file path as identifier.""" + # Create initial note + await write_note( + title="PathTest", + folder="source", + content="# PathTest\nContent for path test.", + ) + + # Move using file path as identifier + result = await move_note( + identifier="source/PathTest.md", + destination_path="target/MovedByPath.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify note exists at new location + content = await read_note("target/moved-by-path") + assert "# PathTest" in content + assert "Content for path test" in content + + +@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 + + +@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( + 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 + ) + + +@pytest.mark.asyncio +async def test_move_note_destination_exists(client): + """Test moving note to existing destination.""" + # Create source note + await write_note( + title="SourceNote", + folder="source", + content="# SourceNote\nSource content.", + ) + + # Create destination note + await write_note( + 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 + + +@pytest.mark.asyncio +async def test_move_note_same_location(client): + """Test moving note to the same location.""" + # Create initial note + await write_note( + 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 + + +@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( + title="OriginalName", + folder="test", + content="# OriginalName\nContent to rename.", + ) + + # Rename within same folder + result = await move_note( + 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 + try: + await read_note("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") + assert "# OriginalName" in content # Title in content remains same + assert "Content to rename" in content + assert "permalink: test/new-name" in content + + +@pytest.mark.asyncio +async def test_move_note_complex_filename(client): + """Test moving note with spaces in filename.""" + # Create note with spaces in name + await write_note( + title="Meeting Notes 2025", + folder="meetings", + content="# Meeting Notes 2025\nMeeting content with dates.", + ) + + # Move to new location + result = await move_note( + identifier="meetings/meeting-notes-2025", + destination_path="archive/2025/meetings/Meeting Notes 2025.md", + ) + + assert isinstance(result, str) + 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") + assert "# Meeting Notes 2025" in content + assert "Meeting content with dates" in content + + +@pytest.mark.asyncio +async def test_move_note_with_tags(client): + """Test moving note with tags preserves tags.""" + # Create note with tags + await write_note( + title="Tagged Note", + folder="source", + content="# Tagged Note\nContent with tags.", + tags=["important", "work", "project"], + ) + + # Move note + result = await move_note( + identifier="source/tagged-note", + destination_path="target/MovedTaggedNote.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify tags are preserved in correct YAML format + content = await read_note("target/moved-tagged-note") + assert "- important" in content + assert "- work" in content + assert "- project" in content + + +@pytest.mark.asyncio +async def test_move_note_empty_string_destination(client): + """Test moving note with empty destination path.""" + # Create initial note + await write_note( + 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 + ) + + +@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( + 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 + ) + + +@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( + title="Test Document", + folder="docs", + content="# Test Document\nContent for testing identifiers.", + ) + + # Test with permalink identifier + result = await move_note( + identifier="docs/test-document", + destination_path="moved/TestDocument.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify it moved correctly + content = await read_note("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): + """Test that moving preserves custom frontmatter.""" + # Create note with custom frontmatter by first creating it normally + await write_note( + title="Custom Frontmatter Note", + folder="source", + content="# Custom Frontmatter Note\nContent with custom metadata.", + ) + + # Move the note + result = await move_note( + identifier="source/custom-frontmatter-note", + destination_path="target/MovedCustomNote.md", + ) + + assert isinstance(result, str) + assert "✅ Note moved successfully" in result + + # Verify the moved note has proper frontmatter structure + content = await read_note("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 diff --git a/tests/mcp/test_tool_utils.py b/tests/mcp/test_tool_utils.py index f1861b0d1..db232559d 100644 --- a/tests/mcp/test_tool_utils.py +++ b/tests/mcp/test_tool_utils.py @@ -17,6 +17,7 @@ class MockResponse: def __init__(self, status_code=200): self.status_code = status_code self.is_success = status_code < 400 + self.json = lambda: {} def raise_for_status(self): if self.status_code >= 400: diff --git a/tests/mcp/test_tool_write_note.py b/tests/mcp/test_tool_write_note.py index 206d04261..b7ec2dbb4 100644 --- a/tests/mcp/test_tool_write_note.py +++ b/tests/mcp/test_tool_write_note.py @@ -24,18 +24,11 @@ async def test_write_note(app): ) assert result - assert ( - dedent(""" - # Created note - file_path: test/Test Note.md - permalink: test/test-note - checksum: 159f2168 - - ## Tags - - test, documentation - """).strip() - in result - ) + assert "# Created note" in result + assert "file_path: test/Test Note.md" in result + assert "permalink: test/test-note" in result + assert "## Tags" in result + assert "- test, documentation" in result # Try reading it back via permalink content = await read_note("test/test-note") @@ -46,8 +39,8 @@ async def test_write_note(app): type: note permalink: test/test-note tags: - - '#test' - - '#documentation' + - test + - documentation --- # Test @@ -63,15 +56,9 @@ async def test_write_note_no_tags(app): result = await write_note(title="Simple Note", folder="test", content="Just some text") assert result - assert ( - dedent(""" - # Created note - file_path: test/Simple Note.md - permalink: test/simple-note - checksum: 9a1ff079 - """).strip() - in 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") assert ( @@ -106,18 +93,11 @@ async def test_write_note_update_existing(app): ) assert result # Got a valid permalink - assert ( - dedent(""" - # Created note - file_path: test/Test Note.md - permalink: test/test-note - checksum: 159f2168 - - ## Tags - - test, documentation - """).strip() - in result - ) + assert "# Created note" in result + assert "file_path: test/Test Note.md" in result + assert "permalink: test/test-note" in result + assert "## Tags" in result + assert "- test, documentation" in result result = await write_note( title="Test Note", @@ -125,18 +105,11 @@ async def test_write_note_update_existing(app): content="# Test\nThis is an updated note", tags=["test", "documentation"], ) - assert ( - dedent(""" - # Updated note - file_path: test/Test Note.md - permalink: test/test-note - checksum: a8eb4d44 - - ## Tags - - test, documentation - """).strip() - in result - ) + assert "# Updated note" in result + assert "file_path: test/Test Note.md" in result + assert "permalink: test/test-note" in result + assert "## Tags" in result + assert "- test, documentation" in result # Try reading it back content = await read_note("test/test-note") @@ -148,8 +121,8 @@ async def test_write_note_update_existing(app): type: note permalink: test/test-note tags: - - '#test' - - '#documentation' + - test + - documentation --- # Test @@ -160,6 +133,82 @@ async def test_write_note_update_existing(app): ) +@pytest.mark.asyncio +async def test_issue_93_write_note_respects_custom_permalink_new_note(app): + """Test that write_note respects custom permalinks in frontmatter for new notes (Issue #93)""" + + # Create a note with custom permalink in frontmatter + content_with_custom_permalink = dedent(""" + --- + permalink: custom/my-desired-permalink + --- + + # My New Note + + This note has a custom permalink specified in frontmatter. + + - [note] Testing if custom permalink is respected + """).strip() + + result = await write_note( + title="My New Note", + folder="notes", + content=content_with_custom_permalink, + ) + + # Verify the custom permalink is respected + assert "# Created note" in result + assert "file_path: notes/My New Note.md" in result + assert "permalink: custom/my-desired-permalink" in result + + +@pytest.mark.asyncio +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( + title="Existing Note", + folder="test", + content="Initial content without custom permalink", + ) + + assert "# Created note" in result1 + + # Extract the auto-generated permalink + initial_permalink = None + for line in result1.split("\n"): + if line.startswith("permalink:"): + initial_permalink = line.split(":", 1)[1].strip() + break + + assert initial_permalink is not None + + # Step 2: Update with content that includes custom permalink in frontmatter + updated_content = dedent(""" + --- + permalink: custom/new-permalink + --- + + # Existing Note + + Updated content with custom permalink in frontmatter. + + - [note] Custom permalink should be respected on update + """).strip() + + result2 = await write_note( + title="Existing Note", + folder="test", + content=updated_content, + ) + + # Verify the custom permalink is respected + assert "# Updated note" in result2 + assert "permalink: custom/new-permalink" in result2 + assert f"permalink: {initial_permalink}" not in result2 + + @pytest.mark.asyncio async def test_delete_note_existing(app): """Test deleting a new note. @@ -241,31 +290,18 @@ async def test_write_note_verbose(app): tags=["test", "documentation"], ) - assert ( - dedent(""" - # Created note - file_path: test/Test Note.md - permalink: test/test-note - checksum: 06873a7a - - ## Observations - - note: 1 - - ## Relations - - Resolved: 0 - - Unresolved: 1 - - Unresolved relations will be retried on next sync. - - ## Tags - - test, documentation - """).strip() - in result - ) + assert "# Created note" in result + assert "file_path: test/Test Note.md" in result + assert "permalink: test/test-note" in result + assert "## Observations" in result + assert "- note: 1" in result + assert "## Relations" in result + assert "## Tags" in result + assert "- test, documentation" in result @pytest.mark.asyncio -async def test_write_note_preserves_custom_metadata(app, test_config): +async def test_write_note_preserves_custom_metadata(app, project_config): """Test that updating a note preserves custom metadata fields. Reproduces issue #36 where custom frontmatter fields like Status @@ -291,7 +327,7 @@ async def test_write_note_preserves_custom_metadata(app, test_config): # We need to use a direct file update to add custom frontmatter import frontmatter - file_path = test_config.home / "test" / "Custom Metadata Note.md" + file_path = project_config.home / "test" / "Custom Metadata Note.md" post = frontmatter.load(file_path) # Add custom frontmatter @@ -327,9 +363,9 @@ async def test_write_note_preserves_custom_metadata(app, test_config): # And new content should be there assert "# Updated content" in content - # And tags should be updated - assert "'#test'" in content - assert "'#updated'" in content + # And tags should be updated (without # prefix) + assert "- test" in content + assert "- updated" in content @pytest.mark.asyncio @@ -366,8 +402,8 @@ async def test_write_note_preserves_content_frontmatter(app): version: 1.0 author: name tags: - - '#test' - - '#documentation' + - test + - documentation --- # Test diff --git a/tests/schemas/test_schemas.py b/tests/schemas/test_schemas.py index b36b887d7..06b265242 100644 --- a/tests/schemas/test_schemas.py +++ b/tests/schemas/test_schemas.py @@ -11,6 +11,7 @@ GetEntitiesRequest, RelationResponse, ) +from basic_memory.schemas.request import EditEntityRequest from basic_memory.schemas.base import to_snake_case, TimeFrame @@ -211,3 +212,68 @@ class TimeFrameModel(BaseModel): else: with pytest.raises(ValueError): tf = TimeFrameModel.model_validate({"timeframe": timeframe}) + + +def test_edit_entity_request_validation(): + """Test EditEntityRequest validation for operation-specific parameters.""" + # Valid request - append operation + edit_request = EditEntityRequest.model_validate( + {"operation": "append", "content": "New content to append"} + ) + assert edit_request.operation == "append" + assert edit_request.content == "New content to append" + + # Valid request - find_replace operation with required find_text + edit_request = EditEntityRequest.model_validate( + {"operation": "find_replace", "content": "replacement text", "find_text": "text to find"} + ) + assert edit_request.operation == "find_replace" + assert edit_request.find_text == "text to find" + + # Valid request - replace_section operation with required section + edit_request = EditEntityRequest.model_validate( + {"operation": "replace_section", "content": "new section content", "section": "## Header"} + ) + assert edit_request.operation == "replace_section" + assert edit_request.section == "## Header" + + # Test that the validators return the value when validation passes + # This ensures the `return v` statements are covered + edit_request = EditEntityRequest.model_validate( + { + "operation": "find_replace", + "content": "replacement", + "find_text": "valid text", + "section": "## Valid Section", + } + ) + assert edit_request.find_text == "valid text" # Covers line 88 (return v) + assert edit_request.section == "## Valid Section" # Covers line 80 (return v) + + +def test_edit_entity_request_find_replace_empty_find_text(): + """Test that find_replace operation requires non-empty find_text parameter.""" + with pytest.raises( + ValueError, match="find_text parameter is required for find_replace operation" + ): + EditEntityRequest.model_validate( + { + "operation": "find_replace", + "content": "replacement text", + "find_text": "", # Empty string triggers validation + } + ) + + +def test_edit_entity_request_replace_section_empty_section(): + """Test that replace_section operation requires non-empty section parameter.""" + with pytest.raises( + ValueError, match="section parameter is required for replace_section operation" + ): + EditEntityRequest.model_validate( + { + "operation": "replace_section", + "content": "new content", + "section": "", # Empty string triggers validation + } + ) diff --git a/tests/services/test_directory_service.py b/tests/services/test_directory_service.py index 74c3b1d3b..c5d965c19 100644 --- a/tests/services/test_directory_service.py +++ b/tests/services/test_directory_service.py @@ -58,3 +58,131 @@ async def test_directory_tree(directory_service: DirectoryService, test_graph): assert node_file.file_path == "test/Deeper Entity.md" assert node_file.has_children is False assert len(node_file.children) == 0 + + +@pytest.mark.asyncio +async def test_list_directory_empty(directory_service: DirectoryService): + """Test listing directory with no entities.""" + result = await directory_service.list_directory() + assert result == [] + + +@pytest.mark.asyncio +async def test_list_directory_root(directory_service: DirectoryService, test_graph): + """Test listing root directory contents.""" + result = await directory_service.list_directory(dir_name="/") + + # Should return immediate children of root (the "test" directory) + assert len(result) == 1 + assert result[0].name == "test" + assert result[0].type == "directory" + assert result[0].directory_path == "/test" + + +@pytest.mark.asyncio +async def test_list_directory_specific_path(directory_service: DirectoryService, test_graph): + """Test listing specific directory contents.""" + result = await directory_service.list_directory(dir_name="/test") + + # Should return the 5 files in the test directory + assert len(result) == 5 + file_names = {node.name for node in result} + expected_files = { + "Connected Entity 1.md", + "Connected Entity 2.md", + "Deep Entity.md", + "Deeper Entity.md", + "Root.md", + } + assert file_names == expected_files + + # All should be files + for node in result: + assert node.type == "file" + + +@pytest.mark.asyncio +async def test_list_directory_nonexistent_path(directory_service: DirectoryService, test_graph): + """Test listing nonexistent directory.""" + result = await directory_service.list_directory(dir_name="/nonexistent") + assert result == [] + + +@pytest.mark.asyncio +async def test_list_directory_with_glob_filter(directory_service: DirectoryService, test_graph): + """Test listing directory with glob pattern filtering.""" + # Filter for files containing "Connected" + result = await directory_service.list_directory(dir_name="/test", file_name_glob="*Connected*") + + assert len(result) == 2 + file_names = {node.name for node in result} + assert file_names == {"Connected Entity 1.md", "Connected Entity 2.md"} + + +@pytest.mark.asyncio +async def test_list_directory_with_markdown_filter(directory_service: DirectoryService, test_graph): + """Test listing directory with markdown file filter.""" + result = await directory_service.list_directory(dir_name="/test", file_name_glob="*.md") + + # All files in test_graph are markdown files + assert len(result) == 5 + + +@pytest.mark.asyncio +async def test_list_directory_with_specific_file_filter( + directory_service: DirectoryService, test_graph +): + """Test listing directory with specific file pattern.""" + result = await directory_service.list_directory(dir_name="/test", file_name_glob="Root.*") + + assert len(result) == 1 + assert result[0].name == "Root.md" + + +@pytest.mark.asyncio +async def test_list_directory_depth_control(directory_service: DirectoryService, test_graph): + """Test listing directory with depth control.""" + # Depth 1 should only return immediate children + result_depth_1 = await directory_service.list_directory(dir_name="/", depth=1) + assert len(result_depth_1) == 1 # Just the "test" directory + + # Depth 2 should return directory + its contents + result_depth_2 = await directory_service.list_directory(dir_name="/", depth=2) + assert len(result_depth_2) == 6 # "test" directory + 5 files in it + + +@pytest.mark.asyncio +async def test_list_directory_path_normalization(directory_service: DirectoryService, test_graph): + """Test that directory paths are normalized correctly.""" + # Test various path formats that should all be equivalent + paths_to_test = ["/test", "test", "/test/", "test/"] + + base_result = await directory_service.list_directory(dir_name="/test") + + for path in paths_to_test: + result = await directory_service.list_directory(dir_name=path) + assert len(result) == len(base_result) + # Compare by name since the objects might be different instances + result_names = {node.name for node in result} + base_names = {node.name for node in base_result} + assert result_names == base_names + + +@pytest.mark.asyncio +async def test_list_directory_glob_no_matches(directory_service: DirectoryService, test_graph): + """Test listing directory with glob that matches nothing.""" + result = await directory_service.list_directory( + dir_name="/test", file_name_glob="*.nonexistent" + ) + assert result == [] + + +@pytest.mark.asyncio +async def test_list_directory_default_parameters(directory_service: DirectoryService, test_graph): + """Test listing directory with default parameters.""" + # Should default to root directory, depth 1, no glob filter + result = await directory_service.list_directory() + + assert len(result) == 1 + assert result[0].name == "test" + assert result[0].type == "directory" diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index 3e1db64a2..cd9ada677 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -6,7 +6,7 @@ import pytest import yaml -from basic_memory.config import ProjectConfig +from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.markdown import EntityParser from basic_memory.models import Entity as EntityModel from basic_memory.repository import EntityRepository @@ -24,7 +24,6 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe title="Test Entity", folder="", entity_type="test", - project=entity_service.repository.project_id, ) # Act @@ -65,7 +64,6 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser folder="", entity_type="test", content="first", - project=entity_service.repository.project_id, ) # Act @@ -85,7 +83,6 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser folder="", entity_type="test", content="second", - project=entity_service.repository.project_id, ) with pytest.raises(EntityCreationError): @@ -94,7 +91,7 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser @pytest.mark.asyncio async def test_create_entity_unique_permalink( - test_config, + project_config, entity_service: EntityService, file_service: FileService, entity_repository: EntityRepository, @@ -104,7 +101,6 @@ async def test_create_entity_unique_permalink( title="Test Entity", folder="test", entity_type="test", - project=entity_repository.project_id, ) entity = await entity_service.create_entity(entity_data) @@ -114,7 +110,7 @@ async def test_create_entity_unique_permalink( # move file file_path = file_service.get_entity_path(entity) - file_path.rename(test_config.home / "new_path.md") + file_path.rename(project_config.home / "new_path.md") await entity_repository.update(entity.id, {"file_path": "new_path.md"}) # create again @@ -137,7 +133,6 @@ async def test_get_by_permalink(entity_service: EntityService): title="TestEntity1", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) entity1 = await entity_service.create_entity(entity1_data) @@ -145,7 +140,6 @@ async def test_get_by_permalink(entity_service: EntityService): title="TestEntity2", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) entity2 = await entity_service.create_entity(entity2_data) @@ -173,7 +167,6 @@ async def test_get_entity_success(entity_service: EntityService): title="TestEntity", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) await entity_service.create_entity(entity_data) @@ -192,7 +185,6 @@ async def test_delete_entity_success(entity_service: EntityService): title="TestEntity", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) await entity_service.create_entity(entity_data) @@ -212,7 +204,6 @@ async def test_delete_entity_by_id(entity_service: EntityService): title="TestEntity", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) created = await entity_service.create_entity(entity_data) @@ -246,7 +237,6 @@ async def test_create_entity_with_special_chars(entity_service: EntityService): title=name, folder="test", entity_type="test", - project=entity_service.repository.project_id, ) entity = await entity_service.create_entity(entity_data) @@ -264,13 +254,11 @@ async def test_get_entities_by_permalinks(entity_service: EntityService): title="Entity1", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) entity2_data = EntitySchema( title="Entity2", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) await entity_service.create_entity(entity1_data) await entity_service.create_entity(entity2_data) @@ -299,7 +287,6 @@ async def test_get_entities_some_not_found(entity_service: EntityService): title="Entity1", folder="test", entity_type="test", - project=entity_service.repository.project_id, ) await entity_service.create_entity(entity_data) @@ -332,7 +319,6 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se folder="test", entity_type="note", entity_metadata={"status": "draft"}, - project=entity_service.repository.project_id, ) entity = await entity_service.create_entity(schema) @@ -370,7 +356,6 @@ async def test_create_or_update_new(entity_service: EntityService, file_service: folder="test", entity_type="test", entity_metadata={"status": "draft"}, - project=entity_service.repository.project_id, ) ) assert entity.title == "test" @@ -388,7 +373,6 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser entity_type="test", content="Test entity", entity_metadata={"status": "final"}, - project=entity_service.repository.project_id, ) ) @@ -431,14 +415,13 @@ async def test_create_with_content(entity_service: EntityService, file_service: folder="test", entity_type="test", content=content, - project=entity_service.repository.project_id, ) ) assert created is True assert entity.title == "Git Workflow Guide" assert entity.entity_type == "test" - assert entity.permalink == "test/git-workflow-guide" + assert entity.permalink == "git-workflow-guide" assert entity.file_path == "test/Git Workflow Guide.md" assert len(entity.observations) == 1 @@ -468,7 +451,7 @@ async def test_create_with_content(entity_service: EntityService, file_service: --- title: Git Workflow Guide type: test - permalink: test/git-workflow-guide + permalink: git-workflow-guide --- # Git Workflow Guide @@ -498,7 +481,6 @@ async def test_update_with_content(entity_service: EntityService, file_service: entity_type="test", folder="test", content=content, - project=entity_service.repository.project_id, ) ) @@ -534,7 +516,7 @@ async def test_update_with_content(entity_service: EntityService, file_service: --- title: Git Workflow Guide type: test - permalink: test/git-workflow-guide + permalink: git-workflow-guide --- # Git Workflow Guide @@ -558,13 +540,15 @@ async def test_update_with_content(entity_service: EntityService, file_service: folder="test", entity_type="test", content=update_content, - project=entity_service.repository.project_id, ) ) assert created is False assert entity.title == "Git Workflow Guide" + # assert custom permalink value + assert entity.permalink == "git-workflow-guide" + assert len(entity.observations) == 1 assert entity.observations[0].category == "design" assert entity.observations[0].content == "Keep feature branches short-lived #git #workflow" @@ -592,7 +576,7 @@ async def test_update_with_content(entity_service: EntityService, file_service: @pytest.mark.asyncio async def test_create_with_no_frontmatter( - test_config: ProjectConfig, + project_config: ProjectConfig, entity_parser: EntityParser, entity_service: EntityService, file_service: FileService, @@ -600,7 +584,7 @@ async def test_create_with_no_frontmatter( # contains no frontmatter content = "# Git Workflow Guide" file_path = Path("test/Git Workflow Guide.md") - full_path = test_config.home / file_path + full_path = project_config.home / file_path await file_service.write_file(Path(full_path), content) @@ -618,3 +602,1059 @@ async def test_create_with_no_frontmatter( # Git Workflow Guide """).strip() assert expected == file_content + + +@pytest.mark.asyncio +async def test_edit_entity_append(entity_service: EntityService, file_service: FileService): + """Test appending content to an entity.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + # Edit entity with append operation + updated = await entity_service.edit_entity( + identifier=entity.permalink, operation="append", content="Appended content" + ) + + # Verify content was appended + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Original content" in file_content + assert "Appended content" in file_content + assert file_content.index("Original content") < file_content.index("Appended content") + + +@pytest.mark.asyncio +async def test_edit_entity_prepend(entity_service: EntityService, file_service: FileService): + """Test prepending content to an entity.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + # Edit entity with prepend operation + updated = await entity_service.edit_entity( + identifier=entity.permalink, operation="prepend", content="Prepended content" + ) + + # Verify content was prepended + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Original content" in file_content + assert "Prepended content" in file_content + assert file_content.index("Prepended content") < file_content.index("Original content") + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace(entity_service: EntityService, file_service: FileService): + """Test find and replace operation on an entity.""" + # Create test entity with specific content to replace + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="This is old content that needs updating", + ) + ) + + # Edit entity with find_replace operation + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="find_replace", + content="new content", + find_text="old content", + ) + + # Verify content was replaced + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "old content" not in file_content + assert "This is new content that needs updating" in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section( + entity_service: EntityService, file_service: FileService +): + """Test replacing a specific section in an entity.""" + # Create test entity with sections + content = dedent(""" + # Main Title + + ## Section 1 + Original section 1 content + + ## Section 2 + Original section 2 content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Edit entity with replace_section operation + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New section 1 content", + section="## Section 1", + ) + + # Verify section was replaced + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "New section 1 content" in file_content + assert "Original section 1 content" not in file_content + assert "Original section 2 content" in file_content # Other sections preserved + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_create_new( + entity_service: EntityService, file_service: FileService +): + """Test replacing a section that doesn't exist creates it.""" + # Create test entity without the section + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="# Main Title\n\nSome content", + ) + ) + + # Edit entity with replace_section operation for non-existent section + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New section content", + section="## New Section", + ) + + # Verify section was created + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "## New Section" in file_content + assert "New section content" in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_not_found(entity_service: EntityService): + """Test editing a non-existent entity raises error.""" + with pytest.raises(EntityNotFoundError): + await entity_service.edit_entity( + identifier="non-existent", operation="append", content="content" + ) + + +@pytest.mark.asyncio +async def test_edit_entity_invalid_operation(entity_service: EntityService): + """Test editing with invalid operation raises error.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + with pytest.raises(ValueError, match="Unsupported operation"): + await entity_service.edit_entity( + identifier=entity.permalink, operation="invalid_operation", content="content" + ) + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_missing_find_text(entity_service: EntityService): + """Test find_replace operation without find_text raises error.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + with pytest.raises(ValueError, match="find_text is required"): + await entity_service.edit_entity( + identifier=entity.permalink, operation="find_replace", content="new content" + ) + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_missing_section(entity_service: EntityService): + """Test replace_section operation without section parameter raises error.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + with pytest.raises(ValueError, match="section is required"): + await entity_service.edit_entity( + identifier=entity.permalink, operation="replace_section", content="new content" + ) + + +@pytest.mark.asyncio +async def test_edit_entity_with_observations_and_relations( + entity_service: EntityService, file_service: FileService +): + """Test editing entity updates observations and relations correctly.""" + # Create test entity with observations and relations + content = dedent(""" + # Test Note + + - [note] This is an observation + - links to [[Other Entity]] + + Original content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Verify initial state + assert len(entity.observations) == 1 + assert len(entity.relations) == 1 + + # Edit entity by appending content with new observations/relations + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="append", + content="\n- [category] New observation\n- relates to [[New Entity]]", + ) + + # Verify observations and relations were updated + assert len(updated.observations) == 2 + assert len(updated.relations) == 2 + + # Check new observation + new_obs = [obs for obs in updated.observations if obs.category == "category"][0] + assert new_obs.content == "New observation" + + # Check new relation + new_rel = [rel for rel in updated.relations if rel.to_name == "New Entity"][0] + assert new_rel.relation_type == "relates to" + + +# Edge case tests for find_replace operation +@pytest.mark.asyncio +async def test_edit_entity_find_replace_not_found(entity_service: EntityService): + """Test find_replace operation when text is not found.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="This is some content", + ) + ) + + # Try to replace text that doesn't exist + with pytest.raises(ValueError, match="Text to replace not found: 'nonexistent'"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="find_replace", + content="new content", + find_text="nonexistent", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_multiple_occurrences_expected_one( + entity_service: EntityService, +): + """Test find_replace with multiple occurrences when expecting one.""" + # Create entity with repeated text (avoiding "test" since it appears in frontmatter) + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content="The word banana appears here. Another banana word here.", + ) + ) + + # Try to replace with expected count of 1 when there are 2 + with pytest.raises(ValueError, match="Expected 1 occurrences of 'banana', but found 2"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="find_replace", + content="replacement", + find_text="banana", + expected_replacements=1, + ) + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_multiple_occurrences_success( + entity_service: EntityService, file_service: FileService +): + """Test find_replace with multiple occurrences when expected count matches.""" + # Create test entity with repeated text (avoiding "test" since it appears in frontmatter) + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content="The word banana appears here. Another banana word here.", + ) + ) + + # Replace with correct expected count + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="find_replace", + content="apple", + find_text="banana", + expected_replacements=2, + ) + + # Verify both instances were replaced + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "The word apple appears here. Another apple word here." in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_empty_find_text(entity_service: EntityService): + """Test find_replace with empty find_text.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Some content", + ) + ) + + # Try with empty find_text + with pytest.raises(ValueError, match="find_text cannot be empty or whitespace only"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="find_replace", + content="new content", + find_text=" ", # whitespace only + ) + + +@pytest.mark.asyncio +async def test_edit_entity_find_replace_multiline( + entity_service: EntityService, file_service: FileService +): + """Test find_replace with multiline text.""" + # Create test entity with multiline content + content = dedent(""" + # Title + + This is a paragraph + that spans multiple lines + and needs replacement. + + Other content. + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Replace multiline text + find_text = "This is a paragraph\nthat spans multiple lines\nand needs replacement." + new_text = "This is new content\nthat replaces the old paragraph." + + updated = await entity_service.edit_entity( + identifier=entity.permalink, operation="find_replace", content=new_text, find_text=find_text + ) + + # Verify replacement worked + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "This is new content\nthat replaces the old paragraph." in file_content + assert "Other content." in file_content # Make sure rest is preserved + + +# Edge case tests for replace_section operation +@pytest.mark.asyncio +async def test_edit_entity_replace_section_multiple_sections_error(entity_service: EntityService): + """Test replace_section with multiple sections having same header.""" + # Create test entity with duplicate section headers + content = dedent(""" + # Main Title + + ## Section 1 + First instance content + + ## Section 2 + Some content + + ## Section 1 + Second instance content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Try to replace section when multiple exist + with pytest.raises(ValueError, match="Multiple sections found with header '## Section 1'"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New content", + section="## Section 1", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_empty_section(entity_service: EntityService): + """Test replace_section with empty section parameter.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Some content", + ) + ) + + # Try with empty section + with pytest.raises(ValueError, match="section cannot be empty or whitespace only"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="new content", + section=" ", # whitespace only + ) + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_header_variations( + entity_service: EntityService, file_service: FileService +): + """Test replace_section with different header formatting.""" + # Create entity with various header formats (avoiding "test" in frontmatter) + content = dedent(""" + # Main Title + + ## Section Name + Original content + + ### Subsection + Sub content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Test replacing with different header format (no ##) + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New section content", + section="Section Name", # No ## prefix + ) + + # Verify replacement worked + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "New section content" in file_content + assert "Original content" not in file_content + assert "### Subsection" in file_content # Subsection preserved + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_at_end_of_document( + entity_service: EntityService, file_service: FileService +): + """Test replace_section when section is at the end of document.""" + # Create test entity with section at end + content = dedent(""" + # Main Title + + ## First Section + First content + + ## Last Section + Last section content""").strip() # No trailing newline + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Replace the last section + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New last section content", + section="## Last Section", + ) + + # Verify replacement worked + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "New last section content" in file_content + assert "Last section content" not in file_content + assert "First content" in file_content # Previous section preserved + + +@pytest.mark.asyncio +async def test_edit_entity_replace_section_with_subsections( + entity_service: EntityService, file_service: FileService +): + """Test replace_section preserves subsections (stops at any header).""" + # Create test entity with nested sections + content = dedent(""" + # Main Title + + ## Parent Section + Parent content + + ### Child Section 1 + Child 1 content + + ### Child Section 2 + Child 2 content + + ## Another Section + Other content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Sample Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Replace parent section (should only replace content until first subsection) + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="replace_section", + content="New parent content", + section="## Parent Section", + ) + + # Verify replacement worked - only immediate content replaced, subsections preserved + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "New parent content" in file_content + assert "Parent content" not in file_content # Original content replaced + assert "Child 1 content" in file_content # Child sections preserved + assert "Child 2 content" in file_content # Child sections preserved + assert "## Another Section" in file_content # Next section preserved + assert "Other content" in file_content + + +# Move entity tests +@pytest.mark.asyncio +async def test_move_entity_success( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test successful entity move with basic settings.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content="Original content", + ) + ) + + # Verify original file exists + original_path = file_service.get_entity_path(entity) + assert await file_service.exists(original_path) + + # Create app config with permalinks disabled + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Move entity + assert entity.permalink == "original/test-note" + result = await entity_service.move_entity( + identifier=entity.permalink, + destination_path="moved/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Verify original file no longer exists + assert not await file_service.exists(original_path) + + # Verify new file exists + new_path = project_config.home / "moved/test-note.md" + assert new_path.exists() + + # Verify database was updated + updated_entity = await entity_service.get_by_permalink(entity.permalink) + assert updated_entity.file_path == "moved/test-note.md" + + # Verify file content is preserved + new_content, _ = await file_service.read_file("moved/test-note.md") + assert "Original content" in new_content + + +@pytest.mark.asyncio +async def test_move_entity_with_permalink_update( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test entity move with permalink updates enabled.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content="Original content", + ) + ) + + original_permalink = entity.permalink + + # Create app config with permalinks enabled + app_config = BasicMemoryConfig(update_permalinks_on_move=True) + + # Move entity + result = await entity_service.move_entity( + identifier=entity.permalink, + destination_path="moved/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + + # Verify entity was found by new path (since permalink changed) + moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md") + assert moved_entity is not None + assert moved_entity.file_path == "moved/test-note.md" + assert moved_entity.permalink != original_permalink + + # Verify frontmatter was updated with new permalink + new_content, _ = await file_service.read_file("moved/test-note.md") + assert moved_entity.permalink in new_content + + +@pytest.mark.asyncio +async def test_move_entity_creates_destination_directory( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test that moving creates destination directory if it doesn't exist.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content="Original content", + ) + ) + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Move to deeply nested path that doesn't exist + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="deeply/nested/folders/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Verify directory was created + new_path = project_config.home / "deeply/nested/folders/test-note.md" + assert new_path.exists() + assert new_path.parent.exists() + + +@pytest.mark.asyncio +async def test_move_entity_not_found( + entity_service: EntityService, + project_config: ProjectConfig, +): + """Test moving non-existent entity raises error.""" + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + with pytest.raises(EntityNotFoundError, match="Entity not found: non-existent"): + await entity_service.move_entity( + identifier="non-existent", + destination_path="new/path.md", + project_config=project_config, + app_config=app_config, + ) + + +@pytest.mark.asyncio +async def test_move_entity_source_file_missing( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test moving when source file doesn't exist on filesystem.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + # Manually delete the file (simulating corruption/external deletion) + file_path = file_service.get_entity_path(entity) + file_path.unlink() + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + with pytest.raises(ValueError, match="Source file not found:"): + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="new/path.md", + project_config=project_config, + app_config=app_config, + ) + + +@pytest.mark.asyncio +async def test_move_entity_destination_exists( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test moving to existing destination fails.""" + # Create two test entities + entity1 = await entity_service.create_entity( + EntitySchema( + title="Test Note 1", + folder="test", + entity_type="note", + content="Content 1", + ) + ) + + entity2 = await entity_service.create_entity( + EntitySchema( + title="Test Note 2", + folder="test", + entity_type="note", + content="Content 2", + ) + ) + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Try to move entity1 to entity2's location + with pytest.raises(ValueError, match="Destination already exists:"): + await entity_service.move_entity( + identifier=entity1.permalink, + destination_path=entity2.file_path, + project_config=project_config, + app_config=app_config, + ) + + +@pytest.mark.asyncio +async def test_move_entity_invalid_destination_path( + entity_service: EntityService, + project_config: ProjectConfig, +): + """Test moving with invalid destination paths.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Original content", + ) + ) + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Test absolute path + with pytest.raises(ValueError, match="Invalid destination path:"): + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="/absolute/path.md", + project_config=project_config, + app_config=app_config, + ) + + # Test empty path + with pytest.raises(ValueError, match="Invalid destination path:"): + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="", + project_config=project_config, + app_config=app_config, + ) + + +@pytest.mark.asyncio +async def test_move_entity_by_title( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, + app_config: BasicMemoryConfig, +): + """Test moving entity by title instead of permalink.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content="Original content", + ) + ) + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Move by title + result = await entity_service.move_entity( + identifier="Test Note", # Use title instead of permalink + destination_path="moved/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Verify old path no longer exists + new_path = project_config.home / entity.file_path + assert not new_path.exists() + + # Verify new file exists + new_path = project_config.home / "moved/test-note.md" + assert new_path.exists() + + +@pytest.mark.asyncio +async def test_move_entity_preserves_observations_and_relations( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test that moving preserves entity observations and relations.""" + # Create test entity with observations and relations + content = dedent(""" + # Test Note + + - [note] This is an observation #test + - links to [[Other Entity]] + + Original content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content=content, + ) + ) + + # Verify initial observations and relations + assert len(entity.observations) == 1 + assert len(entity.relations) == 1 + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Move entity + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="moved/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Get moved entity + moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md") + + # Verify observations and relations are preserved + assert len(moved_entity.observations) == 1 + assert moved_entity.observations[0].content == "This is an observation #test" + assert len(moved_entity.relations) == 1 + assert moved_entity.relations[0].to_name == "Other Entity" + + # Verify file content includes observations and relations + new_content, _ = await file_service.read_file("moved/test-note.md") + assert "- [note] This is an observation #test" in new_content + assert "- links to [[Other Entity]]" in new_content + + +@pytest.mark.asyncio +async def test_move_entity_rollback_on_database_failure( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, + entity_repository: EntityRepository, +): + """Test that filesystem changes are rolled back on database failures.""" + # Create test entity + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + folder="original", + entity_type="note", + content="Original content", + ) + ) + + original_path = file_service.get_entity_path(entity) + assert await file_service.exists(original_path) + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Mock repository update to fail + original_update = entity_repository.update + + async def failing_update(*args, **kwargs): + return None # Simulate failure + + entity_repository.update = failing_update + + try: + with pytest.raises(ValueError, match="Move failed:"): + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="moved/test-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Verify rollback - original file should still exist + assert await file_service.exists(original_path) + + # Verify destination file was cleaned up + destination_path = project_config.home / "moved/test-note.md" + assert not destination_path.exists() + + finally: + # Restore original update method + entity_repository.update = original_update + + +@pytest.mark.asyncio +async def test_move_entity_with_complex_observations( + entity_service: EntityService, + file_service: FileService, + project_config: ProjectConfig, +): + """Test moving entity with complex observations (tags, context).""" + content = dedent(""" + # Complex Note + + - [design] Keep feature branches short-lived #git #workflow (Reduces merge conflicts) + - [tech] Using SQLite for storage #implementation (Fast and reliable) + - implements [[Branch Strategy]] (Our standard workflow) + + Complex content with [[Multiple]] [[Links]]. + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Complex Note", + folder="docs", + entity_type="note", + content=content, + ) + ) + + # Verify complex structure + assert len(entity.observations) == 2 + assert len(entity.relations) == 3 # 1 explicit + 2 wikilinks + + app_config = BasicMemoryConfig(update_permalinks_on_move=False) + + # Move entity + await entity_service.move_entity( + identifier=entity.permalink, + destination_path="moved/complex-note.md", + project_config=project_config, + app_config=app_config, + ) + + # Verify moved entity maintains structure + moved_entity = await entity_service.link_resolver.resolve_link("moved/complex-note.md") + + # Check observations with tags and context + design_obs = [obs for obs in moved_entity.observations if obs.category == "design"][0] + assert "git" in design_obs.tags + assert "workflow" in design_obs.tags + assert design_obs.context == "Reduces merge conflicts" + + tech_obs = [obs for obs in moved_entity.observations if obs.category == "tech"][0] + assert "implementation" in tech_obs.tags + assert tech_obs.context == "Fast and reliable" + + # Check relations + relation_types = {rel.relation_type for rel in moved_entity.relations} + assert "implements" in relation_types + assert "links to" in relation_types + + relation_targets = {rel.to_name for rel in moved_entity.relations} + assert "Branch Strategy" in relation_targets + assert "Multiple" in relation_targets + assert "Links" in relation_targets \ No newline at end of file diff --git a/tests/services/test_initialization.py b/tests/services/test_initialization.py index 92a31c73f..2db71abc5 100644 --- a/tests/services/test_initialization.py +++ b/tests/services/test_initialization.py @@ -18,19 +18,19 @@ @pytest.mark.asyncio @patch("basic_memory.services.initialization.db.run_migrations") -async def test_initialize_database(mock_run_migrations, test_config): +async def test_initialize_database(mock_run_migrations, project_config): """Test initializing the database.""" - await initialize_database(test_config) - mock_run_migrations.assert_called_once_with(test_config) + await initialize_database(project_config) + mock_run_migrations.assert_called_once_with(project_config) @pytest.mark.asyncio @patch("basic_memory.services.initialization.db.run_migrations") -async def test_initialize_database_error(mock_run_migrations, test_config): +async def test_initialize_database_error(mock_run_migrations, project_config): """Test handling errors during database initialization.""" mock_run_migrations.side_effect = Exception("Test error") - await initialize_database(test_config) - mock_run_migrations.assert_called_once_with(test_config) + await initialize_database(project_config) + mock_run_migrations.assert_called_once_with(project_config) @pytest.mark.asyncio @@ -76,9 +76,9 @@ async def test_initialize_app_sync_disabled( @patch("basic_memory.services.initialization.asyncio.run") -def test_ensure_initialization(mock_run, test_config): +def test_ensure_initialization(mock_run, project_config): """Test synchronous initialization wrapper.""" - ensure_initialization(test_config) + ensure_initialization(project_config) mock_run.assert_called_once() diff --git a/tests/services/test_link_resolver.py b/tests/services/test_link_resolver.py index 34d5f4ae3..3e3c13cad 100644 --- a/tests/services/test_link_resolver.py +++ b/tests/services/test_link_resolver.py @@ -179,3 +179,44 @@ async def test_resolve_file(link_resolver): assert resolved is not None assert resolved.entity_type == "file" assert resolved.title == "Image.png" + + +@pytest.mark.asyncio +async def test_folder_title_pattern_with_md_extension(link_resolver, test_entities): + """Test resolving folder/title patterns that need .md extension added. + + This tests the new logic added in step 4 of resolve_link that handles + patterns like 'folder/title' by trying 'folder/title.md' as file path. + """ + # Test folder/title pattern for markdown entities + # "components/Core Service" should resolve to file path "components/Core Service.md" + entity = await link_resolver.resolve_link("components/Core Service") + assert entity is not None + assert entity.permalink == "components/core-service" + assert entity.file_path == "components/Core Service.md" + + # Test with different entity + entity = await link_resolver.resolve_link("config/Service Config") + assert entity is not None + assert entity.permalink == "config/service-config" + assert entity.file_path == "config/Service Config.md" + + # Test with nested folder structure + entity = await link_resolver.resolve_link("specs/subspec/Sub Features 1") + assert entity is not None + assert entity.permalink == "specs/subspec/sub-features-1" + assert entity.file_path == "specs/subspec/Sub Features 1.md" + + # Test that it doesn't try to add .md to things that already have it + entity = await link_resolver.resolve_link("components/Core Service.md") + assert entity is not None + assert entity.permalink == "components/core-service" + + # Test that it doesn't try to add .md to single words (no slash) + entity = await link_resolver.resolve_link("NonExistent") + assert entity is None + + # Test that it doesn't interfere with exact permalink matches + entity = await link_resolver.resolve_link("components/core-service") + assert entity is not None + assert entity.permalink == "components/core-service" diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index a1b74cf14..c17065a44 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -11,7 +11,7 @@ SystemStatus, ) from basic_memory.services.project_service import ProjectService - +from basic_memory.config import ConfigManager def test_projects_property(project_service: ProjectService): """Test the projects property.""" @@ -63,7 +63,7 @@ def test_current_project_property(project_service: ProjectService): @pytest.mark.asyncio -async def test_project_operations_sync_methods(project_service: ProjectService, tmp_path): +async def test_project_operations_sync_methods(app_config, project_service: ProjectService, config_manager: ConfigManager, tmp_path): """Test adding, switching, and removing a project using ConfigManager directly. This test uses the ConfigManager directly instead of the async methods. @@ -77,7 +77,7 @@ async def test_project_operations_sync_methods(project_service: ProjectService, try: # Test adding a project (using ConfigManager directly) - project_service.config_manager.add_project(test_project_name, test_project_path) + config_manager.add_project(test_project_name, test_project_path) # Verify it was added assert test_project_name in project_service.projects @@ -85,22 +85,22 @@ async def test_project_operations_sync_methods(project_service: ProjectService, # Test setting as default original_default = project_service.default_project - project_service.config_manager.set_default_project(test_project_name) + config_manager.set_default_project(test_project_name) assert project_service.default_project == test_project_name # Restore original default if original_default: - project_service.config_manager.set_default_project(original_default) + config_manager.set_default_project(original_default) # Test removing the project - project_service.config_manager.remove_project(test_project_name) + config_manager.remove_project(test_project_name) assert test_project_name not in project_service.projects except Exception as e: # Clean up in case of error if test_project_name in project_service.projects: try: - project_service.config_manager.remove_project(test_project_name) + config_manager.remove_project(test_project_name) except Exception: pass raise e @@ -130,11 +130,6 @@ async def test_get_statistics(project_service: ProjectService, test_graph): assert statistics.total_entities > 0 assert "test" in statistics.entity_types - # Test with no repository - temp_service = ProjectService() # No repository provided - with pytest.raises(ValueError, match="Repository is required for get_statistics"): - await temp_service.get_statistics() - @pytest.mark.asyncio async def test_get_activity_metrics(project_service: ProjectService, test_graph): @@ -147,11 +142,6 @@ async def test_get_activity_metrics(project_service: ProjectService, test_graph) assert len(metrics.recently_created) > 0 assert len(metrics.recently_updated) > 0 - # Test with no repository - temp_service = ProjectService() # No repository provided - with pytest.raises(ValueError, match="Repository is required for get_activity_metrics"): - await temp_service.get_activity_metrics() - @pytest.mark.asyncio async def test_get_project_info(project_service: ProjectService, test_graph): @@ -169,11 +159,6 @@ async def test_get_project_info(project_service: ProjectService, test_graph): assert isinstance(info.activity, ActivityMetrics) assert isinstance(info.system, SystemStatus) - # Test with no repository - temp_service = ProjectService() # No repository provided - with pytest.raises(ValueError, match="Repository is required for get_project_info"): - await temp_service.get_project_info() - @pytest.mark.asyncio async def test_add_project_async(project_service: ProjectService, tmp_path): @@ -249,3 +234,68 @@ async def test_set_default_project_async(project_service: ProjectService, tmp_pa # 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_get_project_method(project_service: ProjectService, tmp_path): + """Test the get_project method directly.""" + test_project_name = f"test-get-project-{os.urandom(4).hex()}" + test_project_path = str(tmp_path / "test-get-project") + + # Make sure the test directory exists + os.makedirs(test_project_path, exist_ok=True) + + try: + # Test getting a non-existent project + result = await project_service.get_project("non-existent-project") + assert result is None + + # Add a project + await project_service.add_project(test_project_name, test_project_path) + + # Test getting an existing project + result = await project_service.get_project(test_project_name) + assert result is not None + assert result.name == test_project_name + assert result.path == test_project_path + + finally: + # Clean up + if test_project_name in project_service.projects: + await project_service.remove_project(test_project_name) + + +@pytest.mark.asyncio +async def test_set_default_project_config_db_mismatch(project_service: ProjectService, config_manager: ConfigManager, tmp_path): + """Test set_default_project when project exists in config but not in database.""" + test_project_name = f"test-mismatch-project-{os.urandom(4).hex()}" + test_project_path = str(tmp_path / "test-mismatch-project") + + # Make sure the test directory exists + os.makedirs(test_project_path, exist_ok=True) + + original_default = project_service.default_project + + try: + # Add project to config only (not to database) + config_manager.add_project(test_project_name, test_project_path) + + # Verify it's in config but not in database + assert test_project_name in project_service.projects + db_project = await project_service.repository.get_by_name(test_project_name) + assert db_project is None + + # Try to set as default - this should trigger the error log on line 142 + await project_service.set_default_project(test_project_name) + + # Should still update config despite database mismatch + assert project_service.default_project == test_project_name + + finally: + # Restore original default + if original_default: + config_manager.set_default_project(original_default) + + # Clean up + if test_project_name in project_service.projects: + config_manager.remove_project(test_project_name) \ No newline at end of file diff --git a/tests/services/test_project_service_operations.py b/tests/services/test_project_service_operations.py index c30560abf..69f7ef377 100644 --- a/tests/services/test_project_service_operations.py +++ b/tests/services/test_project_service_operations.py @@ -44,7 +44,7 @@ async def test_get_project_from_database(project_service: ProjectService, tmp_pa @pytest.mark.asyncio -async def test_add_project_to_config(project_service: ProjectService, tmp_path): +async def test_add_project_to_config(project_service: ProjectService, tmp_path, config_manager): """Test adding a project to the config manager.""" # Generate unique project name for testing test_project_name = f"config-project-{os.urandom(4).hex()}" @@ -55,7 +55,7 @@ async def test_add_project_to_config(project_service: ProjectService, tmp_path): try: # Add a project to config only (using ConfigManager directly) - project_service.config_manager.add_project(test_project_name, test_path) + config_manager.add_project(test_project_name, test_path) # Verify it's in the config assert test_project_name in project_service.projects @@ -64,11 +64,11 @@ async def test_add_project_to_config(project_service: ProjectService, tmp_path): finally: # Clean up if test_project_name in project_service.projects: - project_service.config_manager.remove_project(test_project_name) + config_manager.remove_project(test_project_name) @pytest.mark.asyncio -async def test_update_project_path(project_service: ProjectService, tmp_path): +async def test_update_project_path(project_service: ProjectService, tmp_path, config_manager): """Test updating a project's path.""" # Create a test project test_project = f"path-update-test-project-{os.urandom(4).hex()}" @@ -99,7 +99,7 @@ async def test_update_project_path(project_service: ProjectService, tmp_path): project = await project_service.repository.get_by_name(test_project) if project: await project_service.repository.delete(project.id) - project_service.config_manager.remove_project(test_project) + config_manager.remove_project(test_project) except Exception: pass diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index 1c3d1811b..91e732bef 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -198,6 +198,50 @@ async def test_search_entity_type(search_service, test_graph): assert r.type == SearchItemType.ENTITY +@pytest.mark.asyncio +async def test_extract_entity_tags_exception_handling(search_service): + """Test the _extract_entity_tags method exception handling (lines 147-151).""" + from basic_memory.models.knowledge import Entity + + # Create entity with string tags that will cause parsing to fail and fall back to single tag + entity_with_invalid_tags = Entity( + title="Test Entity", + entity_type="test", + entity_metadata={"tags": "just a string"}, # This will fail ast.literal_eval + content_type="text/markdown", + file_path="test/test-entity.md", + project_id=1 + ) + + # This should trigger the except block on lines 147-149 + result = search_service._extract_entity_tags(entity_with_invalid_tags) + assert result == ['just a string'] + + # Test with empty string (should return empty list) - covers line 149 + entity_with_empty_tags = Entity( + title="Test Entity Empty", + entity_type="test", + entity_metadata={"tags": ""}, + content_type="text/markdown", + file_path="test/test-entity-empty.md", + project_id=1 + ) + + result = search_service._extract_entity_tags(entity_with_empty_tags) + assert result == [] + + +@pytest.mark.asyncio +async def test_delete_entity_without_permalink(search_service, sample_entity): + """Test deleting an entity that has no permalink (edge case).""" + + # Set the entity permalink to None to trigger the else branch on line 355 + sample_entity.permalink = None + + # This should trigger the delete_by_entity_id path (line 355) in handle_delete + await search_service.handle_delete(sample_entity) + + @pytest.mark.asyncio async def test_no_criteria(search_service, test_graph): """Test search with no criteria returns empty list.""" @@ -343,3 +387,357 @@ async def test_boolean_operators_detection(search_service): assert not query.has_boolean_operators(), ( f"Incorrectly detected boolean operators in: {query_text}" ) + + +# Tests for frontmatter tag search functionality + + +@pytest.mark.asyncio +async def test_extract_entity_tags_list_format(search_service, session_maker): + """Test tag extraction from list format in entity metadata.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata={"tags": ["business", "strategy", "planning"]}, + content_type="text/markdown", + file_path="test/business-strategy.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == ["business", "strategy", "planning"] + + +@pytest.mark.asyncio +async def test_extract_entity_tags_string_format(search_service, session_maker): + """Test tag extraction from string format in entity metadata.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata={"tags": "['documentation', 'tools', 'best-practices']"}, + content_type="text/markdown", + file_path="test/docs.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == ["documentation", "tools", "best-practices"] + + +@pytest.mark.asyncio +async def test_extract_entity_tags_empty_list(search_service, session_maker): + """Test tag extraction from empty list in entity metadata.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata={"tags": []}, + content_type="text/markdown", + file_path="test/empty-tags.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == [] + + +@pytest.mark.asyncio +async def test_extract_entity_tags_empty_string(search_service, session_maker): + """Test tag extraction from empty string in entity metadata.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata={"tags": "[]"}, + content_type="text/markdown", + file_path="test/empty-string-tags.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == [] + + +@pytest.mark.asyncio +async def test_extract_entity_tags_no_metadata(search_service, session_maker): + """Test tag extraction when entity has no metadata.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata=None, + content_type="text/markdown", + file_path="test/no-metadata.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == [] + + +@pytest.mark.asyncio +async def test_extract_entity_tags_no_tags_key(search_service, session_maker): + """Test tag extraction when metadata exists but has no tags key.""" + from basic_memory.models import Entity + + entity = Entity( + title="Test Entity", + entity_type="note", + entity_metadata={"title": "Some Title", "type": "note"}, + content_type="text/markdown", + file_path="test/no-tags-key.md", + project_id=1, + ) + + tags = search_service._extract_entity_tags(entity) + assert tags == [] + + +@pytest.mark.asyncio +async def test_search_by_frontmatter_tags(search_service, session_maker, test_project): + """Test that entities can be found by searching for their frontmatter tags.""" + from basic_memory.repository import EntityRepository + from unittest.mock import AsyncMock + + entity_repo = EntityRepository(session_maker, project_id=test_project.id) + + # Create entity with tags + from datetime import datetime + + entity_data = { + "title": "Business Strategy Guide", + "entity_type": "note", + "entity_metadata": {"tags": ["business", "strategy", "planning", "organization"]}, + "content_type": "text/markdown", + "file_path": "guides/business-strategy.md", + "permalink": "guides/business-strategy", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + entity = await entity_repo.create(entity_data) + + # Mock file service to avoid file I/O + search_service.file_service.read_entity_content = AsyncMock(return_value="") + + await search_service.index_entity(entity) + + # Search for entities by tag + results = await search_service.search(SearchQuery(text="business")) + assert len(results) >= 1 + + # Check that our entity is in the results + entity_found = False + for result in results: + if result.title == "Business Strategy Guide": + entity_found = True + break + assert entity_found, "Entity with 'business' tag should be found in search results" + + # Test searching by another tag + results = await search_service.search(SearchQuery(text="planning")) + assert len(results) >= 1 + + entity_found = False + for result in results: + if result.title == "Business Strategy Guide": + entity_found = True + break + assert entity_found, "Entity with 'planning' tag should be found in search results" + + +@pytest.mark.asyncio +async def test_search_by_frontmatter_tags_string_format( + search_service, session_maker, test_project +): + """Test that entities with string format tags can be found in search.""" + from basic_memory.repository import EntityRepository + from unittest.mock import AsyncMock + + entity_repo = EntityRepository(session_maker, project_id=test_project.id) + + # Create entity with tags in string format + from datetime import datetime + + entity_data = { + "title": "Documentation Guidelines", + "entity_type": "note", + "entity_metadata": {"tags": "['documentation', 'tools', 'best-practices']"}, + "content_type": "text/markdown", + "file_path": "guides/documentation.md", + "permalink": "guides/documentation", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + entity = await entity_repo.create(entity_data) + + # Mock file service to avoid file I/O + search_service.file_service.read_entity_content = AsyncMock(return_value="") + + await search_service.index_entity(entity) + + # Search for entities by tag + results = await search_service.search(SearchQuery(text="documentation")) + assert len(results) >= 1 + + # Check that our entity is in the results + entity_found = False + for result in results: + if result.title == "Documentation Guidelines": + entity_found = True + break + assert entity_found, "Entity with 'documentation' tag should be found in search results" + + +@pytest.mark.asyncio +async def test_search_special_characters_in_title(search_service, session_maker, test_project): + """Test that entities with special characters in titles can be searched without FTS5 syntax errors.""" + from basic_memory.repository import EntityRepository + from unittest.mock import AsyncMock + + entity_repo = EntityRepository(session_maker, project_id=test_project.id) + + # Create entities with special characters that could cause FTS5 syntax errors + special_titles = [ + "Note with spaces", + "Note-with-dashes", + "Note_with_underscores", + "Note (with parentheses)", # This is the problematic one + "Note & Symbols!", + "Note [with brackets]", + "Note {with braces}", + 'Note "with quotes"', + "Note 'with apostrophes'", + ] + + entities = [] + for i, title in enumerate(special_titles): + from datetime import datetime + + entity_data = { + "title": title, + "entity_type": "note", + "entity_metadata": {"tags": ["special", "characters"]}, + "content_type": "text/markdown", + "file_path": f"special/{title}.md", + "permalink": f"special/note-{i}", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + entity = await entity_repo.create(entity_data) + entities.append(entity) + + # Mock file service to avoid file I/O + search_service.file_service.read_entity_content = AsyncMock(return_value="") + + # Index all entities + for entity in entities: + await search_service.index_entity(entity) + + # Test searching for each title - this should not cause FTS5 syntax errors + for title in special_titles: + results = await search_service.search(SearchQuery(title=title)) + + # Should find the entity without throwing FTS5 syntax errors + entity_found = False + for result in results: + if result.title == title: + entity_found = True + break + + assert entity_found, f"Entity with title '{title}' should be found in search results" + + +@pytest.mark.asyncio +async def test_search_title_with_parentheses_specific(search_service, session_maker, test_project): + """Test searching specifically for title with parentheses to reproduce FTS5 error.""" + from basic_memory.repository import EntityRepository + from unittest.mock import AsyncMock + + entity_repo = EntityRepository(session_maker, project_id=test_project.id) + + # Create the problematic entity + from datetime import datetime + + entity_data = { + "title": "Note (with parentheses)", + "entity_type": "note", + "entity_metadata": {"tags": ["test"]}, + "content_type": "text/markdown", + "file_path": "special/Note (with parentheses).md", + "permalink": "special/note-with-parentheses", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + entity = await entity_repo.create(entity_data) + + # Mock file service to avoid file I/O + search_service.file_service.read_entity_content = AsyncMock(return_value="") + + # Index the entity + await search_service.index_entity(entity) + + # Test searching for the title - this should not cause FTS5 syntax errors + search_query = SearchQuery(title="Note (with parentheses)") + results = await search_service.search(search_query) + + # Should find the entity without throwing FTS5 syntax errors + assert len(results) >= 1 + assert any(result.title == "Note (with parentheses)" for result in results) + + +@pytest.mark.asyncio +async def test_search_title_via_repository_direct(search_service, session_maker, test_project): + """Test searching via search repository directly to isolate the FTS5 error.""" + from basic_memory.repository import EntityRepository + from unittest.mock import AsyncMock + + entity_repo = EntityRepository(session_maker, project_id=test_project.id) + + # Create the problematic entity + from datetime import datetime + + entity_data = { + "title": "Note (with parentheses)", + "entity_type": "note", + "entity_metadata": {"tags": ["test"]}, + "content_type": "text/markdown", + "file_path": "special/Note (with parentheses).md", + "permalink": "special/note-with-parentheses", + "project_id": test_project.id, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + entity = await entity_repo.create(entity_data) + + # Mock file service to avoid file I/O + search_service.file_service.read_entity_content = AsyncMock(return_value="") + + # Index the entity + await search_service.index_entity(entity) + + # Test searching via repository directly - this reproduces the error path + results = await search_service.repository.search( + title="Note (with parentheses)", + limit=10, + offset=0, + ) + + # Should find the entity without throwing FTS5 syntax errors + assert len(results) >= 1 + assert any(result.title == "Note (with parentheses)" for result in results) diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 2fe7a1a14..f490c26bf 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -25,11 +25,11 @@ async def create_test_file(path: Path, content: str = "test content") -> None: @pytest.mark.asyncio async def test_forward_reference_resolution( sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, entity_service: EntityService, ): """Test that forward references get resolved when target file is created.""" - project_dir = test_config.home + project_dir = project_config.home # First create a file with a forward reference source_content = """ @@ -45,7 +45,7 @@ async def test_forward_reference_resolution( await create_test_file(project_dir / "source.md", source_content) # Initial sync - should create forward reference - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify forward reference source = await entity_service.get_by_permalink("source") @@ -64,7 +64,7 @@ async def test_forward_reference_resolution( await create_test_file(project_dir / "target_doc.md", target_content) # Sync again - should resolve the reference - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify reference is now resolved source = await entity_service.get_by_permalink("source") @@ -76,11 +76,11 @@ async def test_forward_reference_resolution( @pytest.mark.asyncio async def test_sync( - sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService + sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService ): """Test basic knowledge sync functionality.""" # Create test files - project_dir = test_config.home + project_dir = project_config.home # New entity with relation new_content = """ @@ -117,7 +117,7 @@ async def test_sync( await entity_service.repository.add(other) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify results entities = await entity_service.repository.find_all() @@ -137,17 +137,17 @@ async def test_sync( @pytest.mark.asyncio async def test_sync_hidden_file( - sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService + sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService ): """Test basic knowledge sync functionality.""" # Create test files - project_dir = test_config.home + project_dir = project_config.home # hidden file await create_test_file(project_dir / "concept/.hidden.md", "hidden") # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify results entities = await entity_service.repository.find_all() @@ -156,10 +156,10 @@ async def test_sync_hidden_file( @pytest.mark.asyncio async def test_sync_entity_with_nonexistent_relations( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test syncing an entity that references nonexistent entities.""" - project_dir = test_config.home + project_dir = project_config.home # Create entity that references entities we haven't created yet content = """ @@ -181,7 +181,7 @@ async def test_sync_entity_with_nonexistent_relations( await create_test_file(project_dir / "concept/depends_on_future.md", content) # Sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify entity created but no relations entity = await sync_service.entity_service.repository.get_by_permalink( @@ -195,10 +195,10 @@ async def test_sync_entity_with_nonexistent_relations( @pytest.mark.asyncio async def test_sync_entity_circular_relations( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test syncing entities with circular dependencies.""" - project_dir = test_config.home + project_dir = project_config.home # Create entity A that depends on B content_a = """ @@ -237,7 +237,7 @@ async def test_sync_entity_circular_relations( await create_test_file(project_dir / "concept/entity_b.md", content_b) # Sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify both entities and their relations entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a") @@ -265,10 +265,10 @@ async def test_sync_entity_circular_relations( @pytest.mark.asyncio async def test_sync_entity_duplicate_relations( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test handling of duplicate relations in an entity.""" - project_dir = test_config.home + project_dir = project_config.home # Create target entity first target_content = """ @@ -308,7 +308,7 @@ async def test_sync_entity_duplicate_relations( await create_test_file(project_dir / "concept/duplicate_relations.md", content) # Sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify duplicates are handled entity = await sync_service.entity_service.repository.get_by_permalink( @@ -327,10 +327,10 @@ async def test_sync_entity_duplicate_relations( @pytest.mark.asyncio async def test_sync_entity_with_random_categories( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test handling of random observation categories.""" - project_dir = test_config.home + project_dir = project_config.home content = """ --- @@ -350,7 +350,7 @@ async def test_sync_entity_with_random_categories( await create_test_file(project_dir / "concept/invalid_category.md", content) # Sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify observations entity = await sync_service.entity_service.repository.get_by_permalink( @@ -369,10 +369,10 @@ async def test_sync_entity_with_random_categories( @pytest.mark.asyncio async def test_sync_entity_with_order_dependent_relations( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test that order of entity syncing doesn't affect relation creation.""" - project_dir = test_config.home + project_dir = project_config.home # Create several interrelated entities entities = { @@ -430,39 +430,56 @@ async def test_sync_entity_with_order_dependent_relations( await create_test_file(project_dir / f"concept/entity_{name}.md", content) # Sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify all relations are created correctly regardless of order entity_a = await sync_service.entity_service.repository.get_by_permalink("concept/entity-a") entity_b = await sync_service.entity_service.repository.get_by_permalink("concept/entity-b") entity_c = await sync_service.entity_service.repository.get_by_permalink("concept/entity-c") - assert len(entity_a.outgoing_relations) == 2 # Should depend on B and C - assert len(entity_a.incoming_relations) == 1 # C depends on A - - assert len(entity_b.outgoing_relations) == 1 # Should depend on C - assert len(entity_b.incoming_relations) == 1 # A depends on B - - assert len(entity_c.outgoing_relations) == 1 # Should depend on A - assert len(entity_c.incoming_relations) == 2 # A and B depend on C + # Verify outgoing relations by checking actual targets + a_outgoing_targets = {rel.to_id for rel in entity_a.outgoing_relations} + assert entity_b.id in a_outgoing_targets, f"A should depend on B. A's targets: {a_outgoing_targets}, B's ID: {entity_b.id}" + assert entity_c.id in a_outgoing_targets, f"A should depend on C. A's targets: {a_outgoing_targets}, C's ID: {entity_c.id}" + assert len(entity_a.outgoing_relations) == 2, "A should have exactly 2 outgoing relations" + + b_outgoing_targets = {rel.to_id for rel in entity_b.outgoing_relations} + assert entity_c.id in b_outgoing_targets, "B should depend on C" + assert len(entity_b.outgoing_relations) == 1, "B should have exactly 1 outgoing relation" + + c_outgoing_targets = {rel.to_id for rel in entity_c.outgoing_relations} + assert entity_a.id in c_outgoing_targets, "C should depend on A" + assert len(entity_c.outgoing_relations) == 1, "C should have exactly 1 outgoing relation" + + # Verify incoming relations by checking actual sources + a_incoming_sources = {rel.from_id for rel in entity_a.incoming_relations} + assert entity_c.id in a_incoming_sources, "A should have incoming relation from C" + + b_incoming_sources = {rel.from_id for rel in entity_b.incoming_relations} + assert entity_a.id in b_incoming_sources, "B should have incoming relation from A" + + c_incoming_sources = {rel.from_id for rel in entity_c.incoming_relations} + assert entity_a.id in c_incoming_sources, "C should have incoming relation from A" + assert entity_b.id in c_incoming_sources, "C should have incoming relation from B" + @pytest.mark.asyncio -async def test_sync_empty_directories(sync_service: SyncService, test_config: ProjectConfig): +async def test_sync_empty_directories(sync_service: SyncService, project_config: ProjectConfig): """Test syncing empty directories.""" - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Should not raise exceptions for empty dirs - assert (test_config.home).exists() + assert (project_config.home).exists() @pytest.mark.asyncio async def test_sync_file_modified_during_sync( - sync_service: SyncService, test_config: ProjectConfig + sync_service: SyncService, project_config: ProjectConfig ): """Test handling of files that change during sync process.""" # Create initial files - doc_path = test_config.home / "changing.md" + doc_path = project_config.home / "changing.md" await create_test_file( doc_path, """ @@ -485,7 +502,7 @@ async def modify_file(): doc_path.write_text("Modified during sync") # Run sync and modification concurrently - await asyncio.gather(sync_service.sync(test_config.home), modify_file()) + await asyncio.gather(sync_service.sync(project_config.home), modify_file()) # Verify final state doc = await sync_service.entity_service.repository.get_by_permalink("changing") @@ -493,14 +510,14 @@ async def modify_file(): # if we failed in the middle of a sync, the next one should fix it. if doc.checksum is None: - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) doc = await sync_service.entity_service.repository.get_by_permalink("changing") assert doc.checksum is not None @pytest.mark.asyncio async def test_permalink_formatting( - sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService + sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService ): """Test that permalinks are properly formatted during sync.""" @@ -526,10 +543,10 @@ async def test_permalink_formatting( Testing permalink generation. """ - await create_test_file(test_config.home / filename, content) + await create_test_file(project_config.home / filename, content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify permalinks entities = await entity_service.repository.find_all() @@ -545,7 +562,7 @@ async def test_permalink_formatting( async def test_handle_entity_deletion( test_graph, sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, entity_repository: EntityRepository, search_service: SearchService, ): @@ -572,11 +589,11 @@ async def test_handle_entity_deletion( @pytest.mark.asyncio async def test_sync_preserves_timestamps( sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, entity_service: EntityService, ): """Test that sync preserves file timestamps and frontmatter dates.""" - project_dir = test_config.home + project_dir = project_config.home # Create a file with explicit frontmatter dates frontmatter_content = """ @@ -600,7 +617,7 @@ async def test_sync_preserves_timestamps( await create_test_file(file_path, file_dates_content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check explicit frontmatter dates explicit_entity = await entity_service.get_by_permalink("explicit-dates") @@ -621,11 +638,11 @@ async def test_sync_preserves_timestamps( @pytest.mark.asyncio async def test_file_move_updates_search_index( sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, search_service: SearchService, ): """Test that moving a file updates its path in the search index.""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file content = """ @@ -640,7 +657,7 @@ async def test_file_move_updates_search_index( await create_test_file(old_path, content) # Initial sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Move the file new_path = project_dir / "new" / "moved_file.md" @@ -648,7 +665,7 @@ async def test_file_move_updates_search_index( old_path.rename(new_path) # Sync again - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check search index has updated path results = await search_service.search(SearchQuery(text="Content for move test")) @@ -658,7 +675,10 @@ async def test_file_move_updates_search_index( @pytest.mark.asyncio async def test_sync_null_checksum_cleanup( - sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService + sync_service: SyncService, + project_config: ProjectConfig, + entity_service: EntityService, + app_config, ): """Test handling of entities with null checksums from incomplete syncs.""" # Create entity with null checksum (simulating incomplete sync) @@ -687,10 +707,10 @@ async def test_sync_null_checksum_cleanup( ## Observations - Testing cleanup """ - await create_test_file(test_config.home / "concept/incomplete.md", content) + await create_test_file(project_config.home / "concept/incomplete.md", content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify entity was properly synced updated = await entity_service.get_by_permalink("concept/incomplete") @@ -699,12 +719,10 @@ async def test_sync_null_checksum_cleanup( @pytest.mark.asyncio async def test_sync_permalink_resolved( - sync_service: SyncService, - test_config: ProjectConfig, - file_service: FileService, + sync_service: SyncService, project_config: ProjectConfig, file_service: FileService, app_config ): """Test that we resolve duplicate permalinks on sync .""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file content = """ @@ -719,7 +737,7 @@ async def test_sync_permalink_resolved( await create_test_file(old_path, content) # Initial sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Move the file new_path = project_dir / "new" / "moved_file.md" @@ -727,16 +745,16 @@ async def test_sync_permalink_resolved( old_path.rename(new_path) # Sync again - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) file_content, _ = await file_service.read_file(new_path) - assert "permalink: old/test-move" in file_content + assert "permalink: new/moved-file" in file_content # Create another that has the same permalink content = """ --- type: knowledge -permalink: old/test-move +permalink: new/moved-file --- # Test Move Content for move test @@ -746,21 +764,21 @@ async def test_sync_permalink_resolved( await create_test_file(old_path, content) # Sync new file - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # assert permalink is unique file_content, _ = await file_service.read_file(old_path) - assert "permalink: old/test-move-1" in file_content + assert "permalink: new/moved-file-1" in file_content @pytest.mark.asyncio async def test_sync_permalink_resolved_on_update( sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, file_service: FileService, ): """Test that sync resolves permalink conflicts on update.""" - project_dir = test_config.home + project_dir = project_config.home one_file = project_dir / "one.md" two_file = project_dir / "two.md" @@ -788,7 +806,7 @@ async def test_sync_permalink_resolved_on_update( ) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check permalinks file_one_content, _ = await file_service.read_file(one_file) @@ -811,7 +829,7 @@ async def test_sync_permalink_resolved_on_update( two_file.write_text(updated_content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check permalinks file_two_content, _ = await file_service.read_file(two_file) @@ -832,7 +850,7 @@ async def test_sync_permalink_resolved_on_update( await create_test_file(new_file, new_content) # Run another time - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Should have deduplicated permalink new_file_content, _ = await file_service.read_file(new_file) @@ -842,17 +860,17 @@ async def test_sync_permalink_resolved_on_update( @pytest.mark.asyncio async def test_sync_permalink_not_created_if_no_frontmatter( sync_service: SyncService, - test_config: ProjectConfig, + project_config: ProjectConfig, file_service: FileService, ): """Test that sync resolves permalink conflicts on update.""" - project_dir = test_config.home + project_dir = project_config.home file = project_dir / "one.md" await create_test_file(file) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check permalink not created file_content, _ = await file_service.read_file(file) @@ -869,13 +887,13 @@ def test_config_update_permamlinks_on_move(app_config) -> BasicMemoryConfig: @pytest.mark.asyncio async def test_sync_permalink_updated_on_move( test_config_update_permamlinks_on_move: BasicMemoryConfig, - test_config: ProjectConfig, + project_config: ProjectConfig, sync_service: SyncService, file_service: FileService, ): """Test that we update a permalink on a file move if set in config .""" - project_dir = test_config.home - sync_service.project_config = test_config + project_dir = project_config.home + sync_service.project_config = project_config # Create initial file content = dedent( @@ -893,7 +911,7 @@ async def test_sync_permalink_updated_on_move( await create_test_file(old_path, content) # Initial sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # verify permalink old_content, _ = await file_service.read_file(old_path) @@ -905,16 +923,16 @@ async def test_sync_permalink_updated_on_move( old_path.rename(new_path) # Sync again - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) file_content, _ = await file_service.read_file(new_path) assert "permalink: new/moved-file" in file_content @pytest.mark.asyncio -async def test_sync_non_markdown_files(sync_service, test_config, test_files): +async def test_sync_non_markdown_files(sync_service, project_config, test_files): """Test syncing non-markdown files.""" - report = await sync_service.sync(test_config.home) + report = await sync_service.sync(project_config.home) assert report.total == 2 # Check files were detected @@ -934,10 +952,10 @@ async def test_sync_non_markdown_files(sync_service, test_config, test_files): @pytest.mark.asyncio async def test_sync_non_markdown_files_modified( - sync_service, test_config, test_files, file_service + sync_service, project_config, test_files, file_service ): """Test syncing non-markdown files.""" - report = await sync_service.sync(test_config.home) + report = await sync_service.sync(project_config.home) assert report.total == 2 # Check files were detected @@ -947,7 +965,7 @@ async def test_sync_non_markdown_files_modified( test_files["pdf"].write_text("New content") test_files["image"].write_text("New content") - report = await sync_service.sync(test_config.home) + report = await sync_service.sync(project_config.home) assert len(report.modified) == 2 pdf_file_content, pdf_checksum = await file_service.read_file(test_files["pdf"].name) @@ -963,17 +981,17 @@ async def test_sync_non_markdown_files_modified( @pytest.mark.asyncio -async def test_sync_non_markdown_files_move(sync_service, test_config, test_files): +async def test_sync_non_markdown_files_move(sync_service, project_config, test_files): """Test syncing non-markdown files updates permalink""" - report = await sync_service.sync(test_config.home) + report = await sync_service.sync(project_config.home) assert report.total == 2 # Check files were detected assert test_files["pdf"].name in [f for f in report.new] assert test_files["image"].name in [f for f in report.new] - test_files["pdf"].rename(test_config.home / "moved_pdf.pdf") - report2 = await sync_service.sync(test_config.home) + test_files["pdf"].rename(project_config.home / "moved_pdf.pdf") + report2 = await sync_service.sync(project_config.home) assert len(report2.moves) == 1 # Verify entity is updated @@ -983,9 +1001,9 @@ async def test_sync_non_markdown_files_move(sync_service, test_config, test_file @pytest.mark.asyncio -async def test_sync_non_markdown_files_deleted(sync_service, test_config, test_files): +async def test_sync_non_markdown_files_deleted(sync_service, project_config, test_files): """Test syncing non-markdown files updates permalink""" - report = await sync_service.sync(test_config.home) + report = await sync_service.sync(project_config.home) assert report.total == 2 # Check files were detected @@ -993,7 +1011,7 @@ async def test_sync_non_markdown_files_deleted(sync_service, test_config, test_f assert test_files["image"].name in [f for f in report.new] test_files["pdf"].unlink() - report2 = await sync_service.sync(test_config.home) + report2 = await sync_service.sync(project_config.home) assert len(report2.deleted) == 1 # Verify entity is deleted @@ -1003,23 +1021,23 @@ async def test_sync_non_markdown_files_deleted(sync_service, test_config, test_f @pytest.mark.asyncio async def test_sync_non_markdown_files_move_with_delete( - sync_service, test_config, test_files, file_service + sync_service, project_config, test_files, file_service ): """Test syncing non-markdown files handles file deletes and renames during sync""" # Create initial files - await create_test_file(test_config.home / "doc.pdf", "content1") - await create_test_file(test_config.home / "other/doc-1.pdf", "content2") + await create_test_file(project_config.home / "doc.pdf", "content1") + await create_test_file(project_config.home / "other/doc-1.pdf", "content2") # Initial sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # First move/delete the original file to make way for the move - (test_config.home / "doc.pdf").unlink() - (test_config.home / "other/doc-1.pdf").rename(test_config.home / "doc.pdf") + (project_config.home / "doc.pdf").unlink() + (project_config.home / "other/doc-1.pdf").rename(project_config.home / "doc.pdf") # Sync again - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify the changes moved_entity = await sync_service.entity_repository.get_by_file_path("doc.pdf") @@ -1032,10 +1050,10 @@ async def test_sync_non_markdown_files_move_with_delete( @pytest.mark.asyncio async def test_sync_relation_to_non_markdown_file( - sync_service: SyncService, test_config: ProjectConfig, file_service: FileService, test_files + sync_service: SyncService, project_config: ProjectConfig, file_service: FileService, test_files ): """Test that sync resolves permalink conflicts on update.""" - project_dir = test_config.home + project_dir = project_config.home content = f""" --- @@ -1051,7 +1069,7 @@ async def test_sync_relation_to_non_markdown_file( await create_test_file(note_file, content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Check permalinks file_one_content, _ = await file_service.read_file(note_file) diff --git a/tests/sync/test_sync_wikilink_issue.py b/tests/sync/test_sync_wikilink_issue.py index a0f28e3d5..4d90e9a01 100644 --- a/tests/sync/test_sync_wikilink_issue.py +++ b/tests/sync/test_sync_wikilink_issue.py @@ -14,9 +14,9 @@ async def create_test_file(path: Path, content: str) -> None: @pytest.mark.asyncio -async def test_wikilink_modified_status_issue(sync_service: SyncService, test_config): +async def test_wikilink_modified_status_issue(sync_service: SyncService, project_config): """Test that files with wikilinks don't remain in modified status after sync.""" - project_dir = test_config.home + project_dir = project_config.home # Create a file with a wikilink content = """--- @@ -31,12 +31,12 @@ async def test_wikilink_modified_status_issue(sync_service: SyncService, test_co await create_test_file(test_file_path, content) # Initial sync - report1 = await sync_service.sync(test_config.home) + report1 = await sync_service.sync(project_config.home) assert "test_wikilink.md" in report1.new assert "test_wikilink.md" not in report1.modified # Sync again without changing the file - should not be modified - report2 = await sync_service.sync(test_config.home) + report2 = await sync_service.sync(project_config.home) assert "test_wikilink.md" not in report2.new assert "test_wikilink.md" not in report2.modified @@ -53,11 +53,11 @@ async def test_wikilink_modified_status_issue(sync_service: SyncService, test_co await create_test_file(target_file_path, target_content) # Sync again after adding target file - report3 = await sync_service.sync(test_config.home) + report3 = await sync_service.sync(project_config.home) assert "another_file.md" in report3.new assert "test_wikilink.md" not in report3.modified # Sync one more time - both files should now be stable - report4 = await sync_service.sync(test_config.home) + report4 = await sync_service.sync(project_config.home) assert "test_wikilink.md" not in report4.modified assert "another_file.md" not in report4.modified diff --git a/tests/sync/test_tmp_files.py b/tests/sync/test_tmp_files.py index 675d73302..2ec7168d3 100644 --- a/tests/sync/test_tmp_files.py +++ b/tests/sync/test_tmp_files.py @@ -14,7 +14,7 @@ async def create_test_file(path: Path, content: str = "test content") -> None: @pytest.mark.asyncio -async def test_temp_file_filter(watch_service, app_config, test_config, test_project): +async def test_temp_file_filter(watch_service, app_config, project_config, test_project): """Test that .tmp files are correctly filtered out.""" # Test filter_changes method directly tmp_path = Path(test_project.path) / "test.tmp" @@ -26,7 +26,7 @@ async def test_temp_file_filter(watch_service, app_config, test_config, test_pro @pytest.mark.asyncio -async def test_handle_tmp_files(watch_service, test_config, test_project, sync_service): +async def test_handle_tmp_files(watch_service, project_config, test_project, sync_service): """Test handling of .tmp files during sync process.""" project_dir = Path(test_project.path) @@ -57,10 +57,10 @@ async def test_handle_tmp_files(watch_service, test_config, test_project, sync_s @pytest.mark.asyncio async def test_atomic_write_tmp_file_handling( - watch_service, test_config, test_project, sync_service + watch_service, project_config, test_project, sync_service ): """Test handling of file changes during atomic write operations.""" - project_dir = test_config.home + project_dir = project_config.home # This test simulates the full atomic write process: # 1. First a .tmp file is created @@ -103,7 +103,7 @@ async def test_atomic_write_tmp_file_handling( @pytest.mark.asyncio -async def test_rapid_atomic_writes(watch_service, test_config, test_project, sync_service): +async def test_rapid_atomic_writes(watch_service, project_config, test_project, sync_service): """Test handling of rapid atomic writes to the same destination.""" project_dir = Path(test_project.path) diff --git a/tests/sync/test_watch_service.py b/tests/sync/test_watch_service.py index 6e2fadb27..e70ca87b8 100644 --- a/tests/sync/test_watch_service.py +++ b/tests/sync/test_watch_service.py @@ -18,12 +18,12 @@ async def create_test_file(path: Path, content: str = "test content") -> None: @pytest.fixture -def watch_service(sync_service, file_service, test_config): +def watch_service(sync_service, file_service, project_config): """Create watch service instance.""" - return WatchService(sync_service, file_service, test_config) + return WatchService(sync_service, file_service, project_config) -def test_watch_service_init(watch_service, test_config): +def test_watch_service_init(watch_service, project_config): """Test watch service initialization.""" assert watch_service.status_path.parent.exists() @@ -70,9 +70,9 @@ async def test_write_status(watch_service): @pytest.mark.asyncio -async def test_handle_file_add(watch_service, test_config, test_project, entity_repository): +async def test_handle_file_add(watch_service, project_config, test_project, entity_repository): """Test handling new file creation.""" - project_dir = test_config.home + project_dir = project_config.home # empty dir is ignored empty_dir = project_dir / "empty_dir" @@ -107,9 +107,9 @@ async def test_handle_file_add(watch_service, test_config, test_project, entity_ @pytest.mark.asyncio -async def test_handle_file_modify(watch_service, test_config, sync_service, test_project): +async def test_handle_file_modify(watch_service, project_config, sync_service, test_project): """Test handling file modifications.""" - project_dir = test_config.home + project_dir = project_config.home # empty dir is ignored empty_dir = project_dir / "empty_dir" @@ -155,9 +155,9 @@ async def test_handle_file_modify(watch_service, test_config, sync_service, test @pytest.mark.asyncio -async def test_handle_file_delete(watch_service, test_config, test_project, sync_service): +async def test_handle_file_delete(watch_service, project_config, test_project, sync_service): """Test handling file deletion.""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file test_file = project_dir / "to_delete.md" @@ -193,9 +193,9 @@ async def test_handle_file_delete(watch_service, test_config, test_project, sync @pytest.mark.asyncio -async def test_handle_file_move(watch_service, test_config, test_project, sync_service): +async def test_handle_file_move(watch_service, project_config, test_project, sync_service): """Test handling file moves.""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file old_path = project_dir / "old" / "test_move.md" @@ -239,9 +239,9 @@ async def test_handle_file_move(watch_service, test_config, test_project, sync_s @pytest.mark.asyncio -async def test_handle_concurrent_changes(watch_service, test_config, test_project, sync_service): +async def test_handle_concurrent_changes(watch_service, project_config, test_project, sync_service): """Test handling multiple file changes happening close together.""" - project_dir = test_config.home + project_dir = project_config.home # Create multiple files with small delays to simulate concurrent changes async def create_files(): @@ -288,9 +288,9 @@ async def create_files(): @pytest.mark.asyncio -async def test_handle_rapid_move(watch_service, test_config, test_project, sync_service): +async def test_handle_rapid_move(watch_service, project_config, test_project, sync_service): """Test handling rapid move operations.""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file original_path = project_dir / "original.md" @@ -334,9 +334,9 @@ async def test_handle_rapid_move(watch_service, test_config, test_project, sync_ @pytest.mark.asyncio -async def test_handle_delete_then_add(watch_service, test_config, test_project, sync_service): +async def test_handle_delete_then_add(watch_service, project_config, test_project, sync_service): """Test handling rapid move operations.""" - project_dir = test_config.home + project_dir = project_config.home # Create initial file original_path = project_dir / "original.md" @@ -363,12 +363,12 @@ async def test_handle_delete_then_add(watch_service, test_config, test_project, @pytest.mark.asyncio -async def test_handle_directory_rename(watch_service, test_config, test_project, sync_service): +async def test_handle_directory_rename(watch_service, project_config, test_project, sync_service): """Test handling directory rename operations - regression test for the bug where directories were being processed as files, causing errors.""" from unittest.mock import AsyncMock - project_dir = test_config.home + project_dir = project_config.home # Create a directory with a file inside old_dir_path = project_dir / "old_dir" diff --git a/tests/sync/test_watch_service_edge_cases.py b/tests/sync/test_watch_service_edge_cases.py index 376b0ea79..b104ee730 100644 --- a/tests/sync/test_watch_service_edge_cases.py +++ b/tests/sync/test_watch_service_edge_cases.py @@ -6,34 +6,35 @@ from watchfiles import Change -def test_filter_changes_valid_path(watch_service, test_config): +def test_filter_changes_valid_path(watch_service, project_config): """Test the filter_changes method with valid non-hidden paths.""" # Regular file path assert ( - watch_service.filter_changes(Change.added, str(test_config.home / "valid_file.txt")) is True + watch_service.filter_changes(Change.added, str(project_config.home / "valid_file.txt")) + is True ) # Nested path assert ( watch_service.filter_changes( - Change.added, str(test_config.home / "nested" / "valid_file.txt") + Change.added, str(project_config.home / "nested" / "valid_file.txt") ) is True ) -def test_filter_changes_hidden_path(watch_service, test_config): +def test_filter_changes_hidden_path(watch_service, project_config): """Test the filter_changes method with hidden files/directories.""" # Hidden file (starts with dot) assert ( - watch_service.filter_changes(Change.added, str(test_config.home / ".hidden_file.txt")) + watch_service.filter_changes(Change.added, str(project_config.home / ".hidden_file.txt")) is False ) # File in hidden directory assert ( watch_service.filter_changes( - Change.added, str(test_config.home / ".hidden_dir" / "file.txt") + Change.added, str(project_config.home / ".hidden_dir" / "file.txt") ) is False ) @@ -41,14 +42,14 @@ def test_filter_changes_hidden_path(watch_service, test_config): # Deeply nested hidden directory assert ( watch_service.filter_changes( - Change.added, str(test_config.home / "valid" / ".hidden" / "file.txt") + Change.added, str(project_config.home / "valid" / ".hidden" / "file.txt") ) is False ) @pytest.mark.asyncio -async def test_handle_changes_empty_set(watch_service, test_config, test_project): +async def test_handle_changes_empty_set(watch_service, project_config, test_project): """Test handle_changes with an empty set (no processed files).""" # Mock write_status to avoid file operations with patch.object(watch_service, "write_status", return_value=None): diff --git a/tests/test_basic_memory.py b/tests/test_basic_memory.py deleted file mode 100644 index 720d4e480..000000000 --- a/tests/test_basic_memory.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tests for basic-memory package""" - -import sys -import tomllib - -import pytest -from frontmatter.default_handlers import toml - -from basic_memory import __version__ -from basic_memory.config import app_config - - -def read_toml_version(file_path): - try: - with open(file_path, "rb") as f: - if sys.version_info >= (3, 11): - data = tomllib.load(f) - else: - data = toml.load(f) - if "project" in data and "version" in data["project"]: - return data["project"]["version"] - else: - return None - except FileNotFoundError: - return None - except (toml.TomlDecodeError, tomllib.TOMLDecodeError): - return None - - -file_path = "pyproject.toml" - - -def test_version(project_root): - """Test version is set in project src code and pyproject.toml""" - version = read_toml_version(project_root / file_path) - assert __version__ == version - - -def test_config_env(): - """Test the config env is set to test for pytest""" - assert app_config.env == "test" - - -@pytest.mark.asyncio -async def test_config_env_async(): - """Test the config env is set to test for async pytest""" - assert app_config.env == "test" diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 746ee44d3..000000000 --- a/tests/test_config.py +++ /dev/null @@ -1,214 +0,0 @@ -# """Tests for the Basic Memory configuration system.""" -# -# from pathlib import Path -# from tempfile import TemporaryDirectory -# -# import pytest -# -# from basic_memory.config import ( -# BasicMemoryConfig, -# ConfigManager, -# DATA_DIR_NAME, -# CONFIG_FILE_NAME, -# APP_DATABASE_NAME, -# get_project_config, -# config_manager as module_config_manager, -# ) -# -# -# class TestBasicMemoryConfig: -# """Test the BasicMemoryConfig pydantic model.""" -# -# def test_default_values(self): -# """Test that default values are set correctly.""" -# config = BasicMemoryConfig() -# assert "main" in config.projects -# assert config.default_project == "main" -# -# def test_model_post_init(self, tmp_path): -# """Test that model_post_init ensures valid configuration.""" -# # Test with empty projects -# config = BasicMemoryConfig(projects={}, default_project="nonexistent") -# assert "main" in config.projects -# assert config.default_project == "main" -# -# # Test with invalid default project -# config = BasicMemoryConfig( -# projects={"project1": f"{tmp_path}/path/to/project1"}, default_project="nonexistent" -# ) -# assert "main" in config.projects -# assert config.default_project == "main" -# -# def test_custom_values(self, tmp_path): -# """Test with custom values.""" -# config = BasicMemoryConfig( -# projects={"project1": f"{tmp_path}/path/to/project1"}, default_project="project1" -# ) -# assert config.projects["project1"] == f"{tmp_path}/path/to/project1" -# assert config.default_project == "project1" -# # Main should still be added automatically -# assert "main" in config.projects -# -# def test_app_database_path(self, monkeypatch): -# """Test that app_database_path property returns the correct path.""" -# with TemporaryDirectory() as tempdir: -# temp_home = Path(tempdir) -# monkeypatch.setattr(Path, "home", lambda: temp_home) -# -# config = BasicMemoryConfig() -# expected_path = temp_home / DATA_DIR_NAME / APP_DATABASE_NAME -# -# # The property should create the directory and touch the file -# assert config.app_database_path == expected_path -# assert expected_path.exists() -# -# # The path should point to the app directory, not project directory -# assert config.app_database_path.parent == temp_home / DATA_DIR_NAME -# -# def test_database_path(self, monkeypatch): -# """Test that database_path returns the app-level database path.""" -# with TemporaryDirectory() as tempdir: -# temp_home = Path(tempdir) -# monkeypatch.setattr(Path, "home", lambda: temp_home) -# -# # Create a test configuration -# app_config = BasicMemoryConfig(env="test") -# -# # The database_path should point to the app-level database -# app_db_path = temp_home / DATA_DIR_NAME / APP_DATABASE_NAME -# assert app_config.database_path == app_db_path -# -# -# class TestConfigManager: -# """Test the ConfigManager class.""" -# -# @pytest.fixture -# def temp_home(self, monkeypatch): -# """Create a temporary directory for testing.""" -# with TemporaryDirectory() as tempdir: -# temp_home = Path(tempdir) -# monkeypatch.setattr(Path, "home", lambda: temp_home) -# yield temp_home -# -# def test_init_creates_config_dir(self, temp_home): -# """Test that init creates the config directory.""" -# config_manager = ConfigManager() -# assert config_manager.config_dir.exists() -# assert config_manager.config_dir == temp_home / ".basic-memory" -# -# def test_init_creates_default_config(self, temp_home): -# """Test that init creates a default config if none exists.""" -# config_manager = ConfigManager() -# assert config_manager.config_file.exists() -# assert "main" in config_manager.projects -# assert config_manager.default_project == "main" -# -# def test_current_project_id(self, temp_home): -# """Test setting and getting current project ID.""" -# config_manager = ConfigManager() -# -# # Set project ID -# project_id = 42 -# config_manager.current_project_id = project_id -# -# # Verify it was set -# assert config_manager.current_project_id == project_id -# -# def test_save_and_load_config(self, temp_home): -# """Test saving and loading configuration.""" -# config_manager = ConfigManager() -# # Add a project -# config_manager.add_project("test", str(temp_home / "test-project")) -# # Set as default -# config_manager.set_default_project("test") -# -# # Create a new manager to load from file -# new_manager = ConfigManager() -# assert "test" in new_manager.projects -# assert new_manager.default_project == "test" -# assert Path(new_manager.projects["test"]) == temp_home / "test-project" -# -# def test_get_project_path(self, temp_home): -# """Test getting a project path.""" -# config_manager = ConfigManager() -# config_manager.add_project("test", str(temp_home / "test-project")) -# -# # Get by name -# path = config_manager.config.get_project_path(project_name="test") -# assert path == temp_home / "test-project" -# -# # Get default -# path = config_manager.config.get_project_path() -# assert path == temp_home / "basic-memory" -# -# # Project does not exist -# with pytest.raises(ValueError): -# config_manager.config.get_project_path("nonexistent") -# -# def test_environment_variable(self, temp_home, monkeypatch): -# """Test using environment variable to select project.""" -# try: -# # Set environment variable -# monkeypatch.setenv("BASIC_MEMORY_PROJECT", "env_test") -# -# # override the home path for the config manager -# config_manager = module_config_manager -# config_manager.config_dir = temp_home / ".basic-memory" -# config_manager.config_dir.mkdir(parents=True, exist_ok=True) -# -# config_manager.config_file = config_manager.config_dir / CONFIG_FILE_NAME -# -# # add a project -# config_manager.add_project("env_test", str(temp_home / "env_test")) -# -# # Get project without specifying name -# path = get_project_config().home -# assert str(path) == str(temp_home / "env_test") -# finally: -# monkeypatch.delenv("BASIC_MEMORY_PROJECT") -# -# def test_remove_project(self, temp_home): -# """Test removing a project.""" -# config_manager = ConfigManager() -# config_manager.add_project("test", str(temp_home / "test-project")) -# -# # Remove project -# config_manager.remove_project("test") -# assert "test" not in config_manager.projects -# -# # Cannot remove default project -# with pytest.raises(ValueError): -# config_manager.remove_project("main") -# -# # Cannot remove nonexistent project -# with pytest.raises(ValueError): -# config_manager.remove_project("nonexistent") -# -# def test_load_invalid_config(self, temp_home): -# """Test loading invalid configuration.""" -# # Create invalid config file -# config_dir = temp_home / DATA_DIR_NAME -# config_dir.mkdir(parents=True, exist_ok=True) -# config_file = config_dir / CONFIG_FILE_NAME -# config_file.write_text("invalid json") -# -# # Load config -# config_manager = ConfigManager() -# -# # Should have default config -# assert "main" in config_manager.projects -# assert config_manager.default_project == "main" -# -# def test_save_config_error(self, temp_home, monkeypatch): -# """Test error when saving configuration.""" -# # Create config manager -# config_manager = ConfigManager() -# -# # Make write_text raise an exception -# def mock_write_text(content): -# raise PermissionError("Permission denied") -# -# monkeypatch.setattr(Path, "write_text", mock_write_text) -# -# # Should not raise exception -# config_manager.save_config(config_manager.config) diff --git a/tests/utils/test_permalink_formatting.py b/tests/utils/test_permalink_formatting.py index c53b346aa..54515a79c 100644 --- a/tests/utils/test_permalink_formatting.py +++ b/tests/utils/test_permalink_formatting.py @@ -18,7 +18,7 @@ async def create_test_file(path: Path, content: str = "test content") -> None: @pytest.mark.asyncio async def test_permalink_formatting( - sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService + sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService ): """Test that permalinks are properly formatted during sync. @@ -29,7 +29,7 @@ async def test_permalink_formatting( - Directory structure is preserved - Multiple directories work correctly """ - project_dir = test_config.home + project_dir = project_config.home # Test cases with different filename formats test_cases = [ @@ -59,7 +59,7 @@ async def test_permalink_formatting( await create_test_file(project_dir / filename, content) # Run sync - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify permalinks for filename, expected_permalink in test_cases: diff --git a/tests/utils/test_utf8_handling.py b/tests/utils/test_utf8_handling.py index c3aac42b9..6c08e4c03 100644 --- a/tests/utils/test_utf8_handling.py +++ b/tests/utils/test_utf8_handling.py @@ -103,9 +103,9 @@ async def test_frontmatter_with_utf8(tmp_path): @pytest.mark.asyncio -async def test_utf8_in_entity_sync(sync_service, test_config): +async def test_utf8_in_entity_sync(sync_service, project_config): """Test syncing an entity with UTF-8 content.""" - project_dir = test_config.home + project_dir = project_config.home # Create a test file with UTF-8 characters test_file = project_dir / "utf8_entity.md" @@ -130,7 +130,7 @@ async def test_utf8_in_entity_sync(sync_service, test_config): test_file.write_text(utf8_content, encoding="utf-8") # Sync the file - await sync_service.sync(test_config.home) + await sync_service.sync(project_config.home) # Verify entity was created entity = await sync_service.entity_service.get_by_permalink("i18n/utf8-document") @@ -157,7 +157,7 @@ async def test_utf8_in_entity_sync(sync_service, test_config): @pytest.mark.asyncio -async def test_write_file_service_utf8(sync_service, test_config): +async def test_write_file_service_utf8(sync_service, project_config): """Test FileService handling of UTF-8 content.""" file_service = sync_service.file_service diff --git a/uv.lock b/uv.lock index cbfccf1a6..bb835ba30 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 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'", @@ -18,32 +18,32 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454 } +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792 }, + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, ] [[package]] name = "alembic" -version = "1.15.2" +version = "1.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/57/e314c31b261d1e8a5a5f1908065b4ff98270a778ce7579bd4254477209a7/alembic-1.15.2.tar.gz", hash = "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", size = 1925573 } +sdist = { url = "https://files.pythonhosted.org/packages/20/89/bfb4fe86e3fc3972d35431af7bedbc60fa606e8b17196704a1747f7aa4c3/alembic-1.16.1.tar.gz", hash = "sha256:43d37ba24b3d17bc1eb1024fe0f51cd1dc95aeb5464594a02c6bb9ca9864bfa4", size = 1955006, upload-time = "2025-05-21T23:11:05.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911 }, + { url = "https://files.pythonhosted.org/packages/31/59/565286efff3692c5716c212202af61466480f6357c4ae3089d4453bff1f3/alembic-1.16.1-py3-none-any.whl", hash = "sha256:0cdd48acada30d93aa1035767d67dff25702f8de74d7c3919f2e8492c8db2e67", size = 242488, upload-time = "2025-05-21T23:11:07.783Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -55,23 +55,22 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] [[package]] name = "asttokens" version = "3.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] [[package]] name = "basic-memory" -version = "0.12.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -160,18 +159,18 @@ dev = [ 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 } +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 }, + { 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" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, ] [[package]] @@ -181,72 +180,75 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", 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/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" -version = "7.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684 }, - { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935 }, - { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994 }, - { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885 }, - { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142 }, - { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906 }, - { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124 }, - { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317 }, - { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170 }, - { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969 }, - { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, - { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, - { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, - { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, - { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, - { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, - { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, - { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, - { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, - { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, - { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, - { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, - { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, - { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, - { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, - { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, - { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, - { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, - { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, - { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, - { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +version = "7.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, + { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, + { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, + { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, + { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, + { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, + { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, ] [[package]] @@ -264,41 +266,41 @@ dependencies = [ { 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 } -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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/f6/1a/64c825770df0b9cb69e5f15c2647e708bf8e13f55da1011749658bc83c37/cx_freeze-8.3.0-cp312-cp312-win32.whl", hash = "sha256:3bad93b5e44c9faee254b0b27a1698c053b569122e73a32858b8e80e340aa8f2", size = 2336981 }, - { url = "https://files.pythonhosted.org/packages/bf/68/09458532149bcb26bbc078ed232c2f970476d6381045ce76de32ef6014c2/cx_freeze-8.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:82887045c831e5c03f4a33f8baab826b785c6400493a077c482cc45c15fd531c", size = 2341781 }, - { url = "https://files.pythonhosted.org/packages/82/fe/ebe723ade801df8f1030d90b9b676efd43bbf12ca833bb4b82108101ed8e/cx_freeze-8.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:72b9d7e3e98bbc175096b66e67208aea5b2e283f07e3d826c40f89f60a821ae1", size = 2329301 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/36/22/5e1c967e4c8bd129f0fe5d94b0f653bf7709fde251c2dc77f6c5da097163/cx_freeze-8.3.0-cp313-cp313-win32.whl", hash = "sha256:c19b092980e3430a963d328432763742baf852d3ff5fef096b2f32e130cfc0ed", size = 2333521 }, - { url = "https://files.pythonhosted.org/packages/b2/61/18c51dfb8bfcd36619c9314d36168c5254d0ce6d40f70fe1ace55edd1991/cx_freeze-8.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:007fb9507b5265c0922aaea10173651a2138b3d75ee9a67156fea4c9fb2b2582", size = 2337819 }, - { url = "https://files.pythonhosted.org/packages/2d/4b/53a5c7d44e482edadba39f7c62e8cafbc22a699f79230aa7bcb23257c12c/cx_freeze-8.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:bab3634e91c09f235a40b998a9b23327625c9032014c2a9365aa3e8c5f6b5a05", size = 2326957 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, +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 } +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 }, - { url = "https://files.pythonhosted.org/packages/5c/52/b6bd4f4d51eb4f3523da182cdf5969a560e35f4ef178f34841ba6795addc/cx_Logging-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3452add0544db6ff29116b72a4c48761aaffa9b638728330433853c0c4ad2ea1", size = 26911 }, - { url = "https://files.pythonhosted.org/packages/e1/78/0ce28b89aedf369b02bb5cb763324e799844144386fba75c03128ea9e2ff/cx_Logging-3.2.1-cp313-cp313-win32.whl", hash = "sha256:330a29030bdca8795c99b678b4f6d87a75fb606eed1da206fdd9fa579a33dc21", size = 22874 }, - { url = "https://files.pythonhosted.org/packages/cb/23/dab5f561888951ec02843f087f34a59c791e8ac6423c25a412eb49300633/cx_Logging-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e14748b031522a95aa2db4adfc5f2be5f96f4d0fe687da591114f73a09e66926", size = 26916 }, + { 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]] @@ -311,9 +313,9 @@ dependencies = [ { name = "regex" }, { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924, upload-time = "2025-02-05T12:34:55.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 }, + { 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]] @@ -324,18 +326,18 @@ 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 } +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 }, + { 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" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, + { 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]] @@ -345,9 +347,9 @@ 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 } +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 }, + { 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]] @@ -358,9 +360,9 @@ dependencies = [ { name = "dnspython" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, ] [[package]] @@ -370,18 +372,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { 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 = "executing" version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, ] [[package]] @@ -393,9 +395,9 @@ dependencies = [ { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, ] [package.optional-dependencies] @@ -417,9 +419,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705 }, + { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" }, ] [package.optional-dependencies] @@ -429,7 +431,7 @@ standard = [ [[package]] name = "fastmcp" -version = "2.3.5" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup" }, @@ -441,18 +443,18 @@ dependencies = [ { name = "typer" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/30/1a70fce24dd0c9f7e7e2168adad1eb2c126e918128594a7bba06093b9263/fastmcp-2.3.5.tar.gz", hash = "sha256:09e11723c6588d8c13562d5eb04d42b13b91eb32f53cef77cc8c0ee121b2f907", size = 1004996 } +sdist = { url = "https://files.pythonhosted.org/packages/5d/cc/37ff3a96338234a697df31d2c70b50a1d0f5e20f045d9b7cbba052be36af/fastmcp-2.5.1.tar.gz", hash = "sha256:0d10ec65a362ae4f78bdf3b639faf35b36cc0a1c8f5461a54fac906fe821b84d", size = 1035613, upload-time = "2025-05-24T11:48:27.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/0f/098a4c7891d8c6adb69fc4f421e879bed73a352b3c3562b6a0be989b29bd/fastmcp-2.3.5-py3-none-any.whl", hash = "sha256:193e35a8d35a5c6a4af07e764873d8592aadc2f1e32dd8827b57869a83956088", size = 97240 }, + { url = "https://files.pythonhosted.org/packages/df/4f/e7ec7b63eadcd5b10978dbc472fc3c36de3fc8c91f60ad7642192ed78836/fastmcp-2.5.1-py3-none-any.whl", hash = "sha256:a6fe50693954a6aed89fc6e43f227dcd66e112e3d3a1d633ee22b4f435ee8aed", size = 105789, upload-time = "2025-05-24T11:48:26.371Z" }, ] [[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 } +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 }, + { 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" }, ] [[package]] @@ -465,68 +467,68 @@ dependencies = [ { name = "zope-event" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/58/267e8160aea00ab00acd2de97197eecfe307064a376fb5c892870a8a6159/gevent-25.5.1.tar.gz", hash = "sha256:582c948fa9a23188b890d0bc130734a506d039a2e5ad87dae276a456cc683e61", size = 6388207 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/58/267e8160aea00ab00acd2de97197eecfe307064a376fb5c892870a8a6159/gevent-25.5.1.tar.gz", hash = "sha256:582c948fa9a23188b890d0bc130734a506d039a2e5ad87dae276a456cc683e61", size = 6388207, upload-time = "2025-05-12T12:57:59.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/c5/cf71423666a0b83db3d7e3f85788bc47d573fca5fe62b798fe2c4273de7c/gevent-25.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d87c0a1bd809d8f70f96b9b229779ec6647339830b8888a192beed33ac8d129f", size = 2909333 }, - { url = "https://files.pythonhosted.org/packages/26/7e/d2f174ee8bec6eb85d961ca203bc599d059c857b8412e367b8fa206603a5/gevent-25.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b87a4b66edb3808d4d07bbdb0deed5a710cf3d3c531e082759afd283758bb649", size = 1788420 }, - { url = "https://files.pythonhosted.org/packages/fe/f3/3aba8c147b9108e62ba348c726fe38ae69735a233db425565227336e8ce6/gevent-25.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f076779050029a82feb0cb1462021d3404d22f80fa76a181b1a7889cd4d6b519", size = 1868854 }, - { url = "https://files.pythonhosted.org/packages/c6/b1/11a5453f8fcebe90a456471fad48bd154c6a62fcb96e3475a5e408d05fc8/gevent-25.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb673eb291c19370f69295f7a881a536451408481e2e3deec3f41dedb7c281ec", size = 1833946 }, - { url = "https://files.pythonhosted.org/packages/70/1c/37d4a62303f86e6af67660a8df38c1171b7290df61b358e618c6fea79567/gevent-25.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1325ed44225c8309c0dd188bdbbbee79e1df8c11ceccac226b861c7d52e4837", size = 2070583 }, - { url = "https://files.pythonhosted.org/packages/4b/8f/3b14929ff28263aba1d268ea97bcf104be1a86ba6f6bb4633838e7a1905e/gevent-25.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fcd5bcad3102bde686d0adcc341fade6245186050ce14386d547ccab4bd54310", size = 1808341 }, - { url = "https://files.pythonhosted.org/packages/2f/fc/674ec819fb8a96e482e4d21f8baa43d34602dba09dfce7bbdc8700899d1b/gevent-25.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a93062609e8fa67ec97cd5fb9206886774b2a09b24887f40148c9c37e6fb71c", size = 2137974 }, - { url = "https://files.pythonhosted.org/packages/05/9a/048b7f5e28c54e4595ad4a8ad3c338fa89560e558db2bbe8273f44f030de/gevent-25.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:2534c23dc32bed62b659ed4fd9e198906179e68b26c9276a897e04163bdde806", size = 1638344 }, - { url = "https://files.pythonhosted.org/packages/10/25/2162b38d7b48e08865db6772d632bd1648136ce2bb50e340565e45607cad/gevent-25.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a022a9de9275ce0b390b7315595454258c525dc8287a03f1a6cacc5878ab7cbc", size = 2928044 }, - { url = "https://files.pythonhosted.org/packages/1b/e0/dbd597a964ed00176da122ea759bf2a6c1504f1e9f08e185379f92dc355f/gevent-25.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fae8533f9d0ef3348a1f503edcfb531ef7a0236b57da1e24339aceb0ce52922", size = 1788751 }, - { url = "https://files.pythonhosted.org/packages/f1/74/960cc4cf4c9c90eafbe0efc238cdf588862e8e278d0b8c0d15a0da4ed480/gevent-25.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b32d9c3b5294b39ea9060e20c582e49e1ec81edbfeae6cf05f8ad0829cb13d", size = 1869766 }, - { url = "https://files.pythonhosted.org/packages/56/78/fa84b1c7db79b156929685db09a7c18c3127361dca18a09e998e98118506/gevent-25.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b95815fe44f318ebbfd733b6428b4cb18cc5e68f1c40e8501dd69cc1f42a83d", size = 1835358 }, - { url = "https://files.pythonhosted.org/packages/00/5c/bfefe3822bbca5b83bfad256c82251b3f5be13d52d14e17a786847b9b625/gevent-25.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d316529b70d325b183b2f3f5cde958911ff7be12eb2b532b5c301f915dbbf1e", size = 2073071 }, - { url = "https://files.pythonhosted.org/packages/20/e4/08a77a3839a37db96393dea952e992d5846a881b887986dde62ead6b48a1/gevent-25.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6ba33c13db91ffdbb489a4f3d177a261ea1843923e1d68a5636c53fe98fa5ce", size = 1809805 }, - { url = "https://files.pythonhosted.org/packages/2b/ac/28848348f790c1283df74b0fc0a554271d0606676470f848eccf84eae42a/gevent-25.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ee34b77c7553777c0b8379915f75934c3f9c8cd32f7cd098ea43c9323c2276", size = 2138305 }, - { url = "https://files.pythonhosted.org/packages/52/9e/0e9e40facd2d714bfb00f71fc6dacaacc82c24c1c2e097bf6461e00dec9f/gevent-25.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fa6aa0da224ed807d3b76cdb4ee8b54d4d4d5e018aed2478098e685baae7896", size = 1637444 }, - { url = "https://files.pythonhosted.org/packages/60/16/b71171e97ec7b4ded8669542f4369d88d5a289e2704efbbde51e858e062a/gevent-25.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0bacf89a65489d26c7087669af89938d5bfd9f7afb12a07b57855b9fad6ccbd0", size = 2937113 }, + { url = "https://files.pythonhosted.org/packages/58/c5/cf71423666a0b83db3d7e3f85788bc47d573fca5fe62b798fe2c4273de7c/gevent-25.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d87c0a1bd809d8f70f96b9b229779ec6647339830b8888a192beed33ac8d129f", size = 2909333, upload-time = "2025-05-12T11:11:34.883Z" }, + { url = "https://files.pythonhosted.org/packages/26/7e/d2f174ee8bec6eb85d961ca203bc599d059c857b8412e367b8fa206603a5/gevent-25.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b87a4b66edb3808d4d07bbdb0deed5a710cf3d3c531e082759afd283758bb649", size = 1788420, upload-time = "2025-05-12T11:52:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f3/3aba8c147b9108e62ba348c726fe38ae69735a233db425565227336e8ce6/gevent-25.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f076779050029a82feb0cb1462021d3404d22f80fa76a181b1a7889cd4d6b519", size = 1868854, upload-time = "2025-05-12T11:54:21.564Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b1/11a5453f8fcebe90a456471fad48bd154c6a62fcb96e3475a5e408d05fc8/gevent-25.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb673eb291c19370f69295f7a881a536451408481e2e3deec3f41dedb7c281ec", size = 1833946, upload-time = "2025-05-12T12:00:05.514Z" }, + { url = "https://files.pythonhosted.org/packages/70/1c/37d4a62303f86e6af67660a8df38c1171b7290df61b358e618c6fea79567/gevent-25.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1325ed44225c8309c0dd188bdbbbee79e1df8c11ceccac226b861c7d52e4837", size = 2070583, upload-time = "2025-05-12T11:33:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/3b14929ff28263aba1d268ea97bcf104be1a86ba6f6bb4633838e7a1905e/gevent-25.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fcd5bcad3102bde686d0adcc341fade6245186050ce14386d547ccab4bd54310", size = 1808341, upload-time = "2025-05-12T11:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/2f/fc/674ec819fb8a96e482e4d21f8baa43d34602dba09dfce7bbdc8700899d1b/gevent-25.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a93062609e8fa67ec97cd5fb9206886774b2a09b24887f40148c9c37e6fb71c", size = 2137974, upload-time = "2025-05-12T11:40:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/05/9a/048b7f5e28c54e4595ad4a8ad3c338fa89560e558db2bbe8273f44f030de/gevent-25.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:2534c23dc32bed62b659ed4fd9e198906179e68b26c9276a897e04163bdde806", size = 1638344, upload-time = "2025-05-12T12:08:31.776Z" }, + { url = "https://files.pythonhosted.org/packages/10/25/2162b38d7b48e08865db6772d632bd1648136ce2bb50e340565e45607cad/gevent-25.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a022a9de9275ce0b390b7315595454258c525dc8287a03f1a6cacc5878ab7cbc", size = 2928044, upload-time = "2025-05-12T11:11:36.33Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e0/dbd597a964ed00176da122ea759bf2a6c1504f1e9f08e185379f92dc355f/gevent-25.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fae8533f9d0ef3348a1f503edcfb531ef7a0236b57da1e24339aceb0ce52922", size = 1788751, upload-time = "2025-05-12T11:52:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/960cc4cf4c9c90eafbe0efc238cdf588862e8e278d0b8c0d15a0da4ed480/gevent-25.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b32d9c3b5294b39ea9060e20c582e49e1ec81edbfeae6cf05f8ad0829cb13d", size = 1869766, upload-time = "2025-05-12T11:54:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/fa84b1c7db79b156929685db09a7c18c3127361dca18a09e998e98118506/gevent-25.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b95815fe44f318ebbfd733b6428b4cb18cc5e68f1c40e8501dd69cc1f42a83d", size = 1835358, upload-time = "2025-05-12T12:00:06.794Z" }, + { url = "https://files.pythonhosted.org/packages/00/5c/bfefe3822bbca5b83bfad256c82251b3f5be13d52d14e17a786847b9b625/gevent-25.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d316529b70d325b183b2f3f5cde958911ff7be12eb2b532b5c301f915dbbf1e", size = 2073071, upload-time = "2025-05-12T11:33:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/08a77a3839a37db96393dea952e992d5846a881b887986dde62ead6b48a1/gevent-25.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6ba33c13db91ffdbb489a4f3d177a261ea1843923e1d68a5636c53fe98fa5ce", size = 1809805, upload-time = "2025-05-12T12:00:00.537Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ac/28848348f790c1283df74b0fc0a554271d0606676470f848eccf84eae42a/gevent-25.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ee34b77c7553777c0b8379915f75934c3f9c8cd32f7cd098ea43c9323c2276", size = 2138305, upload-time = "2025-05-12T11:40:56.566Z" }, + { url = "https://files.pythonhosted.org/packages/52/9e/0e9e40facd2d714bfb00f71fc6dacaacc82c24c1c2e097bf6461e00dec9f/gevent-25.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fa6aa0da224ed807d3b76cdb4ee8b54d4d4d5e018aed2478098e685baae7896", size = 1637444, upload-time = "2025-05-12T12:17:45.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/b71171e97ec7b4ded8669542f4369d88d5a289e2704efbbde51e858e062a/gevent-25.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0bacf89a65489d26c7087669af89938d5bfd9f7afb12a07b57855b9fad6ccbd0", size = 2937113, upload-time = "2025-05-12T11:12:03.191Z" }, ] [[package]] name = "greenlet" version = "3.2.2" 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 } -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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, +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" } +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" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -537,31 +539,31 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683 }, - { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337 }, - { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796 }, - { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837 }, - { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289 }, - { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779 }, - { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634 }, - { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, - { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, - { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, - { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, - { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, - { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, - { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, ] [[package]] @@ -574,18 +576,18 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "httpx-sse" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, ] [[package]] @@ -598,27 +600,27 @@ dependencies = [ { name = "executing" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/5e/9f41831f032b9ce456c919c4989952562fcc2b0eb8c038080c24ce20d6cd/icecream-2.1.4.tar.gz", hash = "sha256:58755e58397d5350a76f25976dee7b607f5febb3c6e1cddfe6b1951896e91573", size = 15872 } +sdist = { url = "https://files.pythonhosted.org/packages/78/5e/9f41831f032b9ce456c919c4989952562fcc2b0eb8c038080c24ce20d6cd/icecream-2.1.4.tar.gz", hash = "sha256:58755e58397d5350a76f25976dee7b607f5febb3c6e1cddfe6b1951896e91573", size = 15872, upload-time = "2025-01-09T16:23:20.993Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/1d/43ef7a6875190e6745ffcd1b12c7aaa7efed082897401e311ee1cd75c8b2/icecream-2.1.4-py3-none-any.whl", hash = "sha256:7bb715f69102cae871b3a361c3b656536db02cfcadac9664c673581cac4df4fd", size = 14782 }, + { url = "https://files.pythonhosted.org/packages/57/1d/43ef7a6875190e6745ffcd1b12c7aaa7efed082897401e311ee1cd75c8b2/icecream-2.1.4-py3-none-any.whl", hash = "sha256:7bb715f69102cae871b3a361c3b656536db02cfcadac9664c673581cac4df4fd", size = 14782, upload-time = "2025-01-09T16:23:18.213Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -628,9 +630,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { 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]] @@ -638,12 +640,12 @@ 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 }, - { url = "https://files.pythonhosted.org/packages/08/8b/0fdc6b420e24df7c8cc02be595c425e821f2d4eb1be98eb16a7cf4e87fd0/lief-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:587225fd6e1ec424a1a776928beb67095894254c51148b78903844d62faa1a2d", size = 3178830 }, - { url = "https://files.pythonhosted.org/packages/e5/a6/f751d12b88527b591f26a7c4a2b896806c065d9bdfb49eaabec9e6aead41/lief-0.16.5-cp312-cp312-win_arm64.whl", hash = "sha256:ef043c1796d221f128597dc32819fa6bb31da26d2a9b911a32d4a5cdfb566f85", size = 3066592 }, - { url = "https://files.pythonhosted.org/packages/d8/97/72fe8e8bfbfea9d76350635965f668e855490c6f2779c08bf1b9ab3a505d/lief-0.16.5-cp313-cp313-win32.whl", hash = "sha256:6fc879c1c90bf31f7720ece90bd919cbfeeb3bdbc9327f6a16d4dc1af273aef9", size = 3049849 }, - { url = "https://files.pythonhosted.org/packages/66/fc/6faf93a5b44f9e7df193e9fc95b93a7f34b2155b1b470ef61f2f25704a84/lief-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:2f208359d10ade57ace7f7625e2f5e4ca214b4b67f9ade24ca07dafb08e37b0c", size = 3178645 }, - { url = "https://files.pythonhosted.org/packages/6d/47/d0a47b6856d832a2ab0896faa773b4506b41e39131684892017351e8ff28/lief-0.16.5-cp313-cp313-win_arm64.whl", hash = "sha256:afb7d946aa2b62c95831d3be45f2516324418335b077f5337012b779e8dcc97b", size = 3066502 }, + { 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]] @@ -654,18 +656,18 @@ dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "win32-setctime", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, + { 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 } +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 }, + { 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]] @@ -675,9 +677,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] [[package]] @@ -687,52 +689,52 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] [[package]] name = "mcp" -version = "1.9.0" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -745,27 +747,27 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/bc/54aec2c334698cc575ca3b3481eed627125fb66544152fa1af927b1a495c/mcp-1.9.1.tar.gz", hash = "sha256:19879cd6dde3d763297617242888c2f695a95dfa854386a6a68676a646ce75e4", size = 316247, upload-time = "2025-05-22T15:52:21.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, + { url = "https://files.pythonhosted.org/packages/a6/c0/4ac795585a22a0a2d09cd2b1187b0252d2afcdebd01e10a68bbac4d34890/mcp-1.9.1-py3-none-any.whl", hash = "sha256:2900ded8ffafc3c8a7bfcfe8bc5204037e988e753ec398f371663e6a06ecd9a9", size = 130261, upload-time = "2025-05-22T15:52:19.702Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] @@ -775,82 +777,82 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { 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 } +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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, + { 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" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 }, - { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 }, - { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 }, - { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 }, - { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 }, - { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 }, - { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 }, - { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 }, - { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 }, - { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 }, - { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 }, - { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 }, - { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 }, - { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 }, - { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 }, - { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 }, - { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 }, - { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 }, - { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 }, - { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 }, - { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 }, - { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 }, - { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 }, - { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 }, - { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 }, - { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 }, - { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 }, - { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 }, - { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 }, - { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 }, - { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 }, - { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 }, - { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 }, +sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload-time = "2025-04-12T17:48:00.417Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload-time = "2025-04-12T17:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload-time = "2025-04-12T17:48:04.554Z" }, + { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload-time = "2025-04-12T17:48:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload-time = "2025-04-12T17:48:09.229Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload-time = "2025-04-12T17:48:11.631Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload-time = "2025-04-12T17:48:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload-time = "2025-04-12T17:48:15.938Z" }, + { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload-time = "2025-04-12T17:48:25.738Z" }, + { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload-time = "2025-04-12T17:48:27.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload-time = "2025-04-12T17:48:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload-time = "2025-04-12T17:48:31.874Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload-time = "2025-04-12T17:48:34.422Z" }, + { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload-time = "2025-04-12T17:48:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload-time = "2025-04-12T17:48:39.652Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload-time = "2025-04-12T17:48:41.765Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload-time = "2025-04-12T17:48:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload-time = "2025-04-12T17:48:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload-time = "2025-04-12T17:48:47.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload-time = "2025-04-12T17:48:50.189Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload-time = "2025-04-12T17:48:52.346Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload-time = "2025-04-12T17:48:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload-time = "2025-04-12T17:48:56.383Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload-time = "2025-04-12T17:48:58.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload-time = "2025-04-12T17:49:00.709Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload-time = "2025-04-12T17:49:02.946Z" }, + { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload-time = "2025-04-12T17:49:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload-time = "2025-04-12T17:49:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -860,20 +862,20 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pymeta3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/1a/2fb847db017f9f89ab8519d96e35fb3dacb6170a0643fddba3b366af0af1/pybars3-0.9.7.tar.gz", hash = "sha256:6ac847e905e53b9c5b936af112c910475e27bf767f79f4528c16f9af1ec0e252", size = 29203 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/1a/2fb847db017f9f89ab8519d96e35fb3dacb6170a0643fddba3b366af0af1/pybars3-0.9.7.tar.gz", hash = "sha256:6ac847e905e53b9c5b936af112c910475e27bf767f79f4528c16f9af1ec0e252", size = 29203, upload-time = "2019-11-05T09:45:24.07Z" } [[package]] name = "pycparser" version = "2.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] [[package]] name = "pydantic" -version = "2.11.4" +version = "2.11.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -881,9 +883,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, ] [package.optional-dependencies] @@ -901,39 +903,39 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] [[package]] @@ -945,34 +947,34 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, ] [[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] [[package]] name = "pyjwt" version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] name = "pymeta3" 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 } +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" @@ -982,13 +984,13 @@ 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 } +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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/955cfd880f2725a218ee7b272c005658e857e9224823d49c32c93517f6d9/PyQt6-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:d36482000f0cd7ce84a35863766f88a5e671233d5f1024656b600cd8915b3752", size = 6748279 }, - { url = "https://files.pythonhosted.org/packages/9f/38/586ce139b1673a27607f7b85c594878e1bba215abdca3de67732b463f7b2/PyQt6-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:0c8b7251608e05b479cfe731f95857e853067459f7cbbcfe90f89de1bcf04280", size = 5478122 }, + { 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]] @@ -996,43 +998,43 @@ 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/6e/24/6b6168a75c7b6a55b9f6b5c897e6164ec15e94594af11a6f358c49845442/PyQt6_Qt6-6.9.0-py3-none-win_amd64.whl", hash = "sha256:c825a6f5a9875ef04ef6681eda16aa3a9e9ad71847aa78dfafcf388c8007aa0a", size = 73652485 }, - { url = "https://files.pythonhosted.org/packages/44/fd/1238931df039e46e128d53974c0cfc9d34da3d54c5662bd589fe7b0a67c2/PyQt6_Qt6-6.9.0-py3-none-win_arm64.whl", hash = "sha256:1188f118d1c570d27fba39707e3d8a48525f979816e73de0da55b9e6fa9ad0a1", size = 49568913 }, + { 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.0" +version = "13.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/18/0405c54acba0c8e276dd6f0601890e6e735198218d031a6646104870fe22/pyqt6_sip-13.10.0.tar.gz", hash = "sha256:d6daa95a0bd315d9ec523b549e0ce97455f61ded65d5eafecd83ed2aa4ae5350", size = 92464 } +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/69/81/66d9bdacb790592a0641378749a047f12e3b254cdc2cb51f7ed636cf01d2/PyQt6_sip-13.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:48791db2914fc39c3218519a02d2a5fd3fcd354a1be3141a57bf2880701486f2", size = 112334 }, - { url = "https://files.pythonhosted.org/packages/26/2c/4796c209009a018e0d4a5c406d5a519234c5a378f370dc679d0ad5f455b2/PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:466d6b4791973c9fcbdc2e0087ed194b9ea802a8c3948867a849498f0841c70c", size = 322334 }, - { url = "https://files.pythonhosted.org/packages/99/34/2ec54bd475f0a811df1d32be485f2344cf9e8b388ce7adb26b46ce5552d4/PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ae15358941f127cd3d1ab09c1ebd45c4dabb0b2e91587b9eebde0279d0039c54", size = 303798 }, - { url = "https://files.pythonhosted.org/packages/0c/e4/82099bb4ab8bc152b5718541e93c0b3adf7566c0f307c9e58e2368b8c517/PyQt6_sip-13.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad573184fa8b00041944e5a17d150ab0d08db2d2189e39c9373574ebab3f2e58", size = 53569 }, - { url = "https://files.pythonhosted.org/packages/e3/09/90e0378887a3cb9664da77061229cf8e97e6ec25a5611b7dbc9cc3e02c78/PyQt6_sip-13.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d579d810d0047d40bde9c6aef281d6ed218db93c9496ebc9e55b9e6f27a229d", size = 45430 }, - { url = "https://files.pythonhosted.org/packages/6b/0c/8d1de48b45b565a46bf4757341f13f9b1853a7d2e6b023700f0af2c213ab/PyQt6_sip-13.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b6e250c2e7c14702a623f2cc1479d7fb8db2b6eee9697cac10d06fe79c281bb", size = 112343 }, - { url = "https://files.pythonhosted.org/packages/af/13/e2cc2b667a9f5d44c2d0e18fa6e1066fca3f4521dcb301f4b5374caeb33e/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcb30756568f8cd59290f9ef2ae5ee3e72ff9cdd61a6f80c9e3d3b95ae676be", size = 322527 }, - { url = "https://files.pythonhosted.org/packages/20/1a/5c6fcae85edb65cf236c9dc6d23b279b5316e94cdca1abdee6d0a217ddbb/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:757ac52c92b2ef0b56ecc7cd763b55a62d3c14271d7ea8d03315af85a70090ff", size = 303407 }, - { url = "https://files.pythonhosted.org/packages/b9/db/6924ec985be7d746772806b96ab81d24263ef72f0249f0573a82adaed75e/PyQt6_sip-13.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:571900c44a3e38738d696234d94fe2043972b9de0633505451c99e2922cb6a34", size = 53580 }, - { url = "https://files.pythonhosted.org/packages/77/c3/9e44729b582ee7f1d45160e8c292723156889f3e38ce6574f88d5ab8fa02/PyQt6_sip-13.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:39cba2cc71cf80a99b4dc8147b43508d4716e128f9fb99f5eb5860a37f082282", size = 45446 }, + { 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.400" +version = "1.1.401" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193, upload-time = "2025-05-21T10:44:52.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460 }, + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193, upload-time = "2025-05-21T10:44:50.129Z" }, ] [[package]] @@ -1045,21 +1047,21 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.26.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, + { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, ] [[package]] @@ -1070,21 +1072,21 @@ dependencies = [ { name = "coverage" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 } +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, + { 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]] @@ -1094,18 +1096,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, ] [[package]] @@ -1115,100 +1117,100 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256 } +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834 }, + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, ] [[package]] name = "python-multipart" version = "0.0.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { 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 } +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 }, + { 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" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, - { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, - { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, - { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, - { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, - { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, - { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, - { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, - { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, - { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, - { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, - { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, - { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, - { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, - { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, - { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, - { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, - { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, - { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, - { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, - { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, - { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, - { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, - { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, - { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, - { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, - { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, - { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, - { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, - { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494, upload-time = "2024-11-06T20:12:31.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781, upload-time = "2024-11-06T20:10:07.07Z" }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455, upload-time = "2024-11-06T20:10:09.117Z" }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759, upload-time = "2024-11-06T20:10:11.155Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976, upload-time = "2024-11-06T20:10:13.24Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077, upload-time = "2024-11-06T20:10:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160, upload-time = "2024-11-06T20:10:19.027Z" }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896, upload-time = "2024-11-06T20:10:21.85Z" }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997, upload-time = "2024-11-06T20:10:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725, upload-time = "2024-11-06T20:10:28.067Z" }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481, upload-time = "2024-11-06T20:10:31.612Z" }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896, upload-time = "2024-11-06T20:10:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138, upload-time = "2024-11-06T20:10:36.142Z" }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692, upload-time = "2024-11-06T20:10:38.394Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135, upload-time = "2024-11-06T20:10:40.367Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567, upload-time = "2024-11-06T20:10:43.467Z" }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525, upload-time = "2024-11-06T20:10:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324, upload-time = "2024-11-06T20:10:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617, upload-time = "2024-11-06T20:10:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023, upload-time = "2024-11-06T20:10:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072, upload-time = "2024-11-06T20:10:52.926Z" }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130, upload-time = "2024-11-06T20:10:54.828Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857, upload-time = "2024-11-06T20:10:56.634Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006, upload-time = "2024-11-06T20:10:59.369Z" }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650, upload-time = "2024-11-06T20:11:02.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545, upload-time = "2024-11-06T20:11:03.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045, upload-time = "2024-11-06T20:11:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182, upload-time = "2024-11-06T20:11:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733, upload-time = "2024-11-06T20:11:11.256Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122, upload-time = "2024-11-06T20:11:13.161Z" }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" }, ] [[package]] @@ -1219,84 +1221,84 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] [[package]] name = "rich-toolkit" -version = "0.14.6" +version = "0.14.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/31/b6d055f291a660a7bcaec4bcc9457b9fef8ecb6293e527b1eef1840aefd4/rich_toolkit-0.14.6.tar.gz", hash = "sha256:9dbd40e83414b84e828bf899115fff8877ce5951b73175f44db142902f07645d", size = 110805 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/7a/cb48b7024b247631ce39b1f14a0f1abedf311fb27b892b0e0387d809d4b5/rich_toolkit-0.14.7.tar.gz", hash = "sha256:6cca5a68850cc5778915f528eb785662c27ba3b4b2624612cce8340fa9701c5e", size = 104977, upload-time = "2025-05-27T15:48:09.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/3c/7a824c0514e87c61000583ac22c8321da6dc8e58a93d5f56e583482a2ee0/rich_toolkit-0.14.6-py3-none-any.whl", hash = "sha256:764f3a5f9e4b539ce805596863299e8982599514906dc5e3ccc2d390ef74c301", size = 24815 }, + { url = "https://files.pythonhosted.org/packages/0f/2e/95fde5b818dac9a37683ea064096323f593442d0f6358923c5f635974393/rich_toolkit-0.14.7-py3-none-any.whl", hash = "sha256:def05cc6e0f1176d6263b6a26648f16a62c4563b277ca2f8538683acdba1e0da", size = 24870, upload-time = "2025-05-27T15:48:07.942Z" }, ] [[package]] name = "ruff" -version = "0.11.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 }, - { url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 }, - { url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 }, - { url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 }, - { url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 }, - { url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 }, - { url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 }, - { url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 }, - { url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 }, - { url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 }, - { url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 }, - { url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 }, - { url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 }, - { url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 }, - { url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 }, - { url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 }, - { url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 }, +version = "0.11.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/53/ae4857030d59286924a8bdb30d213d6ff22d8f0957e738d0289990091dd8/ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d", size = 4186707, upload-time = "2025-05-22T19:19:34.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/14/f2326676197bab099e2a24473158c21656fbf6a207c65f596ae15acb32b9/ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092", size = 10229049, upload-time = "2025-05-22T19:18:45.516Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f3/bff7c92dd66c959e711688b2e0768e486bbca46b2f35ac319bb6cce04447/ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4", size = 11053601, upload-time = "2025-05-22T19:18:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/e2/38/8e1a3efd0ef9d8259346f986b77de0f62c7a5ff4a76563b6b39b68f793b9/ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd", size = 10367421, upload-time = "2025-05-22T19:18:51.754Z" }, + { url = "https://files.pythonhosted.org/packages/b4/50/557ad9dd4fb9d0bf524ec83a090a3932d284d1a8b48b5906b13b72800e5f/ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6", size = 10581980, upload-time = "2025-05-22T19:18:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b2/e2ed82d6e2739ece94f1bdbbd1d81b712d3cdaf69f0a1d1f1a116b33f9ad/ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4", size = 10089241, upload-time = "2025-05-22T19:18:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9f/b4539f037a5302c450d7c695c82f80e98e48d0d667ecc250e6bdeb49b5c3/ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac", size = 11699398, upload-time = "2025-05-22T19:18:58.248Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/32e029d2c0b17df65e6eaa5ce7aea5fbeaed22dddd9fcfbbf5fe37c6e44e/ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709", size = 12427955, upload-time = "2025-05-22T19:19:00.981Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e3/160488dbb11f18c8121cfd588e38095ba779ae208292765972f7732bfd95/ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8", size = 12069803, upload-time = "2025-05-22T19:19:03.258Z" }, + { url = "https://files.pythonhosted.org/packages/ff/16/3b006a875f84b3d0bff24bef26b8b3591454903f6f754b3f0a318589dcc3/ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b", size = 11242630, upload-time = "2025-05-22T19:19:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/0338bb8ac0b97175c2d533e9c8cdc127166de7eb16d028a43c5ab9e75abd/ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875", size = 11507310, upload-time = "2025-05-22T19:19:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/d7130eb26174ce9b02348b9f86d5874eafbf9f68e5152e15e8e0a392e4a3/ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1", size = 10441144, upload-time = "2025-05-22T19:19:13.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f3/4be2453b258c092ff7b1761987cf0749e70ca1340cd1bfb4def08a70e8d8/ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81", size = 10081987, upload-time = "2025-05-22T19:19:15.821Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6e/dfa4d2030c5b5c13db158219f2ec67bf333e8a7748dccf34cfa2a6ab9ebc/ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639", size = 11073922, upload-time = "2025-05-22T19:19:18.104Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f4/f7b0b0c3d32b593a20ed8010fa2c1a01f2ce91e79dda6119fcc51d26c67b/ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345", size = 11568537, upload-time = "2025-05-22T19:19:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d2/46/0e892064d0adc18bcc81deed9aaa9942a27fd2cd9b1b7791111ce468c25f/ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112", size = 10536492, upload-time = "2025-05-22T19:19:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/232e79459850b9f327e9f1dc9c047a2a38a6f9689e1ec30024841fc4416c/ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f", size = 11612562, upload-time = "2025-05-22T19:19:27.013Z" }, + { url = "https://files.pythonhosted.org/packages/ce/eb/09c132cff3cc30b2e7244191dcce69437352d6d6709c0adf374f3e6f476e/ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b", size = 10735951, upload-time = "2025-05-22T19:19:30.043Z" }, ] [[package]] name = "setuptools" version = "80.4.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 } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/93/dba5ed08c2e31ec7cdc2ce75705a484ef0be1a2fecac8a58272489349de8/setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2", size = 1200812 }, + { 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" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] @@ -1307,25 +1309,25 @@ dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, - { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, - { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, - { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, - { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, - { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, - { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, - { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, - { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, - { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, - { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, - { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, - { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, - { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, - { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, - { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] [[package]] @@ -1336,9 +1338,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 } +sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511, upload-time = "2025-05-12T18:23:52.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 }, + { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233, upload-time = "2025-05-12T18:23:50.722Z" }, ] [[package]] @@ -1348,23 +1350,23 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, + { 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 } +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 }, + { 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.15.4" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1372,39 +1374,39 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/89/c527e6c848739be8ceb5c44eb8208c52ea3515c6cf6406aa61932887bf58/typer-0.15.4.tar.gz", hash = "sha256:89507b104f9b6a0730354f27c39fae5b63ccd0c95b1ce1f1a6ba0cfd329997c3", size = 101559 } +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/62/d4ba7afe2096d5659ec3db8b15d8665bdcb92a3c6ff0b95e99895b335a9c/typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173", size = 45258 }, + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, ] [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] [[package]] @@ -1414,18 +1416,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] [[package]] name = "unidecode" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149 } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837 }, + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, ] [[package]] @@ -1436,9 +1438,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" }, ] [package.optional-dependencies] @@ -1456,20 +1458,20 @@ standard = [ name = "uvloop" version = "0.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284 }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349 }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089 }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770 }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321 }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022 }, - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, ] [[package]] @@ -1479,73 +1481,73 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511 }, - { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715 }, - { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138 }, - { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592 }, - { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532 }, - { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865 }, - { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887 }, - { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498 }, - { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663 }, - { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410 }, - { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965 }, - { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693 }, - { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287 }, - { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531 }, - { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417 }, - { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423 }, - { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185 }, - { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696 }, - { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327 }, - { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741 }, - { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995 }, - { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693 }, - { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677 }, - { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804 }, - { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087 }, +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload-time = "2025-04-08T10:35:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload-time = "2025-04-08T10:35:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload-time = "2025-04-08T10:35:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload-time = "2025-04-08T10:35:21.87Z" }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload-time = "2025-04-08T10:35:23.143Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload-time = "2025-04-08T10:35:24.702Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload-time = "2025-04-08T10:35:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload-time = "2025-04-08T10:35:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload-time = "2025-04-08T10:35:28.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload-time = "2025-04-08T10:35:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload-time = "2025-04-08T10:35:32.023Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload-time = "2025-04-08T10:35:33.225Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload-time = "2025-04-08T10:35:34.568Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload-time = "2025-04-08T10:35:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload-time = "2025-04-08T10:35:37.048Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload-time = "2025-04-08T10:35:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload-time = "2025-04-08T10:35:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload-time = "2025-04-08T10:35:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload-time = "2025-04-08T10:35:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload-time = "2025-04-08T10:35:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload-time = "2025-04-08T10:35:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload-time = "2025-04-08T10:35:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload-time = "2025-04-08T10:35:49.65Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload-time = "2025-04-08T10:35:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload-time = "2025-04-08T10:35:52.458Z" }, ] [[package]] name = "websockets" version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] name = "win32-setctime" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] [[package]] @@ -1555,9 +1557,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350 } +sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350, upload-time = "2023-06-23T06:28:35.709Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824 }, + { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824, upload-time = "2023-06-23T06:28:32.652Z" }, ] [[package]] @@ -1567,18 +1569,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959 }, - { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357 }, - { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235 }, - { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253 }, - { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702 }, - { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466 }, - { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961 }, - { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356 }, - { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196 }, - { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237 }, - { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696 }, - { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472 }, +sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" }, ]