diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4624c3d..a459e23 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,8 +40,8 @@ jobs:
- name: Install dependencies
run: |
sudo apt update -y && sudo apt install -y pipx
- pipx install poetry
- poetry install
+ pipx install uv
+ uv sync
env:
DEBIAN_FRONTEND: "noninteractive"
@@ -52,4 +52,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
- poetry run python -m pytest
+ uv run python -m pytest
diff --git a/.gitignore b/.gitignore
index c18dd8d..c1e64c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
__pycache__/
+.coverage
diff --git a/README.md b/README.md
index d45e0b0..af8ad55 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
* `nc_notes_create_note`: Create a new note.
* `nc_notes_update_note`: Update an existing note by ID.
* `nc_notes_delete_note`: Delete a note by ID.
+* `nc_notes_search_notes`: Search notes by title or content.
+* `nc_get_note`: Get a specific note by ID.
### Available Resources
@@ -20,6 +22,15 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
* `notes://all`: Access all notes.
* `notes://settings`: Access note settings.
* `nc://capabilities`: Access Nextcloud server capabilities.
+* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes.
+
+### Note Attachments
+
+This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
+
+* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
+* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
+* WebDAV permissions must be properly configured for attachment operations to work correctly.
## Installation
diff --git a/attach_and_embed_image.py b/attach_and_embed_image.py
new file mode 100644
index 0000000..930b900
--- /dev/null
+++ b/attach_and_embed_image.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python
+import os
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 487 # ID of the note we just created
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # Check if image exists
+ image_path = 'sample_image.png'
+ if not os.path.exists(image_path):
+ print(f"Error: Image file '{image_path}' not found")
+ return 1
+
+ # Read the image
+ with open(image_path, 'rb') as f:
+ image_content = f.read()
+
+ print(f"Attaching image to note {note_id}...")
+ try:
+ # Attach the image to the note
+ upload_response = client.add_note_attachment(
+ note_id=note_id,
+ filename="sample_image.png",
+ content=image_content,
+ mime_type="image/png"
+ )
+
+ print(f"Image attached successfully (Status: {upload_response['status_code']}).")
+
+ # Now get the current note to get its etag
+ note = client.notes_get_note(note_id=note_id)
+ etag = note["etag"]
+
+ # Update the note content to include the image references
+ updated_content = f"""# Note with Visible Image Demo
+
+This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
+
+We'll include the sample red square image we created earlier using both Markdown and HTML methods.
+
+## Method 1: Markdown Image Syntax
+
+
+## Method 2: HTML Image Tag
+
+
+## Image Path Details
+The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
+"""
+
+ # Update the note with the references to the image
+ updated_note = client.notes_update_note(
+ note_id=note_id,
+ etag=etag,
+ content=updated_content
+ )
+
+ print(f"Note updated with image references. You can now view it in the browser.")
+ print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
+ return 0
+ except Exception as e:
+ print(f"Error: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/attach_image.py b/attach_image.py
new file mode 100644
index 0000000..f9d766c
--- /dev/null
+++ b/attach_image.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python
+import os
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 420 # ID of the note we created earlier
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # Check if image exists
+ image_path = 'sample_image.png'
+ if not os.path.exists(image_path):
+ print(f"Error: Image file '{image_path}' not found")
+ return 1
+
+ # Read the image
+ with open(image_path, 'rb') as f:
+ image_content = f.read()
+
+ print(f"Attaching image to note {note_id}...")
+ try:
+ # Attach the image to the note
+ upload_response = client.add_note_attachment(
+ note_id=note_id,
+ filename="sample_image.png",
+ content=image_content,
+ mime_type="image/png"
+ )
+
+ print(f"Image attached successfully (Status: {upload_response['status_code']}).")
+ print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
+ return 0
+ except Exception as e:
+ print(f"Error attaching image: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/check_updated_note.py b/check_updated_note.py
new file mode 100644
index 0000000..bcba688
--- /dev/null
+++ b/check_updated_note.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 420 # ID of the note with the image attachment
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # Get the note again to see the updated content
+ try:
+ note = client.notes_get_note(note_id=note_id)
+ print(f"Retrieved note: {note['title']}")
+ print("\nCURRENT NOTE CONTENT:")
+ print("-" * 50)
+ print(note['content'])
+ print("-" * 50)
+
+ return 0
+ except Exception as e:
+ print(f"Error retrieving note: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docker-compose.yml b/docker-compose.yml
index 259ccd9..9ab3d9a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,7 @@ services:
volumes:
- nextcloud:/var/www/html
environment:
+ - NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
- NEXTCLOUD_ADMIN_PASSWORD=admin
- MYSQL_PASSWORD=password
@@ -42,6 +43,15 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
+ mcp:
+ build: .
+ ports:
+ - 8000:8000
+ environment:
+ - NEXTCLOUD_HOST=http://app:80
+ - NEXTCLOUD_USERNAME=admin
+ - NEXTCLOUD_PASSWORD=admin
+
volumes:
nextcloud:
db:
diff --git a/docs/ADR-001-enhanced-note-search.md b/docs/ADR-001-enhanced-note-search.md
new file mode 100644
index 0000000..2d793eb
--- /dev/null
+++ b/docs/ADR-001-enhanced-note-search.md
@@ -0,0 +1,168 @@
+# ADR-001: Enhanced Note Search with Token-Based Relevance Ranking
+
+## Status
+Proposed
+
+## Context
+The current search implementation in the Nextcloud MCP server performs simple substring matching without relevance ranking. The existing method:
+1. Fetches all notes
+2. Performs case-insensitive substring matching on title and content
+3. Returns matches without any ordering by relevance
+
+This approach has several limitations:
+- Requires exact substring matches
+- No ranking by relevance
+- Only finds notes where the exact query string appears
+- Cannot prioritize more important matches (e.g., title vs content)
+- Inefficient for large note collections
+
+We need to improve the search functionality without adding external dependencies to enhance the user experience while maintaining simplicity.
+
+## Decision
+We will implement a token-based search with relevance ranking that:
+1. Splits queries and note content into individual tokens (words)
+2. Matches based on tokens rather than complete substrings
+3. Applies weighted scoring with title matches valued higher than content matches
+4. Sorts results by relevance score
+5. Maintains backward compatibility with the existing API
+
+## Implementation Details
+
+### 1. Query Processing
+The search query will be tokenized (split into individual words), normalized (converted to lowercase), and filtered for stop words if necessary:
+
+```python
+def process_query(query: str) -> list[str]:
+ # Convert to lowercase and split into tokens
+ tokens = query.lower().split()
+ # Filter out very short tokens (optional)
+ tokens = [token for token in tokens if len(token) > 1]
+ # Could add stop word removal here
+ return tokens
+```
+
+### 2. Note Content Processing
+Each note's title and content will be processed in a similar way:
+
+```python
+def process_note_content(note: dict) -> tuple[list[str], list[str]]:
+ # Process title
+ title = note.get("title", "").lower()
+ title_tokens = title.split()
+
+ # Process content
+ content = note.get("content", "").lower()
+ content_tokens = content.split()
+
+ return title_tokens, content_tokens
+```
+
+### 3. Scoring Algorithm
+We'll implement a scoring function that:
+- Assigns higher weight to title matches (e.g., 3x more important than content matches)
+- Considers the percentage of query tokens that match
+- Factors in the frequency of matches
+
+```python
+def calculate_score(query_tokens: list[str], title_tokens: list[str], content_tokens: list[str]) -> float:
+ # Constants for weighting
+ TITLE_WEIGHT = 3.0
+ CONTENT_WEIGHT = 1.0
+
+ score = 0.0
+
+ # Count matches in title
+ title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
+ if query_tokens: # Avoid division by zero
+ title_match_ratio = title_matches / len(query_tokens)
+ score += TITLE_WEIGHT * title_match_ratio
+
+ # Count matches in content
+ content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
+ if query_tokens: # Avoid division by zero
+ content_match_ratio = content_matches / len(query_tokens)
+ score += CONTENT_WEIGHT * content_match_ratio
+
+ # If no tokens matched at all, return zero
+ if title_matches == 0 and content_matches == 0:
+ return 0.0
+
+ return score
+```
+
+### 4. Enhanced Search Implementation
+
+```python
+def notes_search_notes(self, *, query: str):
+ """
+ Search notes using token-based matching with relevance ranking.
+ Returns notes sorted by relevance score.
+ """
+ all_notes = self.notes_get_all()
+ search_results = []
+
+ # Process the query
+ query_tokens = process_query(query)
+
+ # If empty query after processing, return empty results
+ if not query_tokens:
+ return []
+
+ # Process and score each note
+ for note in all_notes:
+ title_tokens, content_tokens = process_note_content(note)
+ score = calculate_score(query_tokens, title_tokens, content_tokens)
+
+ # Only include notes with a non-zero score
+ if score > 0:
+ search_results.append({
+ "id": note.get("id"),
+ "title": note.get("title"),
+ "category": note.get("category"),
+ "modified": note.get("modified"),
+ "_score": score # Include score for sorting (optional field)
+ })
+
+ # Sort by score in descending order
+ search_results.sort(key=lambda x: x["_score"], reverse=True)
+
+ # Remove score field before returning (optional)
+ for result in search_results:
+ if "_score" in result:
+ del result["_score"]
+
+ return search_results
+```
+
+### 5. Performance Considerations
+- The enhanced search still retrieves all notes from the server, which could be inefficient for large collections
+- Future improvements could include caching or building an in-memory index
+- For very large note collections, consider adding pagination to the API
+
+## Consequences
+
+### Benefits
+1. Better search results with matches on individual words instead of exact phrases
+2. Relevant results appear first due to ranking
+3. Title matches are prioritized, matching user expectations
+4. No additional dependencies required
+5. Maintains backward compatibility with existing API
+
+### Limitations
+1. Slightly increased complexity in the search implementation
+2. Still requires fetching all notes for each search operation
+3. No handling of typos or similar words (would require fuzzy matching)
+4. No stemming/lemmatization to match word variations
+
+### Future Potential Enhancements
+1. Add support for phrase queries (exact matches)
+2. Implement an in-memory index for faster repeated searches
+3. Add basic natural language processing features (stemming, stop words)
+4. Support for fuzzy matching to handle typos
+
+## Alternatives Considered
+1. Implementing a full-text search engine (e.g., integrating with Elasticsearch)
+2. Using vector-based semantic search with embeddings
+3. Adding external NLP libraries for more sophisticated text processing
+
+These alternatives were not selected for the initial implementation due to the desire to maintain simplicity and avoid adding dependencies, but could be considered for future enhancements.
diff --git a/docs/nextcloud_notes_image_embedding.md b/docs/nextcloud_notes_image_embedding.md
new file mode 100644
index 0000000..6fe2e70
--- /dev/null
+++ b/docs/nextcloud_notes_image_embedding.md
@@ -0,0 +1,118 @@
+# Working with Images in Nextcloud Notes
+
+This document explains how to properly work with images and attachments in Nextcloud Notes through the MCP server.
+
+## Adding Image Attachments
+
+Images and other files can be attached to notes using the WebDAV protocol. The Nextcloud MCP server handles this through the `add_note_attachment` method:
+
+```python
+# Example: Adding an image attachment to a note
+client.add_note_attachment(
+ note_id=123, # The ID of the note
+ filename="image.png", # The filename for the attachment
+ content=image_bytes, # The binary content of the image
+ mime_type="image/png" # The MIME type
+)
+```
+
+## Embedding Images in Notes
+
+For images to display inline within notes, you must reference them correctly in the note content. There are two methods:
+
+### 1. Markdown Syntax (Recommended)
+
+```markdown
+
+```
+
+For example:
+```markdown
+
+```
+
+### 2. HTML Image Tags
+
+```html
+
+```
+
+For example:
+```html
+
+```
+
+## Storage Location
+
+Image attachments are stored in a hidden directory structure:
+
+```
+/Notes/.attachments.{note_id}/{filename}
+```
+
+This path is accessible via WebDAV, allowing direct file operations.
+
+## Orphaned Attachments Behavior
+
+**Important:** When notes are deleted, their attachments remain in the system. This is the expected behavior of the official Nextcloud Notes app, not a bug in the MCP server implementation.
+
+Consequences:
+- Orphaned attachments accumulate over time
+- No automatic cleanup of attachment directories
+- References to attachments in deleted notes become broken links
+
+## Examples
+
+### Complete Example: Creating a Note with Embedded Image
+
+```python
+from nextcloud_mcp_server.client import NextcloudClient
+import os
+
+# Create client
+client = NextcloudClient.from_env()
+
+# 1. Create the note
+note = client.notes_create_note(
+ title="Note with Embedded Image",
+ content="# Image Example\n\nThis note will have an embedded image.",
+ category="Documentation"
+)
+note_id = note["id"]
+note_etag = note["etag"]
+
+# 2. Read image content
+with open("example.png", "rb") as f:
+ image_content = f.read()
+
+# 3. Upload image as attachment
+client.add_note_attachment(
+ note_id=note_id,
+ filename="example.png",
+ content=image_content,
+ mime_type="image/png"
+)
+
+# 4. Update note content to include image reference
+updated_content = f"""# Image Example
+
+This note has an embedded image below:
+
+
+"""
+
+# 5. Update the note with image reference
+client.notes_update_note(
+ note_id=note_id,
+ etag=note_etag,
+ content=updated_content
+)
+```
+
+## Troubleshooting
+
+If you encounter issues with attachments:
+
+1. **401 Unauthorized errors**: Verify WebDAV permissions in Nextcloud
+2. **Images not displaying**: Check the exact path format (`.attachments.{note_id}/{filename}`)
+3. **Attachment access after note deletion**: This is expected - attachments persist after note deletion
diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py
index f065a43..fc52f4b 100644
--- a/nextcloud_mcp_server/client.py
+++ b/nextcloud_mcp_server/client.py
@@ -1,5 +1,7 @@
import os
import time # Import time for sleep
+import mimetypes
+from io import BytesIO
from httpx import (
Client,
Auth,
@@ -32,8 +34,8 @@ def log_response(response: Response):
class NextcloudClient:
- def __init__(self, base_url: str, auth: Auth | None = None):
-
+ def __init__(self, base_url: str, username: str, auth: Auth | None = None):
+ self.username = username # Store username
self._client = Client(
base_url=base_url,
auth=auth,
@@ -48,7 +50,8 @@ class NextcloudClient:
host = os.environ["NEXTCLOUD_HOST"]
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
- return cls(base_url=host, auth=BasicAuth(username, password))
+ # Pass username to constructor
+ return cls(base_url=host, username=username, auth=BasicAuth(username, password))
def capabilities(self):
@@ -106,7 +109,19 @@ class NextcloudClient:
content: str | None = None,
category: str | None = None,
):
- # body = {"etag": etag} # Removed redundant line
+ # First, get the current note details to check for category change
+ old_note = None
+ try:
+ if category is not None: # Only fetch if category might change
+ old_note = self.notes_get_note(note_id=note_id)
+ old_category = old_note.get("category", "")
+ logger.info(f"Current category for note {note_id}: '{old_category}'")
+ except Exception as e:
+ logger.warning(f"Could not fetch current note {note_id} details before update: {e}")
+ # Continue with update even if we couldn't fetch current details
+ old_note = None
+
+ # Prepare update body
body = {}
if title:
body.update({"title": title})
@@ -118,14 +133,14 @@ class NextcloudClient:
logger.info(
"Attempting to update note %s with etag %s. Body: %s",
note_id,
- etag, # This was current_etag in the loop
+ etag,
body,
)
# Ensure conditional PUT using If-Match header is active
response = self._client.put(
url=f"/apps/notes/api/v1/notes/{note_id}",
json=body,
- headers={"If-Match": f'"{etag}"'}, # This was current_etag in the loop
+ headers={"If-Match": f'"{etag}"'},
)
logger.info(
"Update response for note %s: Status %s, Headers %s",
@@ -134,27 +149,390 @@ class NextcloudClient:
response.headers,
)
response.raise_for_status()
- return response.json()
+ updated_note = response.json()
+
+ # Check for category change and clean up old attachment directory if needed
+ if old_note and category is not None and old_note.get("category", "") != category:
+ logger.info(f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory")
+ try:
+ self._cleanup_old_attachment_directory(note_id=note_id, old_category=old_note.get("category", ""))
+ except Exception as e:
+ logger.error(f"Error cleaning up old attachment directory for note {note_id}: {e}")
+ # Continue with update even if cleanup failed
+
+ return updated_note
def notes_search_notes(self, *, query: str):
+ """
+ Search notes using token-based matching with relevance ranking.
+ Returns notes sorted by relevance score.
+ """
all_notes = self.notes_get_all()
search_results = []
- query_lower = query.lower()
+
+ # Process the query
+ query_tokens = self.process_query(query)
+
+ # If empty query after processing, return empty results
+ if not query_tokens:
+ return []
+
+ # Process and score each note
for note in all_notes:
- title_lower = note.get("title", "").lower()
- content_lower = note.get("content", "").lower()
- if query_lower in title_lower or query_lower in content_lower:
- search_results.append(
- {
- "id": note.get("id"),
- "title": note.get("title"),
- "category": note.get("category"),
- "modified": note.get("modified"),
- }
- )
+ title_tokens, content_tokens = self.process_note_content(note)
+ score = self.calculate_score(query_tokens, title_tokens, content_tokens)
+
+ # Only include notes with a non-zero score
+ if score > 0:
+ search_results.append({
+ "id": note.get("id"),
+ "title": note.get("title"),
+ "category": note.get("category"),
+ "modified": note.get("modified"),
+ "_score": score # Include score for sorting (optional field)
+ })
+
+ # Sort by score in descending order
+ search_results.sort(key=lambda x: x["_score"], reverse=True)
+
+ # Keep score field for debugging
+ # for result in search_results:
+ # if "_score" in result:
+ # del result["_score"]
+
return search_results
+ def process_query(self, query: str) -> list[str]:
+ """
+ Tokenize and normalize the search query.
+ """
+ # Convert to lowercase and split into tokens
+ tokens = query.lower().split()
+ # Filter out very short tokens (optional)
+ tokens = [token for token in tokens if len(token) > 1]
+ # Could add stop word removal here
+ return tokens
+
+ def process_note_content(self, note: dict) -> tuple[list[str], list[str]]:
+ """
+ Tokenize and normalize note title and content.
+ """
+ # Process title
+ title = note.get("title", "").lower()
+ title_tokens = title.split()
+
+ # Process content
+ content = note.get("content", "").lower()
+ content_tokens = content.split()
+
+ return title_tokens, content_tokens
+
+ def calculate_score(self, query_tokens: list[str], title_tokens: list[str], content_tokens: list[str]) -> float:
+ """
+ Calculate a relevance score for a note based on query tokens.
+ """
+ # Constants for weighting
+ TITLE_WEIGHT = 3.0
+ CONTENT_WEIGHT = 1.0
+
+ score = 0.0
+
+ # Count matches in title
+ title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
+ if query_tokens: # Avoid division by zero
+ title_match_ratio = title_matches / len(query_tokens)
+ score += TITLE_WEIGHT * title_match_ratio
+
+ # Count matches in content
+ content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
+ if query_tokens: # Avoid division by zero
+ content_match_ratio = content_matches / len(query_tokens)
+ score += CONTENT_WEIGHT * content_match_ratio
+
+ # If no tokens matched at all, return zero
+ if title_matches == 0 and content_matches == 0:
+ return 0.0
+
+ return score
+
+ def _cleanup_old_attachment_directory(self, *, note_id: int, old_category: str):
+ """
+ Clean up the attachment directory for a note in its old category location.
+ Called after a category change to prevent orphaned directories.
+ """
+ # Construct path to old attachment directory
+ old_category_path_part = f"{old_category}/" if old_category else ""
+ old_attachment_dir_path = f"Notes/{old_category_path_part}.attachments.{note_id}/"
+
+ logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
+ try:
+ delete_result = self.delete_webdav_resource(path=old_attachment_dir_path)
+ logger.info(f"Cleanup of old attachment directory result: {delete_result}")
+ return delete_result
+ except Exception as e:
+ logger.error(f"Error during cleanup of old attachment directory: {e}")
+ raise e
+
+ def delete_webdav_resource(self, *, path: str):
+ """Delete a resource (file or directory) via WebDAV DELETE."""
+ # Ensure path ends with a slash if it's a directory
+ if not path.endswith('/'):
+ # This is a heuristic; a more robust solution would check resource type first
+ # but for the specific case of deleting the attachment directory, this is acceptable.
+ path_with_slash = f"{path}/"
+ else:
+ path_with_slash = path
+
+ webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
+ logger.info("Deleting WebDAV resource: %s", webdav_path)
+
+ headers = {"OCS-APIRequest": "true"}
+ try:
+ # First try a PROPFIND to verify resource exists
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = self._client.request("PROPFIND", webdav_path, headers=propfind_headers)
+ logger.info(f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}")
+ # If we get here with 2xx, the resource exists
+ except HTTPStatusError as e:
+ if e.response.status_code == 404:
+ logger.info(f"Resource '{webdav_path}' doesn't exist, no deletion needed.")
+ return {"status_code": 404}
+ # For other errors, continue with deletion attempt
+
+ # Proceed with deletion
+ response = self._client.delete(webdav_path, headers=headers)
+ response.raise_for_status() # Raises for 4xx/5xx status codes
+ logger.info("Successfully deleted WebDAV resource '%s' (Status: %s)", webdav_path, response.status_code)
+ # DELETE typically returns 204 No Content on success
+ return {"status_code": response.status_code}
+
+ except HTTPStatusError as e:
+ logger.error(
+ "HTTP error deleting WebDAV resource '%s': %s",
+ webdav_path,
+ e,
+ )
+ # It's expected to get a 404 if the resource doesn't exist, which is fine.
+ # We only re-raise if it's not a 404.
+ if e.response.status_code != 404:
+ raise e
+ else:
+ logger.info("Resource '%s' not found, no deletion needed.", webdav_path)
+ return {"status_code": 404} # Indicate resource was not found
+ except Exception as e:
+ logger.error(
+ "Unexpected error deleting WebDAV resource '%s': %s",
+ webdav_path,
+ e,
+ )
+ raise e
+
def notes_delete_note(self, *, note_id: int):
+ """Deletes a note via API and attempts to delete its attachment directory via WebDAV."""
+ # Fetch note details first to get the category for path construction
+ try:
+ note_details = self.notes_get_note(note_id=note_id)
+ category = note_details.get("category", "")
+
+ # Check for other potential categories (if any note was moved between categories)
+ # We can't reliably detect this without a dedicated tracking mechanism, but we can
+ # implement a basic check for common category names and empty category
+ potential_categories = []
+ if category:
+ potential_categories.append(category) # Current category first
+
+ # Add empty category (uncategorized notes)
+ if category != "":
+ potential_categories.append("")
+
+ # We could add logic here to check for other common categories if needed
+
+ logger.info(f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}")
+ except HTTPStatusError as e:
+ # If note doesn't exist (404), we can't delete attachments anyway.
+ # Re-raise other errors.
+ if e.response.status_code == 404:
+ logger.warning(f"Note {note_id} not found when attempting delete. Skipping attachment cleanup.")
+ # Still raise the 404 as the primary delete operation failed
+ raise e
+ else:
+ logger.error(f"Error fetching note {note_id} details before deleting attachments: {e}")
+ raise e # Re-raise unexpected errors during fetch
+
+ # Proceed with API note deletion
+ logger.info(f"Deleting note {note_id} via API.")
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
- response.raise_for_status()
- return response.json()
+ response.raise_for_status() # Raise if API deletion fails
+ logger.info(f"Note {note_id} deleted successfully via API.")
+ json_response = response.json() # Usually empty on success
+
+ # Now, attempt to delete the associated attachments directory via WebDAV for each potential category
+ for cat in potential_categories:
+ cat_path_part = f"{cat}/" if cat else ""
+ attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
+
+ logger.info(f"Attempting to delete attachment directory for note {note_id} in category '{cat}' via WebDAV: {attachment_dir_path}")
+ try:
+ # delete_webdav_resource expects path relative to user's files dir
+ delete_result = self.delete_webdav_resource(path=attachment_dir_path)
+ logger.info(f"WebDAV deletion for category '{cat}' attachment directory: {delete_result}")
+ except Exception as e:
+ # Log the error but don't re-raise, as API note deletion itself was successful
+ # Also, we want to try other potential categories even if one fails
+ logger.error(f"Failed during WebDAV deletion for category '{cat}' attachment directory: {e}")
+
+ return json_response
+
+ # Removed incorrect get_note_attachment method that used Notes API
+
+ def _get_webdav_base_path(self) -> str:
+ """Helper to get the base WebDAV path for the authenticated user."""
+ # Use the stored username
+ return f"/remote.php/dav/files/{self.username}"
+
+ # Removed _get_note_attachment_webdav_path helper
+
+ def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, category: str | None = None, mime_type: str | None = None):
+ """
+ Add/Update an attachment to a note via WebDAV PUT.
+ Requires the caller to provide the note's category.
+ """
+ # Construct paths based on provided category
+ webdav_base = self._get_webdav_base_path()
+ category_path_part = f"{category}/" if category else ""
+ attachment_dir_segment = f".attachments.{note_id}"
+ parent_dir_webdav_rel_path = f"Notes/{category_path_part}{attachment_dir_segment}"
+ parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" # Full path for MKCOL
+ attachment_path = f"{parent_dir_path}/{filename}" # Full path for PUT
+
+ logger.info(f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}")
+
+ # Log current auth settings to diagnose the issue
+ logger.info("WebDAV auth settings - Username: %s, Auth Type: %s",
+ self.username, type(self._client.auth).__name__)
+
+ if not mime_type:
+ mime_type, _ = mimetypes.guess_type(filename)
+ if not mime_type:
+ mime_type = "application/octet-stream" # Default if guessing fails
+
+ headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
+ try:
+ # First check if we can access WebDAV at all with current credentials
+ # by checking the Notes directory
+ notes_dir_path = f"{webdav_base}/Notes"
+ logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
+
+ # Log details of the auth being used by the client for this specific request
+ if self._client.auth:
+ auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization")
+ logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow")
+ else:
+ logger.info("No httpx.Auth object configured on the client for PROPFIND (Notes dir).")
+
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers)
+ notes_dir_response = self._client.request("PROPFIND", notes_dir_path,
+ headers=propfind_headers)
+
+ if notes_dir_response.status_code == 401:
+ logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.")
+ raise HTTPStatusError(
+ f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
+ request=notes_dir_response.request,
+ response=notes_dir_response
+ )
+ elif notes_dir_response.status_code >= 400:
+ logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code)
+ notes_dir_response.raise_for_status()
+ else:
+ logger.info("Successfully accessed WebDAV Notes directory (Status: %s)",
+ notes_dir_response.status_code)
+
+ # Ensure the parent directory exists using MKCOL
+ # parent_dir_path is now determined by the helper method
+ logger.info("Ensuring attachments directory exists: %s", parent_dir_path)
+ mkcol_headers = {"OCS-APIRequest": "true"}
+ logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers)
+ mkcol_response = self._client.request("MKCOL", parent_dir_path, headers=mkcol_headers)
+ # MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
+ # We can ignore 405, but raise for other errors
+ if mkcol_response.status_code not in [201, 405]:
+ logger.warning(
+ "Unexpected status code %s when creating attachments directory",
+ mkcol_response.status_code
+ )
+ mkcol_response.raise_for_status()
+ else:
+ logger.info("Created/verified directory: %s (Status: %s)",
+ parent_dir_path, mkcol_response.status_code)
+
+ # Proceed with the PUT request
+ logger.info("Putting attachment file to: %s", attachment_path)
+ response = self._client.put(
+ attachment_path,
+ content=content,
+ headers=headers
+ )
+ response.raise_for_status() # Raises for 4xx/5xx status codes
+ logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code)
+ # PUT typically returns 201 Created or 204 No Content on success
+ return {"status_code": response.status_code} # Return status or relevant info
+
+ except HTTPStatusError as e:
+ logger.error(
+ "HTTP error uploading attachment '%s' to note %s: %s",
+ filename,
+ note_id,
+ e,
+ )
+ raise e
+ except Exception as e:
+ logger.error(
+ "Unexpected error uploading attachment '%s' to note %s: %s",
+ filename,
+ note_id,
+ e,
+ )
+ raise e
+
+ def get_note_attachment(self, *, note_id: int, filename: str, category: str | None = None):
+ """
+ Fetch a specific attachment from a note via WebDAV GET.
+ Requires the caller to provide the note's category.
+ """
+ # Construct path based on provided category
+ webdav_base = self._get_webdav_base_path()
+ category_path_part = f"{category}/" if category else ""
+ attachment_dir_segment = f".attachments.{note_id}"
+ attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
+
+ logger.info(f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}")
+
+ try:
+ response = self._client.get(attachment_path)
+ response.raise_for_status()
+
+ content = response.content
+ mime_type = response.headers.get("content-type", "application/octet-stream")
+
+ logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content))
+ return content, mime_type
+
+ except HTTPStatusError as e:
+ logger.error(
+ "HTTP error fetching attachment '%s' for note %s: %s",
+ filename,
+ note_id,
+ e,
+ )
+ raise e
+ except Exception as e:
+ logger.error(
+ "Unexpected error fetching attachment '%s' for note %s: %s",
+ filename,
+ note_id,
+ e,
+ )
+ raise e
diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py
index e27ff8e..44e5ce1 100644
--- a/nextcloud_mcp_server/config.py
+++ b/nextcloud_mcp_server/config.py
@@ -20,7 +20,7 @@ LOGGING_CONFIG = {
"loggers": {
"": {
"handlers": ["default"],
- "level": "INFO",
+ "level": "DEBUG",
},
"httpx": {
"handlers": ["default"],
diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py
index 0e7b142..fce6e46 100644
--- a/nextcloud_mcp_server/server.py
+++ b/nextcloud_mcp_server/server.py
@@ -7,6 +7,7 @@ from mcp.server.fastmcp import FastMCP, Context
from mcp.server import Server
from collections.abc import AsyncIterator
from nextcloud_mcp_server.client import NextcloudClient
+import asyncio # Import asyncio
setup_logging()
@@ -24,6 +25,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
# Initialize on startup
logger.info("Creating Nextcloud client")
client = NextcloudClient.from_env()
+ # Add a small delay to allow client initialization to complete
+ logger.info("Waiting 2 seconds for client initialization...")
+ logger.info("Client initialization wait complete.")
try:
yield AppContext(client=client)
finally:
@@ -108,6 +112,28 @@ def nc_notes_delete_note(note_id: int, ctx: Context):
return client.notes_delete_note(note_id=note_id)
+@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
+def nc_notes_get_attachment(note_id: int, attachment_filename: str):
+ """Get a specific attachment from a note"""
+ ctx = mcp.get_context()
+ client: NextcloudClient = ctx.request_context.lifespan_context.client
+ # Assuming a method get_note_attachment exists in the client
+ # This method should return the raw content and determine the mime type
+ content, mime_type = client.get_note_attachment(
+ note_id=note_id, filename=attachment_filename
+ )
+ return {
+ "contents": [
+ {
+ # Use uppercase 'Notes' to match the decorator
+ "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
+ "mimeType": mime_type, # Client needs to determine this
+ "data": content, # Return raw bytes/data
+ }
+ ]
+ }
+
+
def run():
mcp.run()
diff --git a/poetry.lock b/poetry.lock
deleted file mode 100644
index 4038539..0000000
--- a/poetry.lock
+++ /dev/null
@@ -1,976 +0,0 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
-
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-description = "Reusable constraint types to use with typing.Annotated"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
- {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
-]
-
-[[package]]
-name = "anyio"
-version = "4.9.0"
-description = "High level compatibility layer for multiple asynchronous event loop implementations"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
- {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
-]
-
-[package.dependencies]
-idna = ">=2.8"
-sniffio = ">=1.1"
-typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
-
-[package.extras]
-doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
-test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
-trio = ["trio (>=0.26.1)"]
-
-[[package]]
-name = "asttokens"
-version = "3.0.0"
-description = "Annotate AST trees with source code positions"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"},
- {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"},
-]
-
-[package.extras]
-astroid = ["astroid (>=2,<4)"]
-test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"]
-
-[[package]]
-name = "black"
-version = "25.1.0"
-description = "The uncompromising code formatter."
-optional = false
-python-versions = ">=3.9"
-groups = ["dev"]
-files = [
- {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"},
- {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"},
- {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"},
- {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"},
- {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"},
- {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"},
- {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"},
- {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"},
- {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"},
- {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"},
- {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"},
- {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"},
- {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"},
- {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"},
- {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"},
- {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"},
- {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"},
- {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"},
- {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"},
- {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"},
- {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"},
- {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"},
-]
-
-[package.dependencies]
-click = ">=8.0.0"
-mypy-extensions = ">=0.4.3"
-packaging = ">=22.0"
-pathspec = ">=0.9.0"
-platformdirs = ">=2"
-
-[package.extras]
-colorama = ["colorama (>=0.4.3)"]
-d = ["aiohttp (>=3.10)"]
-jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
-uvloop = ["uvloop (>=0.15.2)"]
-
-[[package]]
-name = "certifi"
-version = "2025.4.26"
-description = "Python package for providing Mozilla's CA Bundle."
-optional = false
-python-versions = ">=3.6"
-groups = ["main"]
-files = [
- {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"},
- {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"},
-]
-
-[[package]]
-name = "click"
-version = "8.1.8"
-description = "Composable command line interface toolkit"
-optional = false
-python-versions = ">=3.7"
-groups = ["main", "dev"]
-files = [
- {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
- {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-description = "Cross-platform colored terminal text."
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-groups = ["main", "dev"]
-files = [
- {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
- {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
-]
-markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
-
-[[package]]
-name = "decorator"
-version = "5.2.1"
-description = "Decorators for Humans"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"},
- {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
-]
-
-[[package]]
-name = "executing"
-version = "2.2.0"
-description = "Get the currently executing AST node of a frame, and other information"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"},
- {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"},
-]
-
-[package.extras]
-tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
- {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-description = "A minimal low-level HTTP client."
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
- {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
-]
-
-[package.dependencies]
-certifi = "*"
-h11 = ">=0.16"
-
-[package.extras]
-asyncio = ["anyio (>=4.0,<5.0)"]
-http2 = ["h2 (>=3,<5)"]
-socks = ["socksio (==1.*)"]
-trio = ["trio (>=0.22.0,<1.0)"]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-description = "The next generation HTTP client."
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
- {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
-]
-
-[package.dependencies]
-anyio = "*"
-certifi = "*"
-httpcore = "==1.*"
-idna = "*"
-
-[package.extras]
-brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
-cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
-http2 = ["h2 (>=3,<5)"]
-socks = ["socksio (==1.*)"]
-zstd = ["zstandard (>=0.18.0)"]
-
-[[package]]
-name = "httpx-sse"
-version = "0.4.0"
-description = "Consume Server-Sent Event (SSE) messages with HTTPX."
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
- {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
-]
-
-[[package]]
-name = "idna"
-version = "3.10"
-description = "Internationalized Domain Names in Applications (IDNA)"
-optional = false
-python-versions = ">=3.6"
-groups = ["main"]
-files = [
- {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
- {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
-]
-
-[package.extras]
-all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-description = "brain-dead simple config-ini parsing"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
- {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
-]
-
-[[package]]
-name = "ipython"
-version = "9.2.0"
-description = "IPython: Productive Interactive Computing"
-optional = false
-python-versions = ">=3.11"
-groups = ["dev"]
-files = [
- {file = "ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6"},
- {file = "ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-decorator = "*"
-ipython-pygments-lexers = "*"
-jedi = ">=0.16"
-matplotlib-inline = "*"
-pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""}
-prompt_toolkit = ">=3.0.41,<3.1.0"
-pygments = ">=2.4.0"
-stack_data = "*"
-traitlets = ">=5.13.0"
-typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""}
-
-[package.extras]
-all = ["ipython[doc,matplotlib,test,test-extra]"]
-black = ["black"]
-doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"]
-matplotlib = ["matplotlib"]
-test = ["packaging", "pytest", "pytest-asyncio (<0.22)", "testpath"]
-test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"]
-
-[[package]]
-name = "ipython-pygments-lexers"
-version = "1.1.1"
-description = "Defines a variety of Pygments lexers for highlighting IPython code."
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
- {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
-]
-
-[package.dependencies]
-pygments = "*"
-
-[[package]]
-name = "jedi"
-version = "0.19.2"
-description = "An autocompletion tool for Python that can be used for text editors."
-optional = false
-python-versions = ">=3.6"
-groups = ["dev"]
-files = [
- {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"},
- {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"},
-]
-
-[package.dependencies]
-parso = ">=0.8.4,<0.9.0"
-
-[package.extras]
-docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"]
-qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
-testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"]
-
-[[package]]
-name = "markdown-it-py"
-version = "3.0.0"
-description = "Python port of markdown-it. Markdown parsing, done right!"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
- {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
-]
-
-[package.dependencies]
-mdurl = ">=0.1,<1.0"
-
-[package.extras]
-benchmarking = ["psutil", "pytest", "pytest-benchmark"]
-code-style = ["pre-commit (>=3.0,<4.0)"]
-compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
-linkify = ["linkify-it-py (>=1,<3)"]
-plugins = ["mdit-py-plugins"]
-profiling = ["gprof2dot"]
-rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
-testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
-
-[[package]]
-name = "matplotlib-inline"
-version = "0.1.7"
-description = "Inline Matplotlib backend for Jupyter"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"},
- {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"},
-]
-
-[package.dependencies]
-traitlets = "*"
-
-[[package]]
-name = "mcp"
-version = "1.7.1"
-description = "Model Context Protocol SDK"
-optional = false
-python-versions = ">=3.10"
-groups = ["main"]
-files = [
- {file = "mcp-1.7.1-py3-none-any.whl", hash = "sha256:f7e6108977db6d03418495426c7ace085ba2341b75197f8727f96f9cfd30057a"},
- {file = "mcp-1.7.1.tar.gz", hash = "sha256:eb4f1f53bd717f75dda8a1416e00804b831a8f3c331e23447a03b78f04b43a6e"},
-]
-
-[package.dependencies]
-anyio = ">=4.5"
-httpx = ">=0.27"
-httpx-sse = ">=0.4"
-pydantic = ">=2.7.2,<3.0.0"
-pydantic-settings = ">=2.5.2"
-python-dotenv = {version = ">=1.0.0", optional = true, markers = "extra == \"cli\""}
-python-multipart = ">=0.0.9"
-sse-starlette = ">=1.6.1"
-starlette = ">=0.27"
-typer = {version = ">=0.12.4", optional = true, markers = "extra == \"cli\""}
-uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""}
-
-[package.extras]
-cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"]
-rich = ["rich (>=13.9.4)"]
-ws = ["websockets (>=15.0.1)"]
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-description = "Markdown URL utilities"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
- {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
-]
-
-[[package]]
-name = "mypy-extensions"
-version = "1.1.0"
-description = "Type system extensions for programs checked with the mypy type checker."
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
- {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
-]
-
-[[package]]
-name = "packaging"
-version = "25.0"
-description = "Core utilities for Python packages"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
- {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
-]
-
-[[package]]
-name = "parso"
-version = "0.8.4"
-description = "A Python Parser"
-optional = false
-python-versions = ">=3.6"
-groups = ["dev"]
-files = [
- {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"},
- {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"},
-]
-
-[package.extras]
-qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
-testing = ["docopt", "pytest"]
-
-[[package]]
-name = "pathspec"
-version = "0.12.1"
-description = "Utility library for gitignore style pattern matching of file paths."
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
- {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
-]
-
-[[package]]
-name = "pexpect"
-version = "4.9.0"
-description = "Pexpect allows easy control of interactive console applications."
-optional = false
-python-versions = "*"
-groups = ["dev"]
-markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
-files = [
- {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
- {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
-]
-
-[package.dependencies]
-ptyprocess = ">=0.5"
-
-[[package]]
-name = "platformdirs"
-version = "4.3.7"
-description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
-optional = false
-python-versions = ">=3.9"
-groups = ["dev"]
-files = [
- {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"},
- {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"},
-]
-
-[package.extras]
-docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
-type = ["mypy (>=1.14.1)"]
-
-[[package]]
-name = "pluggy"
-version = "1.5.0"
-description = "plugin and hook calling mechanisms for python"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
- {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
-]
-
-[package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.51"
-description = "Library for building powerful interactive command lines in Python"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07"},
- {file = "prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed"},
-]
-
-[package.dependencies]
-wcwidth = "*"
-
-[[package]]
-name = "ptyprocess"
-version = "0.7.0"
-description = "Run a subprocess in a pseudo terminal"
-optional = false
-python-versions = "*"
-groups = ["dev"]
-markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
-files = [
- {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
- {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
-]
-
-[[package]]
-name = "pure-eval"
-version = "0.2.3"
-description = "Safely evaluate AST nodes without side effects"
-optional = false
-python-versions = "*"
-groups = ["dev"]
-files = [
- {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
- {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
-]
-
-[package.extras]
-tests = ["pytest"]
-
-[[package]]
-name = "pydantic"
-version = "2.11.4"
-description = "Data validation using Python type hints"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb"},
- {file = "pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d"},
-]
-
-[package.dependencies]
-annotated-types = ">=0.6.0"
-pydantic-core = "2.33.2"
-typing-extensions = ">=4.12.2"
-typing-inspection = ">=0.4.0"
-
-[package.extras]
-email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
-
-[[package]]
-name = "pydantic-core"
-version = "2.33.2"
-description = "Core functionality for Pydantic validation and serialization"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
- {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
-]
-
-[package.dependencies]
-typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
-
-[[package]]
-name = "pydantic-settings"
-version = "2.9.1"
-description = "Settings management using Pydantic"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"},
- {file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"},
-]
-
-[package.dependencies]
-pydantic = ">=2.7.0"
-python-dotenv = ">=0.21.0"
-typing-inspection = ">=0.4.0"
-
-[package.extras]
-aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"]
-azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
-gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
-toml = ["tomli (>=2.0.1)"]
-yaml = ["pyyaml (>=6.0.1)"]
-
-[[package]]
-name = "pygments"
-version = "2.19.1"
-description = "Pygments is a syntax highlighting package written in Python."
-optional = false
-python-versions = ">=3.8"
-groups = ["main", "dev"]
-files = [
- {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"},
- {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"},
-]
-
-[package.extras]
-windows-terminal = ["colorama (>=0.4.6)"]
-
-[[package]]
-name = "pytest"
-version = "8.3.5"
-description = "pytest: simple powerful testing with Python"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"},
- {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-iniconfig = "*"
-packaging = "*"
-pluggy = ">=1.5,<2"
-
-[package.extras]
-dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
-
-[[package]]
-name = "python-dotenv"
-version = "1.1.0"
-description = "Read key-value pairs from a .env file and set them as environment variables"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"},
- {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"},
-]
-
-[package.extras]
-cli = ["click (>=5.0)"]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.20"
-description = "A streaming multipart parser for Python"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"},
- {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
-]
-
-[[package]]
-name = "rich"
-version = "14.0.0"
-description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
-optional = false
-python-versions = ">=3.8.0"
-groups = ["main"]
-files = [
- {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"},
- {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"},
-]
-
-[package.dependencies]
-markdown-it-py = ">=2.2.0"
-pygments = ">=2.13.0,<3.0.0"
-
-[package.extras]
-jupyter = ["ipywidgets (>=7.5.1,<9)"]
-
-[[package]]
-name = "shellingham"
-version = "1.5.4"
-description = "Tool to Detect Surrounding Shell"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
- {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-description = "Sniff out which async library your code is running under"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
- {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
-]
-
-[[package]]
-name = "sse-starlette"
-version = "2.3.4"
-description = "SSE plugin for Starlette"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "sse_starlette-2.3.4-py3-none-any.whl", hash = "sha256:b8100694f3f892b133d0f7483acb7aacfcf6ed60f863b31947664b6dc74e529f"},
- {file = "sse_starlette-2.3.4.tar.gz", hash = "sha256:0ffd6bed217cdbb74a84816437c609278003998b4991cd2e6872d0b35130e4d5"},
-]
-
-[package.dependencies]
-anyio = ">=4.7.0"
-starlette = ">=0.41.3"
-
-[package.extras]
-examples = ["fastapi"]
-uvicorn = ["uvicorn (>=0.34.0)"]
-
-[[package]]
-name = "stack-data"
-version = "0.6.3"
-description = "Extract data from python stack frames and tracebacks for informative displays"
-optional = false
-python-versions = "*"
-groups = ["dev"]
-files = [
- {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
- {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
-]
-
-[package.dependencies]
-asttokens = ">=2.1.0"
-executing = ">=1.2.0"
-pure-eval = "*"
-
-[package.extras]
-tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
-
-[[package]]
-name = "starlette"
-version = "0.46.2"
-description = "The little ASGI library that shines."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
- {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
-]
-
-[package.dependencies]
-anyio = ">=3.6.2,<5"
-
-[package.extras]
-full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
-
-[[package]]
-name = "traitlets"
-version = "5.14.3"
-description = "Traitlets Python configuration system"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-files = [
- {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"},
- {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"},
-]
-
-[package.extras]
-docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
-test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
-
-[[package]]
-name = "typer"
-version = "0.15.3"
-description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd"},
- {file = "typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c"},
-]
-
-[package.dependencies]
-click = ">=8.0.0"
-rich = ">=10.11.0"
-shellingham = ">=1.3.0"
-typing-extensions = ">=3.7.4.3"
-
-[[package]]
-name = "typing-extensions"
-version = "4.13.2"
-description = "Backported and Experimental Type Hints for Python 3.8+"
-optional = false
-python-versions = ">=3.8"
-groups = ["main", "dev"]
-files = [
- {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
- {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
-]
-markers = {dev = "python_version < \"3.12\""}
-
-[[package]]
-name = "typing-inspection"
-version = "0.4.0"
-description = "Runtime typing introspection tools"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"},
- {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"},
-]
-
-[package.dependencies]
-typing-extensions = ">=4.12.0"
-
-[[package]]
-name = "uvicorn"
-version = "0.34.2"
-description = "The lightning-fast ASGI server."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "sys_platform != \"emscripten\""
-files = [
- {file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"},
- {file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
-]
-
-[package.dependencies]
-click = ">=7.0"
-h11 = ">=0.8"
-
-[package.extras]
-standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
-
-[[package]]
-name = "wcwidth"
-version = "0.2.13"
-description = "Measures the displayed width of unicode strings in a terminal"
-optional = false
-python-versions = "*"
-groups = ["dev"]
-files = [
- {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
- {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
-]
-
-[metadata]
-lock-version = "2.1"
-python-versions = ">=3.11"
-content-hash = "9f0b7b38edcfb60fb521fd54a2b43c2a8d8ab2e3bf0c7b5e994a4999fc9d954e"
diff --git a/pyproject.toml b/pyproject.toml
index 59124a0..eb79a90 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
-version = "0.1.0"
+version = "0.1.3"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -9,13 +9,17 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp[cli] (>=1.7,<1.8)",
- "httpx (>=0.28.1,<0.29.0)"
+ "httpx (>=0.28.1,<0.29.0)",
+ "pillow (>=11.2.1,<12.0.0)"
]
[project.scripts]
nc-mcp-server = "nextcloud_mcp_server.server:run"
[tool.pytest.ini_options]
+log_cli = 1
+log_cli_level = "WARN"
+log_level = "WARN"
markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
]
@@ -25,7 +29,10 @@ markers = [
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
-[tool.poetry.group.dev.dependencies]
-black = "25.1.0"
-ipython = "9.2.0"
-pytest = "8.3.5"
+[dependency-groups]
+dev = [
+ "black>=25.1.0",
+ "ipython>=9.2.0",
+ "pytest>=8.3.5",
+ "pytest-cov>=6.1.1",
+]
diff --git a/retrieved_image.png b/retrieved_image.png
new file mode 100644
index 0000000..fd67dba
Binary files /dev/null and b/retrieved_image.png differ
diff --git a/sample_image.png b/sample_image.png
new file mode 100644
index 0000000..fd67dba
Binary files /dev/null and b/sample_image.png differ
diff --git a/sample_image.py b/sample_image.py
new file mode 100644
index 0000000..b3e0139
--- /dev/null
+++ b/sample_image.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+from PIL import Image, ImageDraw
+
+# Create a simple image (a red square with some text)
+img = Image.new('RGB', (200, 200), color = (255, 255, 255))
+draw = ImageDraw.Draw(img)
+draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0))
+draw.text((40, 100), "Nextcloud MCP", fill=(255, 255, 255))
+img.save('sample_image.png')
+
+print("Image created successfully: sample_image.png")
diff --git a/test_delete_note_with_attachment.py b/test_delete_note_with_attachment.py
new file mode 100644
index 0000000..e591cfc
--- /dev/null
+++ b/test_delete_note_with_attachment.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+import sys
+import time
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # 1. Create a new test note
+ test_title = "Test Note for Deletion with Attachment"
+ print(f"Creating test note: {test_title}...")
+ note = client.notes_create_note(
+ title=test_title,
+ content="This note will be deleted but its attachment should remain.",
+ category="Test"
+ )
+ note_id = note["id"]
+ print(f"Note created with ID: {note_id}")
+
+ # 2. Attach the existing image to the note
+ print(f"Attaching image to note {note_id}...")
+ with open("sample_image.png", 'rb') as f:
+ image_content = f.read()
+
+ upload_response = client.add_note_attachment(
+ note_id=note_id,
+ filename="deletion_test_image.png",
+ content=image_content,
+ mime_type="image/png"
+ )
+ print(f"Image attached successfully (Status: {upload_response['status_code']}).")
+
+ # 3. Verify the attachment exists
+ print(f"Verifying attachment exists...")
+ content, mime_type = client.get_note_attachment(
+ note_id=note_id,
+ filename="deletion_test_image.png"
+ )
+ print(f"Attachment verified (Size: {len(content)} bytes)")
+
+ # 4. Delete the note
+ print(f"\nDeleting note {note_id}...")
+ response = client.notes_delete_note(note_id=note_id)
+ print(f"Note deleted successfully.")
+
+ # Wait a moment for deletion to process
+ time.sleep(1)
+
+ # 5. Verify the note is gone
+ print("\nVerifying note is deleted...")
+ try:
+ client.notes_get_note(note_id=note_id)
+ print("ERROR: Note still exists!")
+ return 1
+ except Exception as e:
+ print(f"Note confirmed deleted (404 Not Found expected): {e}")
+
+ # 6. Check if attachment still exists (expected behavior)
+ print("\nChecking if attachment still exists (orphaned)...")
+ try:
+ content, mime_type = client.get_note_attachment(
+ note_id=note_id,
+ filename="deletion_test_image.png"
+ )
+ print("EXPECTED BEHAVIOR: Attachment still exists after note deletion!")
+ print(f"Attachment size: {len(content)} bytes")
+ print(f"This matches the documented behavior of Nextcloud Notes.")
+
+ # Save the orphaned attachment to verify
+ output_path = "orphaned_attachment.png"
+ with open(output_path, 'wb') as f:
+ f.write(content)
+ print(f"Saved orphaned attachment to: {output_path}")
+
+ return 0
+ except Exception as e:
+ print(f"Unexpected: Attachment was deleted with note: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..12cca15
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,108 @@
+import pytest
+import os
+import logging
+import uuid
+import time
+from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
+
+logger = logging.getLogger(__name__)
+
+@pytest.fixture(scope="session")
+def nc_client() -> NextcloudClient:
+ """
+ Fixture to create a NextcloudClient instance for integration tests.
+ Uses environment variables for configuration.
+ """
+ assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
+ assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
+ assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
+ logger.info("Creating session-scoped NextcloudClient from environment variables.")
+ client = NextcloudClient.from_env()
+ # Optional: Perform a quick check like getting capabilities to ensure connection works
+ try:
+ client.capabilities()
+ logger.info("NextcloudClient session fixture initialized and capabilities checked.")
+ except Exception as e:
+ logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
+ pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
+ return client
+
+@pytest.fixture
+def temporary_note(nc_client: NextcloudClient):
+ """
+ Fixture to create a temporary note for a test and ensure its deletion afterward.
+ Yields the created note dictionary.
+ """
+ note_id = None
+ unique_suffix = uuid.uuid4().hex[:8]
+ note_title = f"Temporary Test Note {unique_suffix}"
+ note_content = f"Content for temporary note {unique_suffix}"
+ note_category = "TemporaryTesting"
+ created_note_data = None
+
+ logger.info(f"Creating temporary note: {note_title}")
+ try:
+ created_note_data = nc_client.notes_create_note(
+ title=note_title, content=note_content, category=note_category
+ )
+ note_id = created_note_data.get("id")
+ if not note_id:
+ pytest.fail("Failed to get ID from created temporary note.")
+
+ logger.info(f"Temporary note created with ID: {note_id}")
+ yield created_note_data # Provide the created note data to the test
+
+ finally:
+ if note_id:
+ logger.info(f"Cleaning up temporary note ID: {note_id}")
+ try:
+ nc_client.notes_delete_note(note_id=note_id)
+ logger.info(f"Successfully deleted temporary note ID: {note_id}")
+ except HTTPStatusError as e:
+ # Ignore 404 if note was already deleted by the test itself
+ if e.response.status_code != 404:
+ logger.error(f"HTTP error deleting temporary note {note_id}: {e}")
+ else:
+ logger.warning(f"Temporary note {note_id} already deleted (404).")
+ except Exception as e:
+ logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
+
+@pytest.fixture
+def temporary_note_with_attachment(nc_client: NextcloudClient, temporary_note: dict):
+ """
+ Fixture that creates a temporary note, adds an attachment, and cleans up both.
+ Yields a tuple: (note_data, attachment_filename, attachment_content).
+ Depends on the temporary_note fixture.
+ """
+ note_data = temporary_note
+ note_id = note_data["id"]
+ note_category = note_data.get("category") # Get category from the note data
+ unique_suffix = uuid.uuid4().hex[:8]
+ attachment_filename = f"temp_attach_{unique_suffix}.txt"
+ attachment_content = f"Content for {attachment_filename}".encode('utf-8')
+ attachment_mime = "text/plain"
+
+ logger.info(f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')")
+ try:
+ # Pass the category to add_note_attachment
+ upload_response = nc_client.add_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ content=attachment_content,
+ category=note_category, # Pass the fetched category
+ mime_type=attachment_mime
+ )
+ assert upload_response.get("status_code") in [201, 204], f"Failed to upload attachment: {upload_response}"
+ logger.info(f"Attachment '{attachment_filename}' added successfully.")
+
+ yield note_data, attachment_filename, attachment_content
+
+ # Cleanup for the attachment is handled by the notes_delete_note call
+ # in the temporary_note fixture's finally block (which deletes the .attachments dir)
+
+ except Exception as e:
+ logger.error(f"Failed to add attachment in fixture: {e}")
+ pytest.fail(f"Fixture setup failed during attachment upload: {e}")
+
+ # Note: The temporary_note fixture's finally block will handle note deletion,
+ # which should also trigger the WebDAV directory deletion attempt.
diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py
new file mode 100644
index 0000000..6745575
--- /dev/null
+++ b/tests/integration/test_attachments.py
@@ -0,0 +1,280 @@
+import pytest
+import logging
+import time
+import uuid
+from httpx import HTTPStatusError
+
+from nextcloud_mcp_server.client import NextcloudClient
+
+# Note: nc_client fixture is session-scoped in conftest.py
+# Note: temporary_note and temporary_note_with_attachment fixtures are function-scoped in conftest.py
+
+logger = logging.getLogger(__name__)
+
+# Mark all tests in this module as integration tests
+pytestmark = pytest.mark.integration
+
+def test_attachments_add_and_get(nc_client: NextcloudClient, temporary_note_with_attachment: tuple):
+ """
+ Tests adding an attachment (via fixture) and retrieving it.
+ """
+ note_data, attachment_filename, attachment_content = temporary_note_with_attachment
+ note_id = note_data["id"]
+ note_category = note_data.get("category") # Get category from fixture data
+
+ logger.info(f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}")
+ # Pass category to get_note_attachment
+ retrieved_content, retrieved_mime = nc_client.get_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ category=note_category
+ )
+ logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
+
+ assert retrieved_content == attachment_content
+ assert "text/plain" in retrieved_mime # Fixture uses text/plain
+ logger.info("Retrieved attachment content and mime type verified successfully.")
+
+def test_attachments_add_to_note_with_category(nc_client: NextcloudClient, temporary_note: dict):
+ """
+ Tests adding and retrieving an attachment specifically for a note that has a category.
+ Uses temporary_note fixture and adds attachment manually within the test.
+ """
+ note_data = temporary_note # Note created by fixture (has category 'TemporaryTesting')
+ note_id = note_data["id"]
+ note_category = note_data["category"]
+ logger.info(f"Using note ID: {note_id} with category '{note_category}' for attachment test.")
+
+ # Add attachment within the test
+ unique_suffix = uuid.uuid4().hex[:8]
+ attachment_filename = f"category_attach_{unique_suffix}.txt"
+ attachment_content = f"Content for {attachment_filename}".encode('utf-8')
+ attachment_mime = "text/plain"
+
+ logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
+ # Pass category to add_note_attachment
+ upload_response = nc_client.add_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ content=attachment_content,
+ category=note_category, # Pass the note's category
+ mime_type=attachment_mime
+ )
+ assert upload_response and "status_code" in upload_response
+ assert upload_response["status_code"] in [201, 204]
+ logger.info(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
+ time.sleep(1)
+
+ # Get and Verify Attachment
+ logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
+ # Pass category to get_note_attachment
+ retrieved_content, retrieved_mime = nc_client.get_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ category=note_category # Pass the note's category
+ )
+ logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
+
+ assert retrieved_content == attachment_content
+ assert attachment_mime in retrieved_mime
+ logger.info("Retrieved attachment content and mime type verified successfully for note with category.")
+ # Cleanup is handled by the temporary_note fixture
+
+def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporary_note_with_attachment: tuple):
+ """
+ Tests that the attachment (and its directory) are deleted when the parent note is deleted.
+ Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture.
+ """
+ note_data, attachment_filename, _ = temporary_note_with_attachment
+ note_id = note_data["id"]
+ note_category = note_data.get("category") # Get category from fixture data
+
+ # Fixture setup already added the attachment.
+ # Fixture teardown (from temporary_note) will delete the note.
+ # We just need to verify the attachment is gone *after* the test finishes
+ # and the fixture cleanup runs. However, pytest fixtures don't easily allow
+ # checking state *after* cleanup.
+ # Instead, we will manually delete the note here and verify the attachment is gone.
+
+ logger.info(f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture).")
+
+ # Manually delete the note
+ logger.info(f"Manually deleting note ID: {note_id} within the test.")
+ nc_client.notes_delete_note(note_id=note_id)
+ logger.info(f"Note ID: {note_id} deleted successfully.")
+ time.sleep(1)
+
+ # Verify Note Is Deleted
+ with pytest.raises(HTTPStatusError) as excinfo_note:
+ nc_client.notes_get_note(note_id=note_id)
+ assert excinfo_note.value.response.status_code == 404
+ logger.info(f"Verified note {note_id} deletion (404 received).")
+
+ # Verify Attachment Is Deleted (via 404 on GET)
+ logger.info(f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}")
+ with pytest.raises(HTTPStatusError) as excinfo_attach:
+ # Pass category to get_note_attachment - although it should fail anyway
+ # because the note (and thus details) are gone.
+ # The client method will raise 404 from the initial notes_get_note call.
+ nc_client.get_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ category=note_category # Pass category, though note fetch should fail first
+ )
+ # Expect 404 because the note itself is gone
+ assert excinfo_attach.value.response.status_code == 404
+ logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.")
+
+ # Directly verify attachment directory doesn't exist using WebDAV PROPFIND
+ logger.info(f"Directly verifying attachment directory doesn't exist via PROPFIND")
+ webdav_base = nc_client._get_webdav_base_path()
+ category_path_part = f"{note_category}/" if note_category else ""
+ attachment_dir_path = f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Successful PROPFIND means directory exists
+ logger.error(f"Attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)")
+
+ # Note: The temporary_note fixture will still run its cleanup,
+ # but it will find the note already deleted (404) and handle it gracefully.
+
+def test_attachments_category_change_handling(nc_client: NextcloudClient):
+ """
+ Tests attachment handling when a note's category is changed.
+ Verifies attachment retrieval works before and after category change,
+ and that cleanup targets the correct final location.
+ """
+ note_id = None
+ initial_category = "CategoryA"
+ new_category = "CategoryB"
+ unique_suffix = uuid.uuid4().hex[:8]
+ note_title = f"Category Change Test {unique_suffix}"
+ attachment_filename = f"cat_change_{unique_suffix}.txt"
+ attachment_content = f"Content for {attachment_filename}".encode('utf-8')
+
+ try:
+ # 1. Create note with initial category
+ logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
+ created_note = nc_client.notes_create_note(
+ title=note_title, content="Initial content", category=initial_category
+ )
+ note_id = created_note["id"]
+ etag1 = created_note["etag"]
+ logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
+ time.sleep(1)
+
+ # 2. Add attachment (passing initial category)
+ logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})")
+ upload_response = nc_client.add_note_attachment(
+ note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
+ )
+ assert upload_response["status_code"] in [201, 204]
+ logger.info("Attachment added successfully.")
+ time.sleep(1)
+
+ # 3. Verify attachment retrieval from initial category (passing initial category)
+ logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
+ retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
+ assert retrieved_content1 == attachment_content
+ logger.info("Attachment retrieved successfully from initial category.")
+
+ # 4. Update note category
+ logger.info(f"Updating note {note_id} category from '{initial_category}' to '{new_category}'")
+ # Need to fetch the latest etag after attachment add (WebDAV ops don't update note etag)
+ current_note_data = nc_client.notes_get_note(note_id=note_id)
+ current_etag = current_note_data["etag"]
+ updated_note = nc_client.notes_update_note(
+ note_id=note_id, etag=current_etag, category=new_category, title=note_title, content="Updated content" # Pass required fields
+ )
+ etag3 = updated_note["etag"]
+ assert updated_note["category"] == new_category
+ logger.info(f"Note category updated successfully. New Etag: {etag3}")
+ time.sleep(1)
+
+ # 5. Verify attachment retrieval from *new* category (passing new category)
+ logger.info(f"Verifying attachment retrieval from new category '{new_category}'")
+ retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
+ assert retrieved_content2 == attachment_content
+ logger.info("Attachment retrieved successfully from new category.")
+
+ # 5.1 Verify old category attachment directory is gone via WebDAV PROPFIND
+ logger.info(f"Directly checking if old attachment directory exists in WebDAV")
+ webdav_base = nc_client._get_webdav_base_path()
+ old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Successful PROPFIND means directory exists
+ logger.error(f"Old attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)")
+
+ # 5.2 Verify new category attachment directory exists via WebDAV PROPFIND
+ logger.info(f"Directly checking if new attachment directory exists in WebDAV")
+ new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}"
+ logger.info(f"Verified new attachment directory exists via PROPFIND ({status} received)")
+ except HTTPStatusError as e:
+ logger.error(f"New attachment directory not found! PROPFIND failed with {e.response.status_code}")
+ assert False, f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
+
+ finally:
+ # 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
+ if note_id:
+ logger.info(f"Cleaning up note ID: {note_id} (last known category: '{new_category}')")
+ try:
+ nc_client.notes_delete_note(note_id=note_id)
+ logger.info(f"Note {note_id} deleted.")
+ time.sleep(1)
+ # Verify note deletion
+ with pytest.raises(HTTPStatusError) as excinfo_note_del:
+ nc_client.notes_get_note(note_id=note_id)
+ assert excinfo_note_del.value.response.status_code == 404
+ logger.info("Verified note deleted (404).")
+ # Verify attachment deletion (should fail with 404 on the initial note fetch)
+ with pytest.raises(HTTPStatusError) as excinfo_attach_del:
+ # Pass the *last known* category, although the note fetch should fail first
+ nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
+ assert excinfo_attach_del.value.response.status_code == 404
+ logger.info("Verified attachment cannot be retrieved after note deletion (404).")
+
+ # 6.1 Verify both old and new attachment directories are gone via WebDAV PROPFIND
+ logger.info("Directly verifying attachment directories don't exist via PROPFIND")
+ webdav_base = nc_client._get_webdav_base_path()
+
+ # Check new category attachment directory
+ new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
+ if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists
+ assert False, f"New category attachment directory still exists!"
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info("Verified new category attachment directory is gone via PROPFIND")
+
+ # Check old category attachment directory
+ old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
+ try:
+ resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
+ if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists
+ assert False, f"Old category attachment directory still exists!"
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info("Verified old category attachment directory is gone via PROPFIND")
+
+ logger.info("Verified all attachment directories are properly cleaned up.")
+ except Exception as e:
+ logger.error(f"Error during cleanup for note {note_id}: {e}")
diff --git a/tests/integration/test_embedded_images.py b/tests/integration/test_embedded_images.py
new file mode 100644
index 0000000..565543f
--- /dev/null
+++ b/tests/integration/test_embedded_images.py
@@ -0,0 +1,150 @@
+import pytest
+import os
+import time
+import uuid
+import logging
+import tempfile
+from PIL import Image, ImageDraw
+from io import BytesIO
+from httpx import HTTPStatusError # Import if needed for specific error checks
+
+from nextcloud_mcp_server.client import NextcloudClient
+
+# Note: nc_client fixture is session-scoped in conftest.py
+# Note: temporary_note fixture is function-scoped in conftest.py
+
+logger = logging.getLogger(__name__)
+
+# Mark all tests in this module as integration tests
+pytestmark = pytest.mark.integration
+
+# Keep the test_image fixture as it's specific to generating image data
+@pytest.fixture(scope="module") # Keep module scope if image generation is slow
+def test_image_data() -> tuple[bytes, str]:
+ """
+ Generate test image data (bytes) and suggest a filename.
+ Returns (image_bytes, suggested_filename).
+ """
+ logger.info("Generating test image data in memory.")
+ img = Image.new('RGB', (300, 200), color=(255, 255, 255))
+ draw = ImageDraw.Draw(img)
+ draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle
+ draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)) # White text
+
+ img_byte_arr = BytesIO()
+ img.save(img_byte_arr, format='PNG')
+ image_bytes = img_byte_arr.getvalue()
+ suggested_filename = "test_image.png"
+ logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
+ return image_bytes, suggested_filename
+
+
+def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: dict, test_image_data: tuple):
+ """
+ Tests creating a note, attaching an image, embedding it in the content,
+ and verifying the attachment can be retrieved.
+ """
+ note_data = temporary_note # Use fixture for note creation/cleanup
+ note_id = note_data["id"]
+ note_etag = note_data["etag"]
+ image_content, suggested_filename = test_image_data # Get image data from fixture
+
+ unique_suffix = uuid.uuid4().hex[:8]
+ attachment_filename = f"test_image_{unique_suffix}.png" # Make filename unique per run
+
+ # 1. Upload the image as an attachment
+ note_category = note_data.get("category") # Get category from fixture data
+ logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')...")
+ upload_response = nc_client.add_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ content=image_content,
+ category=note_category, # Pass the category
+ mime_type="image/png"
+ )
+ assert upload_response and upload_response.get("status_code") in [201, 204]
+ logger.info(f"Image uploaded successfully (Status: {upload_response.get('status_code')}).")
+ time.sleep(1) # Allow potential processing time
+
+ # 1.1 Verify attachment directory exists via WebDAV PROPFIND
+ logger.info(f"Directly checking if attachment directory exists in WebDAV")
+ webdav_base = nc_client._get_webdav_base_path()
+ category_path_part = f"{note_category}/" if note_category else ""
+ attachment_dir_path = f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}"
+ logger.info(f"Verified attachment directory exists via PROPFIND ({status} received)")
+ except HTTPStatusError as e:
+ logger.error(f"Attachment directory not found! PROPFIND failed with {e.response.status_code}")
+ assert False, f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
+
+ # 2. Update the note content to include the embedded image references
+ updated_content = f"""{note_data['content']}
+
+## Image Embedding Test
+
+### Markdown Syntax
+
+
+### HTML Syntax
+
+"""
+ logger.info("Updating note content with image references...")
+ updated_note = nc_client.notes_update_note(
+ note_id=note_id,
+ etag=note_etag, # Use etag from the created note
+ content=updated_content,
+ title=note_data['title'], # Pass required fields
+ category=note_data['category'] # Pass required fields
+ )
+ new_etag = updated_note["etag"]
+ assert new_etag != note_etag
+ logger.info("Note content updated with image references.")
+ time.sleep(1)
+
+ # 3. Verify the updated note content
+ retrieved_note = nc_client.notes_get_note(note_id=note_id)
+ assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
+ logger.info("Verified image reference exists in updated note content.")
+
+ # 4. Verify the image attachment can be retrieved
+ logger.info(f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')...")
+ # Pass category to get_note_attachment
+ retrieved_img_content, mime_type = nc_client.get_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename,
+ category=note_category
+ )
+ assert retrieved_img_content == image_content
+ assert mime_type.startswith("image/png")
+ logger.info("Successfully retrieved and verified image attachment content and mime type.")
+
+ # 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
+ logger.info(f"Manually deleting note ID: {note_id} to verify proper attachment cleanup")
+ nc_client.notes_delete_note(note_id=note_id)
+ logger.info(f"Note ID: {note_id} deleted successfully.")
+ time.sleep(1)
+
+ # 6. Verify note is deleted
+ with pytest.raises(HTTPStatusError) as excinfo_note:
+ nc_client.notes_get_note(note_id=note_id)
+ assert excinfo_note.value.response.status_code == 404
+ logger.info(f"Verified note {note_id} deletion (404 received).")
+
+ # 7. Verify attachment directory is deleted via WebDAV PROPFIND
+ logger.info(f"Directly verifying attachment directory doesn't exist via PROPFIND")
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Successful PROPFIND means directory exists
+ logger.error(f"Attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)")
+
+ # Note: The temporary_note fixture will still run its cleanup,
+ # but it will find the note already deleted (404) and handle it gracefully.
diff --git a/tests/integration/test_notes_api.py b/tests/integration/test_notes_api.py
new file mode 100644
index 0000000..92c444f
--- /dev/null
+++ b/tests/integration/test_notes_api.py
@@ -0,0 +1,114 @@
+import pytest
+import logging
+import time
+import uuid # Keep uuid if needed for generating unique data within tests
+from httpx import HTTPStatusError
+
+from nextcloud_mcp_server.client import NextcloudClient
+
+# Note: nc_client fixture is now session-scoped in conftest.py
+
+logger = logging.getLogger(__name__)
+
+# Mark all tests in this module as integration tests
+pytestmark = pytest.mark.integration
+
+def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: dict):
+ """
+ Tests creating a note via the API (using fixture) and then reading it back.
+ """
+ created_note_data = temporary_note # Get data from fixture
+ note_id = created_note_data["id"]
+
+ logger.info(f"Reading note created by fixture, ID: {note_id}")
+ read_note = nc_client.notes_get_note(note_id=note_id)
+
+ assert read_note["id"] == note_id
+ assert read_note["title"] == created_note_data["title"]
+ assert read_note["content"] == created_note_data["content"]
+ assert read_note["category"] == created_note_data["category"]
+ logger.info(f"Successfully read and verified note ID: {note_id}")
+
+def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
+ """
+ Tests updating a note created by the fixture.
+ """
+ created_note_data = temporary_note
+ note_id = created_note_data["id"]
+ original_etag = created_note_data["etag"]
+ original_category = created_note_data["category"]
+
+ update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
+ update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
+
+ logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
+ updated_note = nc_client.notes_update_note(
+ note_id=note_id,
+ etag=original_etag,
+ title=update_title,
+ content=update_content,
+ # category=original_category # Explicitly pass category if required by update
+ )
+ logger.info(f"Note updated: {updated_note}")
+
+ assert updated_note["id"] == note_id
+ assert updated_note["title"] == update_title
+ assert updated_note["content"] == update_content
+ assert updated_note["category"] == original_category # Verify category didn't change
+ assert "etag" in updated_note
+ assert updated_note["etag"] != original_etag # Etag must change
+
+ # Optional: Verify update by reading again
+ time.sleep(1) # Allow potential propagation delay
+ read_updated_note = nc_client.notes_get_note(note_id=note_id)
+ assert read_updated_note["title"] == update_title
+ assert read_updated_note["content"] == update_content
+ logger.info(f"Successfully updated and verified note ID: {note_id}")
+
+def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: dict):
+ """
+ Tests that attempting to update with an old etag fails with 412.
+ """
+ created_note_data = temporary_note
+ note_id = created_note_data["id"]
+ original_etag = created_note_data["etag"]
+
+ # Perform a first update to change the etag
+ first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
+ logger.info(f"Performing first update on note ID: {note_id} to change etag.")
+ first_updated_note = nc_client.notes_update_note(
+ note_id=note_id,
+ etag=original_etag,
+ title=first_update_title,
+ content="First update content",
+ # category=created_note_data["category"] # Pass category if required
+ )
+ new_etag = first_updated_note["etag"]
+ assert new_etag != original_etag
+ logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
+ time.sleep(1)
+
+ # Now attempt update with the *original* etag
+ logger.info(f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}")
+ with pytest.raises(HTTPStatusError) as excinfo:
+ nc_client.notes_update_note(
+ note_id=note_id,
+ etag=original_etag, # Use the stale etag
+ title="This update should fail due to conflict",
+ # category=created_note_data["category"] # Pass category if required
+ )
+ assert excinfo.value.response.status_code == 412 # Precondition Failed
+ logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
+
+def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
+ """
+ Tests deleting a note that doesn't exist fails with 404.
+ """
+ non_existent_id = 999999999 # Use an ID highly unlikely to exist
+ logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
+ with pytest.raises(HTTPStatusError) as excinfo:
+ nc_client.notes_delete_note(note_id=non_existent_id)
+ assert excinfo.value.response.status_code == 404
+ logger.info(f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404.")
+
+# --- Attachment tests moved to test_attachments.py ---
diff --git a/tests/integration/test_webdav_cleanup.py b/tests/integration/test_webdav_cleanup.py
new file mode 100644
index 0000000..15a91c7
--- /dev/null
+++ b/tests/integration/test_webdav_cleanup.py
@@ -0,0 +1,161 @@
+import pytest
+import logging
+import time
+import uuid
+from httpx import HTTPStatusError
+
+from nextcloud_mcp_server.client import NextcloudClient
+
+logger = logging.getLogger(__name__)
+
+# Mark all tests in this module as integration tests
+pytestmark = pytest.mark.integration
+
+def test_category_change_cleans_up_old_attachments_directory(nc_client: NextcloudClient):
+ """
+ Tests that when a note's category is changed, the old attachment directory is properly cleaned up.
+ """
+ note_id = None
+ initial_category = "CategoryTest1"
+ new_category = "CategoryTest2"
+ unique_suffix = uuid.uuid4().hex[:8]
+ note_title = f"Category Cleanup Test {unique_suffix}"
+ attachment_filename = f"cleanup_test_{unique_suffix}.txt"
+ attachment_content = f"Content for {attachment_filename}".encode('utf-8')
+
+ try:
+ # 1. Create note with initial category
+ logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
+ created_note = nc_client.notes_create_note(
+ title=note_title, content="Initial content", category=initial_category
+ )
+ note_id = created_note["id"]
+ etag1 = created_note["etag"]
+ logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
+ time.sleep(1)
+
+ # 2. Add attachment (passing initial category)
+ logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})")
+ upload_response = nc_client.add_note_attachment(
+ note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
+ )
+ assert upload_response["status_code"] in [201, 204]
+ logger.info("Attachment added successfully.")
+ time.sleep(1)
+
+ # 3. Verify attachment retrieval from initial category
+ logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
+ retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
+ assert retrieved_content1 == attachment_content
+ logger.info("Attachment retrieved successfully from initial category.")
+
+ # 4. Construct and check the WebDAV path for the initial category's attachment directory
+ initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
+ logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}")
+ # Here we would check if the directory exists, but the WebDAV client doesn't directly
+ # expose directory listing functionality, so we'll infer from attachment retrieval success
+
+ # 5. Update note category
+ logger.info(f"Updating note {note_id} category from '{initial_category}' to '{new_category}'")
+ current_note_data = nc_client.notes_get_note(note_id=note_id)
+ current_etag = current_note_data["etag"]
+ updated_note = nc_client.notes_update_note(
+ note_id=note_id, etag=current_etag, category=new_category, title=note_title, content="Updated content"
+ )
+ etag3 = updated_note["etag"]
+ assert updated_note["category"] == new_category
+ logger.info(f"Note category updated successfully. New Etag: {etag3}")
+ time.sleep(1)
+
+ # 6. Verify attachment retrieval from new category
+ logger.info(f"Verifying attachment retrieval from new category '{new_category}'")
+ retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
+ assert retrieved_content2 == attachment_content
+ logger.info("Attachment retrieved successfully from new category.")
+
+ # 7. Try to retrieve from old category - this should fail
+ logger.info(f"Trying to retrieve attachment from old category '{initial_category}' - should fail")
+ try:
+ nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
+ # If we get here, it means the old directory still exists (a problem)
+ logger.error("ISSUE DETECTED: Was able to retrieve attachment from old category path!")
+ assert False, "Old category attachment directory still exists and accessible!"
+ except HTTPStatusError as e:
+ # This is the expected outcome - old directory should be gone
+ logger.info(f"Correctly got error accessing old category path: {e.response.status_code}")
+ assert e.response.status_code == 404, f"Expected 404, got {e.response.status_code}"
+ logger.info("Verified old category attachment directory is not accessible (good!)")
+
+ # 7.1 Directly check old attachment directory existence using WebDAV PROPFIND
+ logger.info(f"Directly checking if old attachment directory exists in WebDAV")
+ webdav_base = nc_client._get_webdav_base_path()
+ old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Success codes indicate the directory exists (a problem)
+ logger.error(f"Old attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
+ # If we got another status code (like 404), it's also good - the directory doesn't exist
+ logger.info(f"Verified old attachment directory does not exist (PROPFIND returned {status})")
+ except HTTPStatusError as e:
+ # 404 is expected - directory should not exist
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)")
+
+ finally:
+ # 8. Cleanup: Delete the note
+ if note_id:
+ logger.info(f"Cleaning up note ID: {note_id}")
+ try:
+ nc_client.notes_delete_note(note_id=note_id)
+ logger.info(f"Note {note_id} deleted.")
+ time.sleep(1)
+
+ # 9. Verify both old and new attachment paths are gone
+ logger.info("Verifying all attachment paths are gone")
+ with pytest.raises(HTTPStatusError) as excinfo_new:
+ nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
+ assert excinfo_new.value.response.status_code == 404
+
+ with pytest.raises(HTTPStatusError) as excinfo_old:
+ nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
+ assert excinfo_old.value.response.status_code == 404
+
+ # 9.1 Directly verify directories don't exist using WebDAV PROPFIND
+ logger.info("Directly verifying attachment directories don't exist via PROPFIND")
+ webdav_base = nc_client._get_webdav_base_path()
+
+ # Check new category attachment directory
+ new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
+ propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Success codes indicate the directory exists (a problem)
+ logger.error(f"New category attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
+ # If we got another status code (like 404), it's also good - the directory doesn't exist
+ logger.info(f"Verified new category attachment directory does not exist (PROPFIND returned {status})")
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info("Verified new category attachment directory is gone via PROPFIND")
+
+ # Check old category attachment directory
+ old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
+ try:
+ propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
+ status = propfind_resp.status_code
+ if status in [200, 207]: # Success codes indicate the directory exists (a problem)
+ logger.error(f"Old category attachment directory still exists! PROPFIND returned {status}")
+ assert False, f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
+ # If we got another status code (like 404), it's also good - the directory doesn't exist
+ logger.info(f"Verified old category attachment directory does not exist (PROPFIND returned {status})")
+ except HTTPStatusError as e:
+ assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
+ logger.info("Verified old category attachment directory is gone via PROPFIND")
+
+ logger.info("Verified all attachment directories are properly cleaned up.")
+ except Exception as e:
+ logger.error(f"Error during cleanup for note {note_id}: {e}")
diff --git a/tests/test_client.py b/tests/test_client.py
deleted file mode 100644
index 8d41cee..0000000
--- a/tests/test_client.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import pytest
-import os
-import time
-import uuid
-from httpx import HTTPStatusError
-
-from nextcloud_mcp_server.client import NextcloudClient
-
-# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
-
-
-@pytest.fixture(scope="module")
-def nc_client() -> NextcloudClient:
- """
- Fixture to create a NextcloudClient instance for integration tests.
- Reads credentials from environment variables.
- Scope is 'module' so the client is reused for all tests in this file.
- """
- # Basic check to ensure env vars seem present - tests will fail properly if not
- assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
- assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
- assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
- return NextcloudClient.from_env()
-
-
-@pytest.mark.integration
-def test_note_crud_integration(nc_client: NextcloudClient):
- """
- Integration test for the complete CRUD (Create, Read, Update, Delete)
- lifecycle of a note.
- """
- # --- Create ---
- unique_id = str(uuid.uuid4()) # To ensure note is unique for this test run
- create_title = f"Integration Test Note {unique_id}"
- create_content = f"Content for integration test {unique_id}"
- create_category = "IntegrationTesting"
-
- created_note = (
- None # Initialize to ensure cleanup happens even if create fails mid-assert
- )
- try:
- print(f"\nAttempting to create note: {create_title}")
- created_note = nc_client.notes_create_note(
- title=create_title, content=create_content, category=create_category
- )
- print(f"Note created: {created_note}")
-
- assert created_note is not None
- assert "id" in created_note
- assert created_note["title"] == create_title
- assert created_note["content"] == create_content
- assert created_note["category"] == create_category
- assert "etag" in created_note
- note_id = created_note["id"]
- etag = created_note["etag"]
-
- # Add a small delay to allow Nextcloud to process if needed
- time.sleep(1)
-
- # --- Read (Verify Create) ---
- print(f"Attempting to read note ID: {note_id}")
- read_note = nc_client.notes_get_note(note_id=note_id)
- print(f"Note read: {read_note}")
- assert read_note["id"] == note_id
- assert read_note["title"] == create_title
- assert read_note["content"] == create_content
- assert read_note["category"] == create_category
- # Etag might change even on read in some systems, so don't assert etag equality here
-
- # --- Update ---
- update_title = f"Updated Test Note {unique_id}"
- update_content = f"Updated content {unique_id}"
- # Use the etag from the *creation* for the update's If-Match header
- print(f"Attempting to update note ID: {note_id} with etag: {etag}")
- updated_note = nc_client.notes_update_note(
- note_id=note_id,
- etag=etag,
- title=update_title,
- content=update_content,
- # category=create_category # Keep category same or update if needed
- )
- print(f"Note updated: {updated_note}")
- assert updated_note["id"] == note_id
- assert updated_note["title"] == update_title
- assert updated_note["content"] == update_content
- assert updated_note["category"] == create_category # Category wasn't updated
- assert "etag" in updated_note
- assert updated_note["etag"] != etag # Etag must change on update
- new_etag = updated_note["etag"]
-
- # Add a small delay
- time.sleep(1)
-
- # --- Read (Verify Update) ---
- print(f"Attempting to read updated note ID: {note_id}")
- read_updated_note = nc_client.notes_get_note(note_id=note_id)
- print(f"Updated note read: {read_updated_note}")
- assert read_updated_note["id"] == note_id
- assert read_updated_note["title"] == update_title
- assert read_updated_note["content"] == update_content
- # Don't assert etag equality here either
-
- # --- Test Update Conflict (Precondition Failed) ---
- print(f"Attempting to update note ID: {note_id} with OLD etag: {etag}")
- with pytest.raises(HTTPStatusError) as excinfo:
- nc_client.notes_update_note(
- note_id=note_id,
- etag=etag, # Use the OLD etag
- title="This update should fail",
- )
- assert excinfo.value.response.status_code == 412 # Precondition Failed
- print("Update with old etag correctly failed with 412.")
-
- finally:
- # --- Delete ---
- if created_note and "id" in created_note:
- note_id_to_delete = created_note["id"]
- print(f"Attempting to delete note ID: {note_id_to_delete}")
- try:
- delete_response = nc_client.notes_delete_note(note_id=note_id_to_delete)
- print(f"Delete response: {delete_response}")
- # Check if delete returns the deleted object or just status
- # Assuming it returns the object based on previous tests
- assert delete_response["id"] == note_id_to_delete
- print(f"Note ID: {note_id_to_delete} deleted successfully.")
-
- # --- Verify Delete ---
- print(f"Attempting to read deleted note ID: {note_id_to_delete}")
- with pytest.raises(HTTPStatusError) as excinfo_del:
- nc_client.notes_get_note(note_id=note_id_to_delete)
- assert excinfo_del.value.response.status_code == 404
- print(
- f"Reading deleted note ID: {note_id_to_delete} correctly failed with 404."
- )
-
- except HTTPStatusError as e:
- # If deletion fails unexpectedly, log it but don't fail the test here
- # as the primary goal was CRUD, and cleanup failure is secondary.
- print(f"Error during cleanup (deleting note {note_id_to_delete}): {e}")
- except Exception as e:
- print(f"Unexpected error during cleanup: {e}")
- else:
- print(
- "Skipping delete step as note creation might have failed or ID was not available."
- )
-
-
-@pytest.mark.integration
-def test_delete_nonexistent_note(nc_client: NextcloudClient):
- """Test deleting a note that doesn't exist."""
- non_existent_id = 999999999 # Use an ID highly unlikely to exist
- print(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
- with pytest.raises(HTTPStatusError) as excinfo:
- nc_client.notes_delete_note(note_id=non_existent_id)
- assert excinfo.value.response.status_code == 404
- print(
- f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
- )
diff --git a/update_content_with_image_reference.py b/update_content_with_image_reference.py
new file mode 100644
index 0000000..f5c4efc
--- /dev/null
+++ b/update_content_with_image_reference.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 420 # ID of the note with the image attachment
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # First get the current note
+ try:
+ note = client.notes_get_note(note_id=note_id)
+ print(f"Retrieved note: {note['title']}")
+
+ # Update the note content to include a direct reference to the image
+ updated_content = f"""# Note with Image Attachment
+
+This note demonstrates attaching images to Nextcloud Notes.
+
+An image will be attached to this note as a demonstration.
+
+## Image Reference
+
+The image is attached but not displayed inline in the Notes UI.
+Attachments in Nextcloud Notes exist as separate files in the .attachments.{note_id}
+directory but aren't automatically embedded in the note content.
+
+You can view the image by going to the Files app and navigating to:
+/Notes/.attachments.{note_id}/sample_image.png
+
+## Orphaned Attachments
+
+When notes are deleted, their attachments remain in the system.
+This is the expected behavior of the official Nextcloud Notes app.
+"""
+
+ # Update the note with the new content
+ updated_note = client.notes_update_note(
+ note_id=note_id,
+ etag=note['etag'],
+ content=updated_content
+ )
+
+ print(f"Note updated successfully with image reference information.")
+ return 0
+
+ except Exception as e:
+ print(f"Error updating note: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/update_image_reference.py b/update_image_reference.py
new file mode 100644
index 0000000..80f6d84
--- /dev/null
+++ b/update_image_reference.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python
+import os
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 487 # ID of the note with the issue
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ try:
+ # Get the current note to get its etag
+ note = client.notes_get_note(note_id=note_id)
+ etag = note["etag"]
+
+ # Update the note content with correct image reference syntax
+ updated_content = f"""# Note with Visible Image Demo
+
+This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
+
+We'll include the sample red square image we created earlier using both Markdown and HTML methods.
+
+## Method 1: Markdown Image Syntax
+
+
+## Method 2: HTML Image Tag
+
+
+## Image Path Details
+The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
+
+## Note on Image Embedding
+In Nextcloud Notes, images must be referenced with a period at the beginning of the path. The correct format is:
+`.attachments.{note_id}/filename.png`
+
+Without the leading period, the image won't display correctly.
+"""
+
+ # Update the note with the corrected image references
+ updated_note = client.notes_update_note(
+ note_id=note_id,
+ etag=etag,
+ content=updated_content
+ )
+
+ print(f"Note updated with corrected image references.")
+ print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
+ return 0
+ except Exception as e:
+ print(f"Error: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/update_webdav_auth.py b/update_webdav_auth.py
new file mode 100644
index 0000000..9a3210f
--- /dev/null
+++ b/update_webdav_auth.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+import sys
+import os
+import base64
+from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
+import logging
+import time
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+def test_webdav_auth_with_attachment():
+ """
+ Test function to verify WebDAV authentication by attempting to use add_note_attachment.
+ """
+ client = NextcloudClient.from_env()
+ print("Client authentication type:", type(client._client.auth).__name__)
+
+ username = os.environ["NEXTCLOUD_USERNAME"]
+ webdav_base = client._get_webdav_base_path()
+ notes_path = f"{webdav_base}/Notes"
+ print(f"Target WebDAV Notes path for PROPFIND check: {notes_path}")
+
+ temp_note_id = None
+ try:
+ # 1. Create a temporary note to get a note_id
+ print("\nCreating a temporary note...")
+ temp_note_title = f"Temp Note for WebDAV Test - {int(time.time())}"
+ created_note = client.notes_create_note(title=temp_note_title, content="Test content")
+ temp_note_id = created_note.get("id")
+ if not temp_note_id:
+ print("Error: Failed to create temporary note.")
+ return 1
+ print(f"Temporary note created with ID: {temp_note_id}")
+
+ # 2. Attempt to add an attachment (this will trigger the internal PROPFIND)
+ print(f"\nTest: Attempting add_note_attachment for note_id {temp_note_id} (uses client's BasicAuth)")
+ dummy_content = b"This is a test attachment."
+ dummy_filename = "test_attachment.txt"
+
+ # The add_note_attachment method itself contains the PROPFIND check
+ # and will log details if it fails.
+ response_data = client.add_note_attachment(
+ note_id=temp_note_id,
+ filename=dummy_filename,
+ content=dummy_content,
+ mime_type="text/plain"
+ )
+ print(f"add_note_attachment response: {response_data}")
+ if response_data and response_data.get("status_code") in [201, 204]:
+ print("Success! add_note_attachment (and its internal PROPFIND) worked.")
+ else:
+ print("Failure or unexpected response from add_note_attachment.")
+ # The client.py logs should show details of the PROPFIND if it failed.
+
+ except HTTPStatusError as e:
+ print(f"HTTPStatusError during add_note_attachment: {e.response.status_code} - {e.response.text}")
+ if e.response.status_code == 401:
+ print("Reproduced 401 Unauthorized during add_note_attachment's PROPFIND check!")
+ else:
+ print("An HTTP error other than 401 occurred.")
+ except Exception as e:
+ print(f"An unexpected error occurred: {str(e)}")
+ finally:
+ # 3. Clean up: Delete the temporary note
+ if temp_note_id:
+ print(f"\nCleaning up: Deleting temporary note ID {temp_note_id}...")
+ try:
+ client.notes_delete_note(note_id=temp_note_id)
+ print(f"Successfully deleted temporary note ID {temp_note_id}.")
+ except Exception as e_del:
+ print(f"Error deleting temporary note ID {temp_note_id}: {str(e_del)}")
+
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(test_webdav_auth_with_attachment())
diff --git a/uv.lock b/uv.lock
index 3af3ac9..ee53e4a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -25,6 +25,43 @@ wheels = [
{ 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, 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, upload-time = "2024-11-30T04:30:10.946Z" },
+]
+
+[[package]]
+name = "black"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "mypy-extensions" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" },
+ { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" },
+ { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" },
+ { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" },
+ { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" },
+ { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" },
+]
+
[[package]]
name = "certifi"
version = "2025.4.26"
@@ -55,6 +92,79 @@ wheels = [
{ 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, upload-time = "2025-03-30T20:36:45.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493, upload-time = "2025-03-30T20:35:12.286Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921, upload-time = "2025-03-30T20:35:14.18Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556, upload-time = "2025-03-30T20:35:15.616Z" },
+ { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245, upload-time = "2025-03-30T20:35:18.648Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032, upload-time = "2025-03-30T20:35:20.131Z" },
+ { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679, upload-time = "2025-03-30T20:35:21.636Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852, upload-time = "2025-03-30T20:35:23.525Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389, upload-time = "2025-03-30T20:35:25.09Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997, upload-time = "2025-03-30T20:35:26.914Z" },
+ { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911, upload-time = "2025-03-30T20:35:28.498Z" },
+ { 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, upload-time = "2025-03-30T20:35:29.959Z" },
+ { 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, upload-time = "2025-03-30T20:35:31.912Z" },
+ { 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, upload-time = "2025-03-30T20:35:33.455Z" },
+ { 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, upload-time = "2025-03-30T20:35:35.354Z" },
+ { 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, upload-time = "2025-03-30T20:35:37.121Z" },
+ { 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, upload-time = "2025-03-30T20:35:39.07Z" },
+ { 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, upload-time = "2025-03-30T20:35:40.598Z" },
+ { 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, upload-time = "2025-03-30T20:35:42.204Z" },
+ { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170, upload-time = "2025-03-30T20:35:44.216Z" },
+ { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969, upload-time = "2025-03-30T20:35:45.797Z" },
+ { 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, upload-time = "2025-03-30T20:35:47.417Z" },
+ { 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, upload-time = "2025-03-30T20:35:49.002Z" },
+ { 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, upload-time = "2025-03-30T20:35:51.073Z" },
+ { 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, upload-time = "2025-03-30T20:35:52.941Z" },
+ { 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, upload-time = "2025-03-30T20:35:54.658Z" },
+ { 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, upload-time = "2025-03-30T20:35:56.221Z" },
+ { 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, upload-time = "2025-03-30T20:35:57.801Z" },
+ { 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, upload-time = "2025-03-30T20:35:59.378Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" },
+ { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" },
+ { 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, upload-time = "2025-03-30T20:36:04.638Z" },
+ { 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, upload-time = "2025-03-30T20:36:06.503Z" },
+ { 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, upload-time = "2025-03-30T20:36:08.137Z" },
+ { 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, upload-time = "2025-03-30T20:36:09.781Z" },
+ { 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, upload-time = "2025-03-30T20:36:11.409Z" },
+ { 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, upload-time = "2025-03-30T20:36:13.86Z" },
+ { 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, upload-time = "2025-03-30T20:36:16.074Z" },
+ { 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, upload-time = "2025-03-30T20:36:18.033Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443, upload-time = "2025-03-30T20:36:41.959Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[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, 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, upload-time = "2025-01-22T15:41:25.929Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -110,6 +220,61 @@ wheels = [
{ 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, 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, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "decorator" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394, upload-time = "2025-04-25T17:55:40.498Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277, upload-time = "2025-04-25T17:55:37.625Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -122,6 +287,18 @@ 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, upload-time = "2023-06-03T06:41:11.019Z" },
]
+[[package]]
+name = "matplotlib-inline"
+version = "0.1.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" },
+]
+
[[package]]
name = "mcp"
version = "1.7.1"
@@ -157,6 +334,15 @@ wheels = [
{ 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 = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
[[package]]
name = "nextcloud-mcp-server"
version = "0.1.0"
@@ -164,12 +350,176 @@ source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
+ { name = "pillow" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "black" },
+ { name = "ipython" },
+ { name = "pytest" },
+ { name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.7,<1.8" },
+ { name = "pillow", specifier = ">=11.2.1,<12.0.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "black", specifier = ">=25.1.0" },
+ { name = "ipython", specifier = ">=9.2.0" },
+ { name = "pytest", specifier = ">=8.3.5" },
+ { name = "pytest-cov", specifier = ">=6.1.1" },
+]
+
+[[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, 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, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "parso"
+version = "0.8.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[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, upload-time = "2025-04-12T17:50:03.289Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload-time = "2025-04-12T17:47:37.135Z" },
+ { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload-time = "2025-04-12T17:47:39.345Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload-time = "2025-04-12T17:47:41.128Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload-time = "2025-04-12T17:47:42.912Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload-time = "2025-04-12T17:47:44.611Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload-time = "2025-04-12T17:47:46.46Z" },
+ { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload-time = "2025-04-12T17:47:49.255Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload-time = "2025-04-12T17:47:51.067Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload-time = "2025-04-12T17:47:54.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload-time = "2025-04-12T17:47:56.535Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload-time = "2025-04-12T17:47:58.217Z" },
+ { 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" },
+ { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload-time = "2025-04-12T17:49:46.789Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload-time = "2025-04-12T17:49:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload-time = "2025-04-12T17:49:50.831Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload-time = "2025-04-12T17:49:53.278Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload-time = "2025-04-12T17:49:55.164Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload-time = "2025-04-12T17:49:57.171Z" },
+ { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
+]
+
+[[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, 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, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.51"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
@@ -275,6 +625,34 @@ wheels = [
{ 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 = "pytest"
+version = "8.3.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { 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, 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, upload-time = "2025-03-02T12:54:52.069Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "6.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pytest" },
+]
+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, upload-time = "2025-04-05T14:07:49.641Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.1.0"
@@ -337,6 +715,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/a4/ee4a20f0b5ff34c391f3685eff7cdba1178a487766e31b04efb51bbddd87/sse_starlette-2.3.4-py3-none-any.whl", hash = "sha256:b8100694f3f892b133d0f7483acb7aacfcf6ed60f863b31947664b6dc74e529f", size = 10232, upload-time = "2025-05-04T19:28:50.199Z" },
]
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
[[package]]
name = "starlette"
version = "0.46.2"
@@ -349,6 +741,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
]
+[[package]]
+name = "tomli"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
+ { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
+ { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
+ { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
+ { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
+ { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
+ { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
+ { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
+ { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
+ { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
+]
+
+[[package]]
+name = "traitlets"
+version = "5.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
+]
+
[[package]]
name = "typer"
version = "0.15.3"
@@ -397,3 +837,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9
wheels = [
{ 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]]
+name = "wcwidth"
+version = "0.2.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
+]
diff --git a/verify_image_attachment.py b/verify_image_attachment.py
new file mode 100644
index 0000000..281f1b2
--- /dev/null
+++ b/verify_image_attachment.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+import sys
+from nextcloud_mcp_server.client import NextcloudClient
+
+def main():
+ note_id = 420 # ID of the note we created earlier
+
+ # Create client
+ client = NextcloudClient.from_env()
+
+ # First verify the note exists
+ print(f"Retrieving note {note_id}...")
+ try:
+ note = client.notes_get_note(note_id=note_id)
+ print(f"Note retrieved: {note['title']}")
+ except Exception as e:
+ print(f"Error retrieving note: {e}")
+ return 1
+
+ # Now try to get the image attachment
+ attachment_filename = "sample_image.png"
+ print(f"Retrieving attachment '{attachment_filename}' from note {note_id}...")
+ try:
+ content, mime_type = client.get_note_attachment(
+ note_id=note_id,
+ filename=attachment_filename
+ )
+ print(f"Attachment retrieved successfully!")
+ print(f"MIME type: {mime_type}")
+ print(f"Content size: {len(content)} bytes")
+
+ # Save the retrieved image to verify it's the same
+ output_path = "retrieved_image.png"
+ with open(output_path, 'wb') as f:
+ f.write(content)
+ print(f"Saved retrieved image to: {output_path}")
+
+ return 0
+ except Exception as e:
+ print(f"Error retrieving attachment: {e}")
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())