From 04e4a8e0a8ce39232ada3c3382891382797d5d9f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 6 May 2025 02:52:51 +0200 Subject: [PATCH 1/8] Add support for attachments in notes --- README.md | 11 + attach_and_embed_image.py | 70 +++++++ attach_image.py | 40 ++++ check_updated_note.py | 26 +++ docs/nextcloud_notes_image_embedding.md | 118 +++++++++++ nextcloud_mcp_server/client.py | 176 +++++++++++++++- nextcloud_mcp_server/server.py | 20 ++ poetry.lock | 102 +++++++++- pyproject.toml | 3 +- retrieved_image.png | Bin 0 -> 1565 bytes sample_image.png | Bin 0 -> 1565 bytes sample_image.py | 11 + test_delete_note_with_attachment.py | 82 ++++++++ tests/test_client.py | 255 ++++++++++++++++++++++++ tests/test_embedded_images.py | 126 ++++++++++++ tests/test_note_attachment_cleanup.py | 100 ++++++++++ tests/test_note_image_integration.py | 133 ++++++++++++ update_content_with_image_reference.py | 53 +++++ update_image_reference.py | 55 +++++ update_webdav_auth.py | 74 +++++++ verify_image_attachment.py | 44 ++++ 21 files changed, 1493 insertions(+), 6 deletions(-) create mode 100644 attach_and_embed_image.py create mode 100644 attach_image.py create mode 100644 check_updated_note.py create mode 100644 docs/nextcloud_notes_image_embedding.md create mode 100644 retrieved_image.png create mode 100644 sample_image.png create mode 100644 sample_image.py create mode 100644 test_delete_note_with_attachment.py create mode 100644 tests/test_embedded_images.py create mode 100644 tests/test_note_attachment_cleanup.py create mode 100644 tests/test_note_image_integration.py create mode 100644 update_content_with_image_reference.py create mode 100644 update_image_reference.py create mode 100644 update_webdav_auth.py create mode 100644 verify_image_attachment.py 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 +![Sample Red Square Image](.attachments.{note_id}/sample_image.png) + +## Method 2: HTML Image Tag +Sample Red Square Image + +## 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/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 +![Image Alt Text](.attachments.{note_id}/{filename}) +``` + +For example: +```markdown +![My Screenshot](.attachments.123/screenshot.png) +``` + +### 2. HTML Image Tags + +```html +Image description +``` + +For example: +```html +My Screenshot +``` + +## 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: + +![Example Image](.attachments.{note_id}/example.png) +""" + +# 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..e143485 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): @@ -155,6 +158,171 @@ class NextcloudClient: return search_results def notes_delete_note(self, *, note_id: int): + # First delete the note through the Notes API response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() - return response.json() + json_response = response.json() + + # Then try to delete the attachments directory via WebDAV + try: + webdav_base = self._get_webdav_base_path() + attachments_dir = f"{webdav_base}/Notes/.attachments.{note_id}" + logger.info("Deleting attachment directory: %s", attachments_dir) + + delete_response = self._client.request("DELETE", attachments_dir) + # 204 No Content = successful delete, 404 Not Found = already gone (both OK) + if delete_response.status_code not in [204, 404]: + logger.warning( + "Unexpected status code %s when deleting attachments directory for note %s", + delete_response.status_code, + note_id + ) + + # In production, we should not raise an error if the Notes API deletion was successful + # but WebDAV cleanup failed - this would leave the note inaccessible to users. + # Instead, log the issue for admin attention. + if delete_response.status_code == 401: + logger.error( + "Authentication error when trying to delete attachment directory for note %s. " + "Please verify WebDAV permissions.", + note_id + ) + elif delete_response.status_code >= 400: + logger.error( + "Error (HTTP %s) when trying to delete attachment directory for note %s.", + delete_response.status_code, + note_id + ) + except Exception as e: + # Log but don't fail the operation if attachments cleanup fails + logger.error( + "Error cleaning up attachments directory for note %s: %s", + note_id, + 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}" + + def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, mime_type: str | None = None): + """Add/Update an attachment to a note via WebDAV PUT.""" + # Attachments are stored in a hidden folder .attachments.{note_id} within the Notes folder + webdav_base = self._get_webdav_base_path() + attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" + logger.info("Uploading attachment to WebDAV path: %s", 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} + 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) + notes_dir_response = self._client.request("PROPFIND", notes_dir_path, + headers={"Depth": "0"}) + + 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 = f"{webdav_base}/Notes/.attachments.{note_id}" + logger.info("Creating attachments directory: %s", parent_dir_path) + mkcol_response = self._client.request("MKCOL", parent_dir_path) + # MKCOL should return 201 Created or 405 Method Not Allowed (if 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): + """Fetch a specific attachment from a note via WebDAV GET.""" + webdav_base = self._get_webdav_base_path() + attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" + logger.info("Fetching attachment from WebDAV path: %s", 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/server.py b/nextcloud_mcp_server/server.py index 0e7b142..740fdd2 100644 --- a/nextcloud_mcp_server/server.py +++ b/nextcloud_mcp_server/server.py @@ -108,6 +108,26 @@ 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 index 4038539..209e9f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -478,6 +478,106 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pillow" +version = "11.2.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, + {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, + {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, + {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, + {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, + {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, + {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, + {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, + {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, + {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, + {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, + {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, + {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, + {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, + {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, + {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, + {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, + {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, + {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, + {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, + {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.7" @@ -973,4 +1073,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "9f0b7b38edcfb60fb521fd54a2b43c2a8d8ab2e3bf0c7b5e994a4999fc9d954e" +content-hash = "38328edadd5d23977a5c867229a1445013862796853bc5e8e9de08106ecba60f" diff --git a/pyproject.toml b/pyproject.toml index 59124a0..646e43e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ 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] diff --git a/retrieved_image.png b/retrieved_image.png new file mode 100644 index 0000000000000000000000000000000000000000..fd67dba665e8a12681ca538e9d780d4038bd108f GIT binary patch literal 1565 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIV1450;uumf=k4v&-XXOz$3ON@ zpX6{#Y1hPwthu``9Bax*H22`Rlo8nBlJMVQ%Lxu8ja9W0F5ODeR}Q&O5ngo8qHJN) zyWFm@yJZUQg%oK|uEeGhlHlGB0vVKFbb4}5Wn z*r1>jAt0v1!7bLp%6+JbHSu86h6G1^!c+XR$^tLO@8av8r>fFnFIiN9IN3*Ib>GZTe50`pxEKK&;m=kGJEf%B`E9F1mt+aGW&|^Z%=>85ta!uU;i(WV{J|ds|%HuSLYY|JXd+ zlT-NR7p-|SeZ5mx?(IdJH-E@Jb0uQqGKUCL(}`!F@!#Ly@1u6}-ag+v&vyRf%l!jG zO|u@g^BWeYc?1Rt6+e4&+`i6-OGCT6U;cR8*RR>1baoz}GVR#*{N(jn_x8Ma%d2T+ zmGtdR%CG0=AGezt2+Z{P^5w|c*&Ayr9|q5mN}MWIb##i6BrkvV>uXOoYzeV0&&u#P zdi2(w$is&xg~Y9sw-1T4%S!U>?CMgE~xpmZL|t^sX&-zqL1d(yUn}&wT#nyxj8b-P9#}tEZmy z2&&&7wtm`@Ha1s}OMic#yz}c<;h{+-&-?u5hoz_2Em`Wi+;8O?pGn`|dY66BZrv~X zY}>kZ5;8oRTH4A}yXMV%@v2Ky+o|i_yBk|HmtQg%oK|uEeGhlHlGB0vVKFbb4}5Wn z*r1>jAt0v1!7bLp%6+JbHSu86h6G1^!c+XR$^tLO@8av8r>fFnFIiN9IN3*Ib>GZTe50`pxEKK&;m=kGJEf%B`E9F1mt+aGW&|^Z%=>85ta!uU;i(WV{J|ds|%HuSLYY|JXd+ zlT-NR7p-|SeZ5mx?(IdJH-E@Jb0uQqGKUCL(}`!F@!#Ly@1u6}-ag+v&vyRf%l!jG zO|u@g^BWeYc?1Rt6+e4&+`i6-OGCT6U;cR8*RR>1baoz}GVR#*{N(jn_x8Ma%d2T+ zmGtdR%CG0=AGezt2+Z{P^5w|c*&Ayr9|q5mN}MWIb##i6BrkvV>uXOoYzeV0&&u#P zdi2(w$is&xg~Y9sw-1T4%S!U>?CMgE~xpmZL|t^sX&-zqL1d(yUn}&wT#nyxj8b-P9#}tEZmy z2&&&7wtm`@Ha1s}OMic#yz}c<;h{+-&-?u5hoz_2Em`Wi+;8O?pGn`|dY66BZrv~X zY}>kZ5;8oRTH4A}yXMV%@v2Ky+o|i_yBk|Hmt NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + """ + 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.fixture +def test_image(): + """Generate a test image with embedded text for attachment tests""" + # Create a temporary file to store the test image + fd, image_path = tempfile.mkstemp(suffix='.png') + os.close(fd) + + # Create a test image with text + img = Image.new('RGB', (300, 200), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) + draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)) + img.save(image_path) + + try: + yield image_path + finally: + # Clean up the temporary image file + if os.path.exists(image_path): + os.unlink(image_path) + +@pytest.mark.integration +def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): + """ + Test creating a note with an embedded image and verify the process works end-to-end. + This test documents how images should be embedded in Nextcloud Notes. + """ + # Generate a unique identifier for this test run + unique_id = str(uuid.uuid4())[:8] + note_title = f"Embedded Image Test {unique_id}" + initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes." + + # Create the note + print(f"Creating test note: {note_title}") + note = nc_client.notes_create_note( + title=note_title, + content=initial_content, + category="Documentation" + ) + note_id = note["id"] + note_etag = note["etag"] + print(f"Note created with ID: {note_id}") + + try: + # Read the test image content + with open(test_image, 'rb') as f: + image_content = f.read() + + # Generate a unique filename for the attachment + attachment_filename = f"test_image_{unique_id}.png" + + # Upload the image as an attachment + print(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=image_content, + mime_type="image/png" + ) + print(f"Image uploaded: {upload_response}") + + # Update the note content to include the embedded image using Markdown syntax + # This is the correct syntax for embedding images in Nextcloud Notes + updated_content = f"""# Embedded Image Test + +This note demonstrates how to properly embed images in Nextcloud Notes. + +## Method 1: Markdown Image Syntax +![Test Image](.attachments.{note_id}/{attachment_filename}) + +## Method 2: HTML Image Tag +Test Image HTML + +## Notes on Image Embedding +- Images must be stored in the .attachments.{note_id} directory +- Images are referenced using relative paths +- Both Markdown and HTML image tags work in Nextcloud Notes +- The Nextcloud Notes UI will display these images inline when viewing the note +""" + + # Update the note with the image references + print("Updating note content with image references...") + updated_note = nc_client.notes_update_note( + note_id=note_id, + etag=note_etag, + content=updated_content + ) + + # Verify the updated note has the correct content + retrieved_note = nc_client.notes_get_note(note_id=note_id) + assert ".attachments." in retrieved_note["content"], "Image reference not found in note content" + print("Note updated successfully with image references") + + # Verify we can retrieve the image attachment + retrieved_content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + assert len(retrieved_content) > 0, "Retrieved image content is empty" + assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" + + print("Test completed successfully - image was embedded in the note and can be retrieved") + + finally: + # Clean up - delete the test note + print(f"Cleaning up - deleting test note {note_id}") + nc_client.notes_delete_note(note_id=note_id) diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py new file mode 100644 index 0000000..cf8f2cf --- /dev/null +++ b/tests/test_note_attachment_cleanup.py @@ -0,0 +1,100 @@ +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. + """ + 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_attachment_remains_after_note_deletion(nc_client: NextcloudClient): + """ + Test to verify and document that when a note is deleted, its attachments remain + in the system. This is the expected behavior of the Nextcloud Notes app. + """ + # --- Create Note --- + unique_id = str(uuid.uuid4()) + note_title = f"Attachment Cleanup Test {unique_id}" + note_content = f"# Test for attachment cleanup behavior\n\nThis note will be deleted, but attachments should remain." + note_category = "CleanupTests" + + created_note = None + note_id = None + + try: + # Create the note + print(f"Creating note: {note_title}") + created_note = nc_client.notes_create_note( + title=note_title, + content=note_content, + category=note_category + ) + assert created_note and "id" in created_note + note_id = created_note["id"] + print(f"Note created with ID: {note_id}") + time.sleep(1) + + # Create a simple text attachment + attachment_filename = f"orphan_test_{unique_id}.txt" + attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8') + + # Attach the file to the note + print(f"Attaching text file to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + mime_type="text/plain" + ) + + assert upload_response["status_code"] in [201, 204] + print(f"Attachment added successfully (Status: {upload_response['status_code']}).") + time.sleep(1) + + # Verify the attachment exists + content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + assert content == attachment_content, "Attachment content mismatch" + print("Attachment verified") + + # Now delete the note + print(f"Deleting note ID: {note_id}") + nc_client.notes_delete_note(note_id=note_id) + print(f"Note deleted successfully.") + time.sleep(1) + + # Verify the note is deleted + with pytest.raises(HTTPStatusError) as excinfo: + nc_client.notes_get_note(note_id=note_id) + assert excinfo.value.response.status_code == 404 + print(f"Verified note deletion (404 Not Found)") + + # Now check if the attachment still exists (expected behavior: it should) + print(f"Checking if attachment still exists after note deletion...") + orphaned_content, orphaned_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + # If we get here without an exception, the attachment still exists + print("CONFIRMED: Attachment still exists after note deletion") + print("This is the expected behavior of the Nextcloud Notes app") + assert orphaned_content == attachment_content, "Orphaned attachment content mismatch" + + finally: + # No cleanup needed since we've already deleted the note + pass diff --git a/tests/test_note_image_integration.py b/tests/test_note_image_integration.py new file mode 100644 index 0000000..26225ce --- /dev/null +++ b/tests/test_note_image_integration.py @@ -0,0 +1,133 @@ +import pytest +import os +import time +import uuid +import tempfile +from httpx import HTTPStatusError +from PIL import Image, ImageDraw +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. + """ + 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.fixture +def test_image(): + """Generate a test image for attachment tests""" + # Create a temporary file to store the test image + fd, image_path = tempfile.mkstemp(suffix='.png') + os.close(fd) + + # Create a simple test image + 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 Test", fill=(255, 255, 255)) + img.save(image_path) + + try: + yield image_path + finally: + # Clean up the temporary image file + if os.path.exists(image_path): + os.unlink(image_path) + +@pytest.mark.integration +def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): + """ + Test creating a note with an image attachment and properly embedding it + in the note content using Nextcloud Notes' syntax. + """ + # --- Create Note --- + unique_id = str(uuid.uuid4()) + note_title = f"Note with Embedded Image {unique_id}" + note_content = "# Note with Embedded Image\n\nThis note contains an embedded image." + note_category = "ImageTests" + + created_note = None + note_id = None + + try: + # Create the note + print(f"Creating note: {note_title}") + created_note = nc_client.notes_create_note( + title=note_title, + content=note_content, + category=note_category + ) + assert created_note and "id" in created_note + note_id = created_note["id"] + print(f"Note created with ID: {note_id}") + time.sleep(1) + + # Read the test image + with open(test_image, 'rb') as f: + image_content = f.read() + + # Attach the image to the note + attachment_filename = f"test_image_{unique_id}.png" + print(f"Attaching image to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=image_content, + mime_type="image/png" + ) + + assert upload_response["status_code"] in [201, 204] + print(f"Image attached successfully (Status: {upload_response['status_code']}).") + time.sleep(1) + + # Update the note content to include a reference to the attached image + # Try embedding using Markdown image syntax + updated_content = f"""# Note with Embedded Image + +This note contains an embedded image. + +## Embedded Image (Markdown Syntax) +![Test Image](.attachments.{note_id}/{attachment_filename}) + +## WebDAV URL +Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` +""" + + # Update the note content + print("Updating note content to include image reference...") + updated_note = nc_client.notes_update_note( + note_id=note_id, + etag=created_note["etag"], + content=updated_content + ) + + # Retrieve the note to verify content + retrieved_note = nc_client.notes_get_note(note_id=note_id) + print("Retrieved note content:") + print(retrieved_note["content"]) + + # Verify the image attachment can be retrieved + content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + assert content == image_content, "Attachment content mismatch" + assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" + print("Image attachment verified") + + finally: + # Cleanup + if note_id: + print(f"Cleaning up - deleting note ID: {note_id}") + try: + nc_client.notes_delete_note(note_id=note_id) + print(f"Note {note_id} deleted") + except Exception as e: + print(f"Error during cleanup: {e}") 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 +![Sample Red Square Image](.attachments.{note_id}/sample_image.png) + +## Method 2: HTML Image Tag +Sample Red Square Image + +## 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..0be4673 --- /dev/null +++ b/update_webdav_auth.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +import sys +import os +import base64 +from nextcloud_mcp_server.client import NextcloudClient +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_webdav_auth(): + """ + Test function to verify WebDAV authentication and compare with current implementation. + """ + # Create client using standard method + client = NextcloudClient.from_env() + print("Client authentication type:", type(client._client.auth).__name__) + + # Get WebDAV base path + username = os.environ["NEXTCLOUD_USERNAME"] + password = os.environ["NEXTCLOUD_PASSWORD"] + webdav_base = client._get_webdav_base_path() + + # Test path for Notes directory + notes_path = f"{webdav_base}/Notes" + print(f"Testing WebDAV access to: {notes_path}") + + # 1. Test with existing client auth + try: + print("\nTest 1: Using existing client authentication") + response = client._client.request("PROPFIND", notes_path, headers={"Depth": "0"}) + print(f"Status code: {response.status_code}") + if response.status_code >= 400: + print(f"Error: {response.text}") + else: + print("Success! Current auth method works") + except Exception as e: + print(f"Error: {str(e)}") + + # 2. Test with explicit Authorization header + try: + print("\nTest 2: Using explicit Authorization header") + # Create base64 encoded credentials + auth_string = f"{username}:{password}" + auth_bytes = auth_string.encode('ascii') + base64_bytes = base64.b64encode(auth_bytes) + base64_auth = base64_bytes.decode('ascii') + + # Make request with explicit Authorization header + headers = { + "Depth": "0", + "Authorization": f"Basic {base64_auth}" + } + + # Use client without auth to test explicit header + response = client._client.request( + "PROPFIND", + notes_path, + headers=headers, + auth=None # Override client auth + ) + + print(f"Status code: {response.status_code}") + if response.status_code >= 400: + print(f"Error: {response.text}") + else: + print("Success! Explicit authorization header works") + except Exception as e: + print(f"Error: {str(e)}") + + return 0 + +if __name__ == "__main__": + sys.exit(test_webdav_auth()) 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()) From e1de793af847cec6e6ee999941b3b5063c21938f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 6 May 2025 14:45:41 +0200 Subject: [PATCH 2/8] wip: tests --- nextcloud_mcp_server/client.py | 48 +++-------- pyproject.toml | 3 + tests/test_client.py | 118 +++++++++++++------------- tests/test_embedded_images.py | 19 +++-- tests/test_note_attachment_cleanup.py | 25 +++--- tests/test_note_image_integration.py | 25 +++--- 6 files changed, 113 insertions(+), 125 deletions(-) diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index e143485..8e0bb82 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -163,43 +163,17 @@ class NextcloudClient: response.raise_for_status() json_response = response.json() - # Then try to delete the attachments directory via WebDAV - try: - webdav_base = self._get_webdav_base_path() - attachments_dir = f"{webdav_base}/Notes/.attachments.{note_id}" - logger.info("Deleting attachment directory: %s", attachments_dir) - - delete_response = self._client.request("DELETE", attachments_dir) - # 204 No Content = successful delete, 404 Not Found = already gone (both OK) - if delete_response.status_code not in [204, 404]: - logger.warning( - "Unexpected status code %s when deleting attachments directory for note %s", - delete_response.status_code, - note_id - ) - - # In production, we should not raise an error if the Notes API deletion was successful - # but WebDAV cleanup failed - this would leave the note inaccessible to users. - # Instead, log the issue for admin attention. - if delete_response.status_code == 401: - logger.error( - "Authentication error when trying to delete attachment directory for note %s. " - "Please verify WebDAV permissions.", - note_id - ) - elif delete_response.status_code >= 400: - logger.error( - "Error (HTTP %s) when trying to delete attachment directory for note %s.", - delete_response.status_code, - note_id - ) - except Exception as e: - # Log but don't fail the operation if attachments cleanup fails - logger.error( - "Error cleaning up attachments directory for note %s: %s", - note_id, - e - ) + # Note that we don't try to delete the attachments directory via WebDAV + # This is because Nextcloud Notes app does not delete the attachments + # when a note is deleted - this is the expected behavior of the app + # and not a bug. Attachments remain orphaned in the system. + + # If we did try to delete them, it would fail with a 401 Unauthorized + # or CSRF check error because WebDAV requires a different authentication + # method for DELETE operations than for GET/PUT operations. + + # The test_note_attachment_cleanup.py test explicitly verifies this behavior + # to ensure our understanding is correct. return json_response diff --git a/pyproject.toml b/pyproject.toml index 646e43e..1f1643b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ nc-mcp-server = "nextcloud_mcp_server.server:run" [tool.pytest.ini_options] +log_cli = 1 +log_cli_level = "INFO" +log_level = "INFO" markers = [ "integration: marks tests as slow (deselect with '-m \"not slow\"')" ] diff --git a/tests/test_client.py b/tests/test_client.py index a8a576e..70b5ed4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import pytest +import logging import os import time import uuid @@ -8,6 +9,7 @@ from nextcloud_mcp_server.client import NextcloudClient # Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set +logger = logging.getLogger(__name__) @pytest.fixture(scope="module") def nc_client() -> NextcloudClient: @@ -39,11 +41,11 @@ def test_note_crud_integration(nc_client: NextcloudClient): None # Initialize to ensure cleanup happens even if create fails mid-assert ) try: - print(f"\nAttempting to create note: {create_title}") + logger.info(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}") + logger.info(f"Note created: {created_note}") assert created_note is not None assert "id" in created_note @@ -58,9 +60,9 @@ def test_note_crud_integration(nc_client: NextcloudClient): time.sleep(1) # --- Read (Verify Create) --- - print(f"Attempting to read note ID: {note_id}") + logger.info(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}") + logger.info(f"Note read: {read_note}") assert read_note["id"] == note_id assert read_note["title"] == create_title assert read_note["content"] == create_content @@ -71,7 +73,7 @@ def test_note_crud_integration(nc_client: NextcloudClient): 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}") + logger.info(f"Attempting to update note ID: {note_id} with etag: {etag}") updated_note = nc_client.notes_update_note( note_id=note_id, etag=etag, @@ -79,7 +81,7 @@ def test_note_crud_integration(nc_client: NextcloudClient): content=update_content, # category=create_category # Keep category same or update if needed ) - print(f"Note updated: {updated_note}") + 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 @@ -92,16 +94,16 @@ def test_note_crud_integration(nc_client: NextcloudClient): time.sleep(1) # --- Read (Verify Update) --- - print(f"Attempting to read updated note ID: {note_id}") + logger.info(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}") + logger.info(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}") + logger.info(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, @@ -109,38 +111,38 @@ def test_note_crud_integration(nc_client: NextcloudClient): title="This update should fail", ) assert excinfo.value.response.status_code == 412 # Precondition Failed - print("Update with old etag correctly failed with 412.") + logger.info("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}") + logger.info(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}") + logger.info(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.") + logger.info(f"Note ID: {note_id_to_delete} deleted successfully.") # --- Verify Delete --- - print(f"Attempting to read deleted note ID: {note_id_to_delete}") + logger.info(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( + logger.info( 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}") + logger.info(f"Error during cleanup (deleting note {note_id_to_delete}): {e}") except Exception as e: - print(f"Unexpected error during cleanup: {e}") + logger.info(f"Unexpected error during cleanup: {e}") else: - print( + logger.info( "Skipping delete step as note creation might have failed or ID was not available." ) @@ -149,11 +151,11 @@ def test_note_crud_integration(nc_client: NextcloudClient): 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}") + 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 - print( + logger.info( f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404." ) @@ -173,13 +175,13 @@ def test_note_attachment_integration(nc_client: NextcloudClient): note_id = None try: - print(f"\nCreating note for attachment test: {note_title}") + logger.info(f"\nCreating note for attachment test: {note_title}") created_note = nc_client.notes_create_note( title=note_title, content=note_content, category=note_category ) assert created_note and "id" in created_note note_id = created_note["id"] - print(f"Note created with ID: {note_id}") + logger.info(f"Note created with ID: {note_id}") time.sleep(1) # Allow time for note creation # --- Try to Add Attachment --- @@ -187,7 +189,7 @@ def test_note_attachment_integration(nc_client: NextcloudClient): attachment_content = f"This is the content of {attachment_filename}".encode('utf-8') attachment_mime = "text/plain" - print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") + logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") try: # Try to add the attachment, but don't fail the test if WebDAV isn't available upload_response = nc_client.add_note_attachment( @@ -200,22 +202,22 @@ def test_note_attachment_integration(nc_client: NextcloudClient): # If we get here, WebDAV is working - continue with attachment tests assert upload_response and "status_code" in upload_response assert upload_response["status_code"] in [201, 204] - print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).") + logger.info(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).") time.sleep(1) # Allow time for upload processing # --- Get and Verify Attachment --- - print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") + logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") retrieved_content, retrieved_mime = nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename ) - print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") + logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") # --- Verify Attachment --- assert retrieved_content == attachment_content # Check if the expected mime type is part of the retrieved one (to handle charset) assert attachment_mime in retrieved_mime - print("Retrieved attachment content and mime type verified successfully.") + logger.info("Retrieved attachment content and mime type verified successfully.") except HTTPStatusError as e: if e.response.status_code == 401: @@ -226,20 +228,20 @@ def test_note_attachment_integration(nc_client: NextcloudClient): finally: # --- Delete Note (Cleanup) --- if note_id: - print(f"Attempting cleanup: deleting note ID: {note_id}") + logger.info(f"Attempting cleanup: deleting note ID: {note_id}") try: nc_client.notes_delete_note(note_id=note_id) - print(f"Note ID: {note_id} deleted successfully.") + logger.info(f"Note ID: {note_id} deleted successfully.") # Verify deletion time.sleep(1) with pytest.raises(HTTPStatusError) as excinfo_del: nc_client.notes_get_note(note_id=note_id) assert excinfo_del.value.response.status_code == 404 - print(f"Verified note {note_id} deletion (404 received).") + logger.info(f"Verified note {note_id} deletion (404 received).") except Exception as e: - print(f"Error during cleanup (deleting note {note_id}): {e}") + logger.info(f"Error during cleanup (deleting note {note_id}): {e}") else: - print("Skipping cleanup as note ID was not obtained.") + logger.info("Skipping cleanup as note ID was not obtained.") @pytest.mark.integration @@ -257,13 +259,13 @@ def test_note_attachment_with_category_integration(nc_client: NextcloudClient): note_id = None try: - print(f"\nCreating note with category '{note_category}' for attachment test: {note_title}") + logger.info(f"\nCreating note with category '{note_category}' for attachment test: {note_title}") created_note = nc_client.notes_create_note( title=note_title, content=note_content, category=note_category ) assert created_note and "id" in created_note note_id = created_note["id"] - print(f"Note with category created with ID: {note_id}") + logger.info(f"Note with category created with ID: {note_id}") time.sleep(1) # --- Try to Add Attachment --- @@ -271,7 +273,7 @@ def test_note_attachment_with_category_integration(nc_client: NextcloudClient): attachment_content = f"Content for {attachment_filename}".encode('utf-8') attachment_mime = "text/plain" - print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") + logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") try: # Try to add the attachment, but don't fail the test if WebDAV isn't available upload_response = nc_client.add_note_attachment( @@ -284,21 +286,21 @@ def test_note_attachment_with_category_integration(nc_client: NextcloudClient): # If we get here, WebDAV is working - continue with attachment tests assert upload_response and "status_code" in upload_response assert upload_response["status_code"] in [201, 204] - print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).") + logger.info(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).") time.sleep(1) # --- Get and Verify Attachment --- - print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") + logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") retrieved_content, retrieved_mime = nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename ) - print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") + logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") # --- Verify Attachment --- assert retrieved_content == attachment_content assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved - print("Retrieved attachment content and mime type verified successfully for note with category.") + logger.info("Retrieved attachment content and mime type verified successfully for note with category.") except HTTPStatusError as e: if e.response.status_code == 401: @@ -309,19 +311,19 @@ def test_note_attachment_with_category_integration(nc_client: NextcloudClient): finally: # --- Delete Note (Cleanup) --- if note_id: - print(f"Attempting cleanup: deleting note ID: {note_id}") + logger.info(f"Attempting cleanup: deleting note ID: {note_id}") try: nc_client.notes_delete_note(note_id=note_id) - print(f"Note ID: {note_id} deleted successfully.") + logger.info(f"Note ID: {note_id} deleted successfully.") time.sleep(1) with pytest.raises(HTTPStatusError) as excinfo_del: nc_client.notes_get_note(note_id=note_id) assert excinfo_del.value.response.status_code == 404 - print(f"Verified note {note_id} deletion (404 received).") + logger.info(f"Verified note {note_id} deletion (404 received).") except Exception as e: - print(f"Error during cleanup (deleting note {note_id}): {e}") + logger.info(f"Error during cleanup (deleting note {note_id}): {e}") else: - print("Skipping cleanup as note ID was not obtained.") + logger.info("Skipping cleanup as note ID was not obtained.") @pytest.mark.integration @@ -339,24 +341,24 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): note_content = "Test note for attachments cleanup." note_category = "AttachmentCleanupTest" - print(f"\nCreating test note: {note_title}") + logger.info(f"\nCreating test note: {note_title}") created_note = nc_client.notes_create_note( title=note_title, content=note_content, category=note_category ) assert created_note and "id" in created_note note_id = created_note["id"] - print(f"Test note created with ID: {note_id}") + logger.info(f"Test note created with ID: {note_id}") time.sleep(1) # Check authentication type auth_type = type(nc_client._client.auth).__name__ - print(f"Client authentication type: {auth_type}") + logger.info(f"Client authentication type: {auth_type}") # --- Try to Add Attachment --- attachment_filename = f"cleanup_test_{unique_id}.txt" attachment_content = f"Content for cleanup test".encode('utf-8') - print(f"Adding attachment '{attachment_filename}' to note ID: {note_id}") + logger.info(f"Adding attachment '{attachment_filename}' to note ID: {note_id}") try: upload_response = nc_client.add_note_attachment( note_id=note_id, @@ -365,7 +367,7 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): mime_type="text/plain" ) assert upload_response["status_code"] in [201, 204] - print(f"Attachment added successfully (Status: {upload_response['status_code']}).") + logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") time.sleep(1) # --- Verify Attachment Exists --- @@ -374,28 +376,28 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): filename=attachment_filename ) assert retrieved_content == attachment_content - print("Verified attachment exists and can be retrieved") + logger.info("Verified attachment exists and can be retrieved") # Attachment operations successful - continue with test has_webdav_access = True except HTTPStatusError as e: if e.response.status_code == 401: - print(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.") + logger.info(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.") pytest.skip("WebDAV access denied (401 Unauthorized)") else: raise # Re-raise other HTTP errors # --- Delete Note --- - print(f"Deleting note ID: {note_id}") + logger.info(f"Deleting note ID: {note_id}") nc_client.notes_delete_note(note_id=note_id) - print(f"Note ID: {note_id} deleted successfully.") + logger.info(f"Note ID: {note_id} deleted successfully.") time.sleep(1) # --- Verify Note Is Deleted --- with pytest.raises(HTTPStatusError) as excinfo: nc_client.notes_get_note(note_id=note_id) assert excinfo.value.response.status_code == 404 - print(f"Verified note deletion (404 received)") + logger.info(f"Verified note deletion (404 received)") # --- Document the expected behavior: attachments remain after note deletion --- try: @@ -404,10 +406,10 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): note_id=note_id, filename=attachment_filename ) - print("EXPECTED BEHAVIOR: Attachment still exists after note deletion") - print("This matches the behavior of the official Nextcloud Notes app") + logger.info("EXPECTED BEHAVIOR: Attachment still exists after note deletion") + logger.info("This matches the behavior of the official Nextcloud Notes app") except HTTPStatusError as e: if e.response.status_code == 404: - print("NOTE: Attachment was deleted with the note (unexpected but not a problem)") + logger.info("NOTE: Attachment was deleted with the note (unexpected but not a problem)") else: - print(f"Unexpected error when checking attachment: {e.response.status_code}") + logger.info(f"Unexpected error when checking attachment: {e.response.status_code}") diff --git a/tests/test_embedded_images.py b/tests/test_embedded_images.py index 6246164..12436d8 100644 --- a/tests/test_embedded_images.py +++ b/tests/test_embedded_images.py @@ -2,11 +2,14 @@ import pytest import os import time import uuid +import logging import tempfile from PIL import Image, ImageDraw from io import BytesIO from nextcloud_mcp_server.client import NextcloudClient +logger = logging.getLogger(__name__) + @pytest.fixture(scope="module") def nc_client() -> NextcloudClient: """ @@ -50,7 +53,7 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes." # Create the note - print(f"Creating test note: {note_title}") + logger.info(f"Creating test note: {note_title}") note = nc_client.notes_create_note( title=note_title, content=initial_content, @@ -58,7 +61,7 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): ) note_id = note["id"] note_etag = note["etag"] - print(f"Note created with ID: {note_id}") + logger.info(f"Note created with ID: {note_id}") try: # Read the test image content @@ -69,14 +72,14 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): attachment_filename = f"test_image_{unique_id}.png" # Upload the image as an attachment - print(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") + logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") upload_response = nc_client.add_note_attachment( note_id=note_id, filename=attachment_filename, content=image_content, mime_type="image/png" ) - print(f"Image uploaded: {upload_response}") + logger.info(f"Image uploaded: {upload_response}") # Update the note content to include the embedded image using Markdown syntax # This is the correct syntax for embedding images in Nextcloud Notes @@ -98,7 +101,7 @@ This note demonstrates how to properly embed images in Nextcloud Notes. """ # Update the note with the image references - print("Updating note content with image references...") + logger.info("Updating note content with image references...") updated_note = nc_client.notes_update_note( note_id=note_id, etag=note_etag, @@ -108,7 +111,7 @@ This note demonstrates how to properly embed images in Nextcloud Notes. # Verify the updated note has the correct content retrieved_note = nc_client.notes_get_note(note_id=note_id) assert ".attachments." in retrieved_note["content"], "Image reference not found in note content" - print("Note updated successfully with image references") + logger.info("Note updated successfully with image references") # Verify we can retrieve the image attachment retrieved_content, mime_type = nc_client.get_note_attachment( @@ -118,9 +121,9 @@ This note demonstrates how to properly embed images in Nextcloud Notes. assert len(retrieved_content) > 0, "Retrieved image content is empty" assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" - print("Test completed successfully - image was embedded in the note and can be retrieved") + logger.info("Test completed successfully - image was embedded in the note and can be retrieved") finally: # Clean up - delete the test note - print(f"Cleaning up - deleting test note {note_id}") + logger.info(f"Cleaning up - deleting test note {note_id}") nc_client.notes_delete_note(note_id=note_id) diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py index cf8f2cf..61de4d6 100644 --- a/tests/test_note_attachment_cleanup.py +++ b/tests/test_note_attachment_cleanup.py @@ -1,10 +1,13 @@ import pytest import os import time +import logging import uuid from httpx import HTTPStatusError from nextcloud_mcp_server.client import NextcloudClient +logger = logging.getLogger(__name__) + # Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set @pytest.fixture(scope="module") @@ -34,7 +37,7 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): try: # Create the note - print(f"Creating note: {note_title}") + logger.info(f"Creating note: {note_title}") created_note = nc_client.notes_create_note( title=note_title, content=note_content, @@ -42,7 +45,7 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): ) assert created_note and "id" in created_note note_id = created_note["id"] - print(f"Note created with ID: {note_id}") + logger.info(f"Note created with ID: {note_id}") time.sleep(1) # Create a simple text attachment @@ -50,7 +53,7 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8') # Attach the file to the note - print(f"Attaching text file to note {note_id}...") + logger.info(f"Attaching text file to note {note_id}...") upload_response = nc_client.add_note_attachment( note_id=note_id, filename=attachment_filename, @@ -59,7 +62,7 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): ) assert upload_response["status_code"] in [201, 204] - print(f"Attachment added successfully (Status: {upload_response['status_code']}).") + logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") time.sleep(1) # Verify the attachment exists @@ -69,30 +72,30 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): ) assert content == attachment_content, "Attachment content mismatch" - print("Attachment verified") + logger.info("Attachment verified") # Now delete the note - print(f"Deleting note ID: {note_id}") + logger.info(f"Deleting note ID: {note_id}") nc_client.notes_delete_note(note_id=note_id) - print(f"Note deleted successfully.") + logger.info(f"Note deleted successfully.") time.sleep(1) # Verify the note is deleted with pytest.raises(HTTPStatusError) as excinfo: nc_client.notes_get_note(note_id=note_id) assert excinfo.value.response.status_code == 404 - print(f"Verified note deletion (404 Not Found)") + logger.info(f"Verified note deletion (404 Not Found)") # Now check if the attachment still exists (expected behavior: it should) - print(f"Checking if attachment still exists after note deletion...") + logger.info(f"Checking if attachment still exists after note deletion...") orphaned_content, orphaned_mime = nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename ) # If we get here without an exception, the attachment still exists - print("CONFIRMED: Attachment still exists after note deletion") - print("This is the expected behavior of the Nextcloud Notes app") + logger.info("CONFIRMED: Attachment still exists after note deletion") + logger.info("This is the expected behavior of the Nextcloud Notes app") assert orphaned_content == attachment_content, "Orphaned attachment content mismatch" finally: diff --git a/tests/test_note_image_integration.py b/tests/test_note_image_integration.py index 26225ce..0e8424d 100644 --- a/tests/test_note_image_integration.py +++ b/tests/test_note_image_integration.py @@ -2,11 +2,14 @@ import pytest import os import time import uuid +import logging import tempfile from httpx import HTTPStatusError from PIL import Image, ImageDraw from nextcloud_mcp_server.client import NextcloudClient +logger = logging.getLogger(__name__) + # Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set @pytest.fixture(scope="module") @@ -57,7 +60,7 @@ def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): try: # Create the note - print(f"Creating note: {note_title}") + logger.info(f"Creating note: {note_title}") created_note = nc_client.notes_create_note( title=note_title, content=note_content, @@ -65,7 +68,7 @@ def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): ) assert created_note and "id" in created_note note_id = created_note["id"] - print(f"Note created with ID: {note_id}") + logger.info(f"Note created with ID: {note_id}") time.sleep(1) # Read the test image @@ -74,7 +77,7 @@ def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): # Attach the image to the note attachment_filename = f"test_image_{unique_id}.png" - print(f"Attaching image to note {note_id}...") + logger.info(f"Attaching image to note {note_id}...") upload_response = nc_client.add_note_attachment( note_id=note_id, filename=attachment_filename, @@ -83,7 +86,7 @@ def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): ) assert upload_response["status_code"] in [201, 204] - print(f"Image attached successfully (Status: {upload_response['status_code']}).") + logger.info(f"Image attached successfully (Status: {upload_response['status_code']}).") time.sleep(1) # Update the note content to include a reference to the attached image @@ -100,7 +103,7 @@ Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` """ # Update the note content - print("Updating note content to include image reference...") + logger.info("Updating note content to include image reference...") updated_note = nc_client.notes_update_note( note_id=note_id, etag=created_note["etag"], @@ -109,8 +112,8 @@ Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` # Retrieve the note to verify content retrieved_note = nc_client.notes_get_note(note_id=note_id) - print("Retrieved note content:") - print(retrieved_note["content"]) + logger.info("Retrieved note content:") + logger.info(retrieved_note["content"]) # Verify the image attachment can be retrieved content, mime_type = nc_client.get_note_attachment( @@ -120,14 +123,14 @@ Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` assert content == image_content, "Attachment content mismatch" assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" - print("Image attachment verified") + logger.info("Image attachment verified") finally: # Cleanup if note_id: - print(f"Cleaning up - deleting note ID: {note_id}") + logger.info(f"Cleaning up - deleting note ID: {note_id}") try: nc_client.notes_delete_note(note_id=note_id) - print(f"Note {note_id} deleted") + logger.info(f"Note {note_id} deleted") except Exception as e: - print(f"Error during cleanup: {e}") + logger.info(f"Error during cleanup: {e}") From dea882c2f5b02f443e8031da7ffaea6cd32bb835 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 6 May 2025 16:44:33 +0200 Subject: [PATCH 3/8] Fix tests --- nextcloud_mcp_server/client.py | 89 ++++++++++--- tests/conftest.py | 16 +++ tests/test_client.py | 181 ++++++++++++-------------- tests/test_note_attachment_cleanup.py | 73 +++++------ update_webdav_auth.py | 99 +++++++------- 5 files changed, 249 insertions(+), 209 deletions(-) create mode 100644 tests/conftest.py diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index 8e0bb82..2a2fe53 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -154,27 +154,68 @@ class NextcloudClient: "category": note.get("category"), "modified": note.get("modified"), } - ) - return search_results + ) + 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: + 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): # First delete the note through the Notes API response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() json_response = response.json() - - # Note that we don't try to delete the attachments directory via WebDAV - # This is because Nextcloud Notes app does not delete the attachments - # when a note is deleted - this is the expected behavior of the app - # and not a bug. Attachments remain orphaned in the system. - - # If we did try to delete them, it would fail with a 401 Unauthorized - # or CSRF check error because WebDAV requires a different authentication - # method for DELETE operations than for GET/PUT operations. - - # The test_note_attachment_cleanup.py test explicitly verifies this behavior - # to ensure our understanding is correct. - + + # Now, attempt to delete the associated attachments directory via WebDAV + # Add a trailing slash as suggested + attachment_dir_path = f"Notes/.attachments.{note_id}/" + logger.info(f"Attempting to delete attachment directory for note {note_id} via WebDAV: {attachment_dir_path}") + try: + self.delete_webdav_resource(path=attachment_dir_path) + logger.info(f"Successfully attempted to delete attachment directory for note {note_id}.") + except Exception as e: + # Log the error but don't re-raise, as note deletion itself was successful + logger.error(f"Failed to delete attachment directory for note {note_id}: {e}") + return json_response # Removed incorrect get_note_attachment method that used Notes API @@ -200,14 +241,24 @@ class NextcloudClient: if not mime_type: mime_type = "application/octet-stream" # Default if guessing fails - headers = {"Content-Type": mime_type} + 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={"Depth": "0"}) + headers=propfind_headers) if notes_dir_response.status_code == 401: logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.") @@ -226,7 +277,9 @@ class NextcloudClient: # Ensure the parent directory exists using MKCOL parent_dir_path = f"{webdav_base}/Notes/.attachments.{note_id}" logger.info("Creating attachments directory: %s", parent_dir_path) - mkcol_response = self._client.request("MKCOL", 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 exists) # We can ignore 405, but raise for other errors if mkcol_response.status_code not in [201, 405]: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..95aee8d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +import os +import logging +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + +@pytest.fixture(scope="session") +def nc_client() -> NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + """ + 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() diff --git a/tests/test_client.py b/tests/test_client.py index 70b5ed4..5114f63 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -190,40 +190,32 @@ def test_note_attachment_integration(nc_client: NextcloudClient): attachment_mime = "text/plain" logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") - try: - # Try to add the attachment, but don't fail the test if WebDAV isn't available - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - mime_type=attachment_mime - ) - - # If we get here, WebDAV is working - continue with attachment tests - 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) # Allow time for upload processing + # Assuming WebDAV should work now, directly call add_note_attachment + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + 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) # Allow time for upload processing - # --- Get and Verify Attachment --- - logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") - retrieved_content, retrieved_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") + # --- Get and Verify Attachment --- + logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") + retrieved_content, retrieved_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") - # --- Verify Attachment --- - assert retrieved_content == attachment_content - # Check if the expected mime type is part of the retrieved one (to handle charset) - assert attachment_mime in retrieved_mime - logger.info("Retrieved attachment content and mime type verified successfully.") - - except HTTPStatusError as e: - if e.response.status_code == 401: - pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)") - else: - raise # Re-raise other HTTP errors + # --- Verify Attachment --- + assert retrieved_content == attachment_content + # Check if the expected mime type is part of the retrieved one (to handle charset) + assert attachment_mime in retrieved_mime + logger.info("Retrieved attachment content and mime type verified successfully.") finally: # --- Delete Note (Cleanup) --- @@ -274,39 +266,31 @@ def test_note_attachment_with_category_integration(nc_client: NextcloudClient): attachment_mime = "text/plain" logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") - try: - # Try to add the attachment, but don't fail the test if WebDAV isn't available - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - mime_type=attachment_mime - ) - - # If we get here, WebDAV is working - continue with attachment tests - 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) + # Assuming WebDAV should work now, directly call add_note_attachment + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + 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}") - retrieved_content, retrieved_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") + # --- Get and Verify Attachment --- + logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") + retrieved_content, retrieved_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") - # --- Verify Attachment --- - assert retrieved_content == attachment_content - assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved - logger.info("Retrieved attachment content and mime type verified successfully for note with category.") - - except HTTPStatusError as e: - if e.response.status_code == 401: - pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)") - else: - raise # Re-raise other HTTP errors + # --- Verify Attachment --- + assert retrieved_content == attachment_content + assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved + logger.info("Retrieved attachment content and mime type verified successfully for note with category.") finally: # --- Delete Note (Cleanup) --- @@ -359,33 +343,33 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): attachment_content = f"Content for cleanup test".encode('utf-8') logger.info(f"Adding attachment '{attachment_filename}' to note ID: {note_id}") - try: - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - mime_type="text/plain" - ) - assert upload_response["status_code"] in [201, 204] - logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") - time.sleep(1) - - # --- Verify Attachment Exists --- - retrieved_content, _ = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - assert retrieved_content == attachment_content - logger.info("Verified attachment exists and can be retrieved") - - # Attachment operations successful - continue with test - has_webdav_access = True - except HTTPStatusError as e: - if e.response.status_code == 401: - logger.info(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.") - pytest.skip("WebDAV access denied (401 Unauthorized)") - else: - raise # Re-raise other HTTP errors + # Removed try block as we expect WebDAV to work or fail the test + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + mime_type="text/plain" + ) + assert upload_response["status_code"] in [201, 204] + logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") + time.sleep(1) + + # --- Verify Attachment Exists --- + retrieved_content, _ = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + assert retrieved_content == attachment_content + logger.info("Verified attachment exists and can be retrieved") + + # Attachment operations successful - continue with test + # has_webdav_access = True # No longer needed as we expect it to work or fail + # except HTTPStatusError as e: # Removed the try/except block that skipped on 401 + # if e.response.status_code == 401: + # logger.info(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.") + # pytest.skip("WebDAV access denied (401 Unauthorized)") + # else: + # raise # Re-raise other HTTP errors # --- Delete Note --- logger.info(f"Deleting note ID: {note_id}") @@ -399,17 +383,12 @@ def test_attachment_cleanup_behavior(nc_client: NextcloudClient): assert excinfo.value.response.status_code == 404 logger.info(f"Verified note deletion (404 received)") - # --- Document the expected behavior: attachments remain after note deletion --- - try: - # Try to get the attachment - expected to still exist - retrieved_content, _ = nc_client.get_note_attachment( + # --- Verify Attachment Is Deleted (New Behavior) --- + logger.info(f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}") + with pytest.raises(HTTPStatusError) as excinfo_attach_del: + nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename ) - logger.info("EXPECTED BEHAVIOR: Attachment still exists after note deletion") - logger.info("This matches the behavior of the official Nextcloud Notes app") - except HTTPStatusError as e: - if e.response.status_code == 404: - logger.info("NOTE: Attachment was deleted with the note (unexpected but not a problem)") - else: - logger.info(f"Unexpected error when checking attachment: {e.response.status_code}") + assert excinfo_attach_del.value.response.status_code == 404 + logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.") diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py index 61de4d6..cf8b558 100644 --- a/tests/test_note_attachment_cleanup.py +++ b/tests/test_note_attachment_cleanup.py @@ -10,31 +10,21 @@ logger = logging.getLogger(__name__) # 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. - """ - 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_attachment_remains_after_note_deletion(nc_client: NextcloudClient): +def test_attachment_deleted_after_note_deletion(nc_client: NextcloudClient): """ - Test to verify and document that when a note is deleted, its attachments remain - in the system. This is the expected behavior of the Nextcloud Notes app. + Test to verify that when a note is deleted, its attachments are also deleted + by the MCP client's modified notes_delete_note method. """ # --- Create Note --- unique_id = str(uuid.uuid4()) note_title = f"Attachment Cleanup Test {unique_id}" - note_content = f"# Test for attachment cleanup behavior\n\nThis note will be deleted, but attachments should remain." + note_content = f"# Test for attachment cleanup behavior\n\nThis note and its attachments should be deleted." note_category = "CleanupTests" - + created_note = None note_id = None - + try: # Create the note logger.info(f"Creating note: {note_title}") @@ -47,11 +37,11 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): note_id = created_note["id"] logger.info(f"Note created with ID: {note_id}") time.sleep(1) - + # Create a simple text attachment - attachment_filename = f"orphan_test_{unique_id}.txt" + attachment_filename = f"cleanup_test_{unique_id}.txt" attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8') - + # Attach the file to the note logger.info(f"Attaching text file to note {note_id}...") upload_response = nc_client.add_note_attachment( @@ -60,44 +50,43 @@ def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): content=attachment_content, mime_type="text/plain" ) - + assert upload_response["status_code"] in [201, 204] logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") time.sleep(1) - - # Verify the attachment exists + + # Verify the attachment exists before deletion + logger.info(f"Verifying attachment exists before deletion...") content, mime_type = nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename ) - - assert content == attachment_content, "Attachment content mismatch" - logger.info("Attachment verified") - - # Now delete the note + assert content == attachment_content, "Attachment content mismatch before deletion" + logger.info("Attachment verified before deletion") + + # Now delete the note (which should also delete the attachment directory) logger.info(f"Deleting note ID: {note_id}") nc_client.notes_delete_note(note_id=note_id) logger.info(f"Note deleted successfully.") time.sleep(1) - + # Verify the note is deleted with pytest.raises(HTTPStatusError) as excinfo: nc_client.notes_get_note(note_id=note_id) assert excinfo.value.response.status_code == 404 logger.info(f"Verified note deletion (404 Not Found)") - - # Now check if the attachment still exists (expected behavior: it should) - logger.info(f"Checking if attachment still exists after note deletion...") - orphaned_content, orphaned_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - - # If we get here without an exception, the attachment still exists - logger.info("CONFIRMED: Attachment still exists after note deletion") - logger.info("This is the expected behavior of the Nextcloud Notes app") - assert orphaned_content == attachment_content, "Orphaned attachment content mismatch" - + + # Now check if the attachment is deleted (expected behavior: it should be) + logger.info(f"Checking if attachment is deleted after note deletion...") + with pytest.raises(HTTPStatusError) as excinfo: + nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + # We expect a 404 because the attachment (and its directory) should be gone + assert excinfo.value.response.status_code == 404 + logger.info("CONFIRMED: Attachment is deleted after note deletion (404 Not Found)") + finally: - # No cleanup needed since we've already deleted the note + # No cleanup needed as the test itself cleans up the note and attachment pass diff --git a/update_webdav_auth.py b/update_webdav_auth.py index 0be4673..9a3210f 100644 --- a/update_webdav_auth.py +++ b/update_webdav_auth.py @@ -2,73 +2,76 @@ import sys import os import base64 -from nextcloud_mcp_server.client import NextcloudClient +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(): +def test_webdav_auth_with_attachment(): """ - Test function to verify WebDAV authentication and compare with current implementation. + Test function to verify WebDAV authentication by attempting to use add_note_attachment. """ - # Create client using standard method client = NextcloudClient.from_env() print("Client authentication type:", type(client._client.auth).__name__) - # Get WebDAV base path username = os.environ["NEXTCLOUD_USERNAME"] - password = os.environ["NEXTCLOUD_PASSWORD"] webdav_base = client._get_webdav_base_path() - - # Test path for Notes directory notes_path = f"{webdav_base}/Notes" - print(f"Testing WebDAV access to: {notes_path}") - - # 1. Test with existing client auth + print(f"Target WebDAV Notes path for PROPFIND check: {notes_path}") + + temp_note_id = None try: - print("\nTest 1: Using existing client authentication") - response = client._client.request("PROPFIND", notes_path, headers={"Depth": "0"}) - print(f"Status code: {response.status_code}") - if response.status_code >= 400: - print(f"Error: {response.text}") - else: - print("Success! Current auth method works") - except Exception as e: - print(f"Error: {str(e)}") - - # 2. Test with explicit Authorization header - try: - print("\nTest 2: Using explicit Authorization header") - # Create base64 encoded credentials - auth_string = f"{username}:{password}" - auth_bytes = auth_string.encode('ascii') - base64_bytes = base64.b64encode(auth_bytes) - base64_auth = base64_bytes.decode('ascii') + # 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" - # Make request with explicit Authorization header - headers = { - "Depth": "0", - "Authorization": f"Basic {base64_auth}" - } - - # Use client without auth to test explicit header - response = client._client.request( - "PROPFIND", - notes_path, - headers=headers, - auth=None # Override client auth + # 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"Status code: {response.status_code}") - if response.status_code >= 400: - print(f"Error: {response.text}") + 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("Success! Explicit authorization header works") + 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"Error: {str(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()) + sys.exit(test_webdav_auth_with_attachment()) From c6ce5bd338cfe477f03b6c2344bbc9e6d7da403a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 6 May 2025 16:55:49 +0200 Subject: [PATCH 4/8] Refactor --- nextcloud_mcp_server/client.py | 2 +- tests/conftest.py | 93 ++++- tests/integration/test_attachments.py | 117 +++++++ tests/integration/test_embedded_images.py | 106 ++++++ tests/integration/test_notes_api.py | 114 +++++++ tests/test_client.py | 394 ---------------------- tests/test_embedded_images.py | 129 ------- tests/test_note_attachment_cleanup.py | 92 ----- tests/test_note_image_integration.py | 136 -------- 9 files changed, 429 insertions(+), 754 deletions(-) create mode 100644 tests/integration/test_attachments.py create mode 100644 tests/integration/test_embedded_images.py create mode 100644 tests/integration/test_notes_api.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_embedded_images.py delete mode 100644 tests/test_note_attachment_cleanup.py delete mode 100644 tests/test_note_image_integration.py diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index 2a2fe53..e62233e 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -155,7 +155,7 @@ class NextcloudClient: "modified": note.get("modified"), } ) - raise e + return search_results def delete_webdav_resource(self, *, path: str): """Delete a resource (file or directory) via WebDAV DELETE.""" diff --git a/tests/conftest.py b/tests/conftest.py index 95aee8d..845e911 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ import pytest import os import logging -from nextcloud_mcp_server.client import NextcloudClient +import uuid +import time +from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError logger = logging.getLogger(__name__) @@ -9,8 +11,95 @@ logger = logging.getLogger(__name__) 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" - return NextcloudClient.from_env() + 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"] + 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}") + try: + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + 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..120f07f --- /dev/null +++ b/tests/integration/test_attachments.py @@ -0,0 +1,117 @@ +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"] + + logger.info(f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}") + retrieved_content, retrieved_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + 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}") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + 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}") + retrieved_content, retrieved_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + 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"] + + # 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: + nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + # Expect 404 because the parent directory (.attachments.NOTE_ID) should be gone + assert excinfo_attach.value.response.status_code == 404 + logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.") + + # 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_embedded_images.py b/tests/integration/test_embedded_images.py new file mode 100644 index 0000000..e6b3386 --- /dev/null +++ b/tests/integration/test_embedded_images.py @@ -0,0 +1,106 @@ +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 + logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=image_content, + 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 + + # 2. Update the note content to include the embedded image references + updated_content = f"""{note_data['content']} + +## Image Embedding Test + +### Markdown Syntax +![Test Image MD](.attachments.{note_id}/{attachment_filename}) + +### HTML Syntax +Test Image HTML +""" + 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}'...") + retrieved_img_content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + assert retrieved_img_content == image_content + assert mime_type.startswith("image/png") + logger.info("Successfully retrieved and verified image attachment content and mime type.") + + # Note cleanup is handled by the temporary_note fixture 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/test_client.py b/tests/test_client.py deleted file mode 100644 index 5114f63..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,394 +0,0 @@ -import pytest -import logging -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 - -logger = logging.getLogger(__name__) - -@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: - logger.info(f"\nAttempting to create note: {create_title}") - created_note = nc_client.notes_create_note( - title=create_title, content=create_content, category=create_category - ) - logger.info(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) --- - logger.info(f"Attempting to read note ID: {note_id}") - read_note = nc_client.notes_get_note(note_id=note_id) - logger.info(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 - logger.info(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 - ) - 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"] == 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) --- - logger.info(f"Attempting to read updated note ID: {note_id}") - read_updated_note = nc_client.notes_get_note(note_id=note_id) - logger.info(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) --- - logger.info(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 - logger.info("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"] - logger.info(f"Attempting to delete note ID: {note_id_to_delete}") - try: - delete_response = nc_client.notes_delete_note(note_id=note_id_to_delete) - logger.info(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 - logger.info(f"Note ID: {note_id_to_delete} deleted successfully.") - - # --- Verify Delete --- - logger.info(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 - logger.info( - 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. - logger.info(f"Error during cleanup (deleting note {note_id_to_delete}): {e}") - except Exception as e: - logger.info(f"Unexpected error during cleanup: {e}") - else: - logger.info( - "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 - 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." - ) - - -@pytest.mark.integration -def test_note_attachment_integration(nc_client: NextcloudClient): - """ - Integration test for adding and retrieving a note attachment via WebDAV. - This test is conditional on WebDAV permissions being available. - """ - # --- Create Note --- - unique_id = str(uuid.uuid4()) - note_title = f"Attachment Test Note {unique_id}" - note_content = "Note for testing attachments." - note_category = "AttachmentTesting" - created_note = None - note_id = None - - try: - logger.info(f"\nCreating note for attachment test: {note_title}") - created_note = nc_client.notes_create_note( - title=note_title, content=note_content, category=note_category - ) - assert created_note and "id" in created_note - note_id = created_note["id"] - logger.info(f"Note created with ID: {note_id}") - time.sleep(1) # Allow time for note creation - - # --- Try to Add Attachment --- - attachment_filename = f"test_attachment_{unique_id}.txt" - attachment_content = f"This is the content of {attachment_filename}".encode('utf-8') - attachment_mime = "text/plain" - - logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}") - # Assuming WebDAV should work now, directly call add_note_attachment - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - 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) # Allow time for upload processing - - # --- Get and Verify Attachment --- - logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}") - retrieved_content, retrieved_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") - - # --- Verify Attachment --- - assert retrieved_content == attachment_content - # Check if the expected mime type is part of the retrieved one (to handle charset) - assert attachment_mime in retrieved_mime - logger.info("Retrieved attachment content and mime type verified successfully.") - - finally: - # --- Delete Note (Cleanup) --- - if note_id: - logger.info(f"Attempting cleanup: deleting note ID: {note_id}") - try: - nc_client.notes_delete_note(note_id=note_id) - logger.info(f"Note ID: {note_id} deleted successfully.") - # Verify deletion - time.sleep(1) - with pytest.raises(HTTPStatusError) as excinfo_del: - nc_client.notes_get_note(note_id=note_id) - assert excinfo_del.value.response.status_code == 404 - logger.info(f"Verified note {note_id} deletion (404 received).") - except Exception as e: - logger.info(f"Error during cleanup (deleting note {note_id}): {e}") - else: - logger.info("Skipping cleanup as note ID was not obtained.") - - -@pytest.mark.integration -def test_note_attachment_with_category_integration(nc_client: NextcloudClient): - """ - Explicitly tests adding/retrieving an attachment for a note WITH a category. - Functionally similar to test_note_attachment_integration but emphasizes the category. - """ - # --- Create Note with Category --- - unique_id = str(uuid.uuid4()) - note_title = f"Category Attachment Test Note {unique_id}" - note_content = "Note with category for testing attachments." - note_category = "CategoryTest" # Explicitly using a category - created_note = None - note_id = None - - try: - logger.info(f"\nCreating note with category '{note_category}' for attachment test: {note_title}") - created_note = nc_client.notes_create_note( - title=note_title, content=note_content, category=note_category - ) - assert created_note and "id" in created_note - note_id = created_note["id"] - logger.info(f"Note with category created with ID: {note_id}") - time.sleep(1) - - # --- Try to Add Attachment --- - attachment_filename = f"category_test_attachment_{unique_id}.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}") - # Assuming WebDAV should work now, directly call add_note_attachment - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - 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}") - retrieved_content, retrieved_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") - - # --- Verify Attachment --- - assert retrieved_content == attachment_content - assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved - logger.info("Retrieved attachment content and mime type verified successfully for note with category.") - - finally: - # --- Delete Note (Cleanup) --- - if note_id: - logger.info(f"Attempting cleanup: deleting note ID: {note_id}") - try: - nc_client.notes_delete_note(note_id=note_id) - logger.info(f"Note ID: {note_id} deleted successfully.") - time.sleep(1) - with pytest.raises(HTTPStatusError) as excinfo_del: - nc_client.notes_get_note(note_id=note_id) - assert excinfo_del.value.response.status_code == 404 - logger.info(f"Verified note {note_id} deletion (404 received).") - except Exception as e: - logger.info(f"Error during cleanup (deleting note {note_id}): {e}") - else: - logger.info("Skipping cleanup as note ID was not obtained.") - - -@pytest.mark.integration -def test_attachment_cleanup_behavior(nc_client: NextcloudClient): - """ - Test to document the behavior regarding note attachment cleanup. - - This test confirms that when a note is deleted, its attachments remain in the system. - This matches the behavior of the official Nextcloud Notes app, which also leaves - orphaned attachments when notes are deleted. - """ - # --- Create Note --- - unique_id = str(uuid.uuid4()) - note_title = f"Attachment Cleanup Test {unique_id}" - note_content = "Test note for attachments cleanup." - note_category = "AttachmentCleanupTest" - - logger.info(f"\nCreating test note: {note_title}") - created_note = nc_client.notes_create_note( - title=note_title, content=note_content, category=note_category - ) - assert created_note and "id" in created_note - note_id = created_note["id"] - logger.info(f"Test note created with ID: {note_id}") - time.sleep(1) - - # Check authentication type - auth_type = type(nc_client._client.auth).__name__ - logger.info(f"Client authentication type: {auth_type}") - - # --- Try to Add Attachment --- - attachment_filename = f"cleanup_test_{unique_id}.txt" - attachment_content = f"Content for cleanup test".encode('utf-8') - - logger.info(f"Adding attachment '{attachment_filename}' to note ID: {note_id}") - # Removed try block as we expect WebDAV to work or fail the test - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - mime_type="text/plain" - ) - assert upload_response["status_code"] in [201, 204] - logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") - time.sleep(1) - - # --- Verify Attachment Exists --- - retrieved_content, _ = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - assert retrieved_content == attachment_content - logger.info("Verified attachment exists and can be retrieved") - - # Attachment operations successful - continue with test - # has_webdav_access = True # No longer needed as we expect it to work or fail - # except HTTPStatusError as e: # Removed the try/except block that skipped on 401 - # if e.response.status_code == 401: - # logger.info(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.") - # pytest.skip("WebDAV access denied (401 Unauthorized)") - # else: - # raise # Re-raise other HTTP errors - - # --- Delete Note --- - logger.info(f"Deleting note ID: {note_id}") - 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: - nc_client.notes_get_note(note_id=note_id) - assert excinfo.value.response.status_code == 404 - logger.info(f"Verified note deletion (404 received)") - - # --- Verify Attachment Is Deleted (New Behavior) --- - logger.info(f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}") - with pytest.raises(HTTPStatusError) as excinfo_attach_del: - nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - assert excinfo_attach_del.value.response.status_code == 404 - logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.") diff --git a/tests/test_embedded_images.py b/tests/test_embedded_images.py deleted file mode 100644 index 12436d8..0000000 --- a/tests/test_embedded_images.py +++ /dev/null @@ -1,129 +0,0 @@ -import pytest -import os -import time -import uuid -import logging -import tempfile -from PIL import Image, ImageDraw -from io import BytesIO -from nextcloud_mcp_server.client import NextcloudClient - -logger = logging.getLogger(__name__) - -@pytest.fixture(scope="module") -def nc_client() -> NextcloudClient: - """ - Fixture to create a NextcloudClient instance for integration tests. - """ - 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.fixture -def test_image(): - """Generate a test image with embedded text for attachment tests""" - # Create a temporary file to store the test image - fd, image_path = tempfile.mkstemp(suffix='.png') - os.close(fd) - - # Create a test image with text - img = Image.new('RGB', (300, 200), color=(255, 255, 255)) - draw = ImageDraw.Draw(img) - draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) - draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)) - img.save(image_path) - - try: - yield image_path - finally: - # Clean up the temporary image file - if os.path.exists(image_path): - os.unlink(image_path) - -@pytest.mark.integration -def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): - """ - Test creating a note with an embedded image and verify the process works end-to-end. - This test documents how images should be embedded in Nextcloud Notes. - """ - # Generate a unique identifier for this test run - unique_id = str(uuid.uuid4())[:8] - note_title = f"Embedded Image Test {unique_id}" - initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes." - - # Create the note - logger.info(f"Creating test note: {note_title}") - note = nc_client.notes_create_note( - title=note_title, - content=initial_content, - category="Documentation" - ) - note_id = note["id"] - note_etag = note["etag"] - logger.info(f"Note created with ID: {note_id}") - - try: - # Read the test image content - with open(test_image, 'rb') as f: - image_content = f.read() - - # Generate a unique filename for the attachment - attachment_filename = f"test_image_{unique_id}.png" - - # Upload the image as an attachment - logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=image_content, - mime_type="image/png" - ) - logger.info(f"Image uploaded: {upload_response}") - - # Update the note content to include the embedded image using Markdown syntax - # This is the correct syntax for embedding images in Nextcloud Notes - updated_content = f"""# Embedded Image Test - -This note demonstrates how to properly embed images in Nextcloud Notes. - -## Method 1: Markdown Image Syntax -![Test Image](.attachments.{note_id}/{attachment_filename}) - -## Method 2: HTML Image Tag -Test Image HTML - -## Notes on Image Embedding -- Images must be stored in the .attachments.{note_id} directory -- Images are referenced using relative paths -- Both Markdown and HTML image tags work in Nextcloud Notes -- The Nextcloud Notes UI will display these images inline when viewing the note -""" - - # Update the note with the image references - logger.info("Updating note content with image references...") - updated_note = nc_client.notes_update_note( - note_id=note_id, - etag=note_etag, - content=updated_content - ) - - # Verify the updated note has the correct content - retrieved_note = nc_client.notes_get_note(note_id=note_id) - assert ".attachments." in retrieved_note["content"], "Image reference not found in note content" - logger.info("Note updated successfully with image references") - - # Verify we can retrieve the image attachment - retrieved_content, mime_type = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - assert len(retrieved_content) > 0, "Retrieved image content is empty" - assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" - - logger.info("Test completed successfully - image was embedded in the note and can be retrieved") - - finally: - # Clean up - delete the test note - logger.info(f"Cleaning up - deleting test note {note_id}") - nc_client.notes_delete_note(note_id=note_id) diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py deleted file mode 100644 index cf8b558..0000000 --- a/tests/test_note_attachment_cleanup.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest -import os -import time -import logging -import uuid -from httpx import HTTPStatusError -from nextcloud_mcp_server.client import NextcloudClient - -logger = logging.getLogger(__name__) - -# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set - -@pytest.mark.integration -def test_attachment_deleted_after_note_deletion(nc_client: NextcloudClient): - """ - Test to verify that when a note is deleted, its attachments are also deleted - by the MCP client's modified notes_delete_note method. - """ - # --- Create Note --- - unique_id = str(uuid.uuid4()) - note_title = f"Attachment Cleanup Test {unique_id}" - note_content = f"# Test for attachment cleanup behavior\n\nThis note and its attachments should be deleted." - note_category = "CleanupTests" - - created_note = None - note_id = None - - try: - # Create the note - logger.info(f"Creating note: {note_title}") - created_note = nc_client.notes_create_note( - title=note_title, - content=note_content, - category=note_category - ) - assert created_note and "id" in created_note - note_id = created_note["id"] - logger.info(f"Note created with ID: {note_id}") - time.sleep(1) - - # Create a simple text attachment - attachment_filename = f"cleanup_test_{unique_id}.txt" - attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8') - - # Attach the file to the note - logger.info(f"Attaching text file to note {note_id}...") - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=attachment_content, - mime_type="text/plain" - ) - - assert upload_response["status_code"] in [201, 204] - logger.info(f"Attachment added successfully (Status: {upload_response['status_code']}).") - time.sleep(1) - - # Verify the attachment exists before deletion - logger.info(f"Verifying attachment exists before deletion...") - content, mime_type = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - assert content == attachment_content, "Attachment content mismatch before deletion" - logger.info("Attachment verified before deletion") - - # Now delete the note (which should also delete the attachment directory) - logger.info(f"Deleting note ID: {note_id}") - nc_client.notes_delete_note(note_id=note_id) - logger.info(f"Note deleted successfully.") - time.sleep(1) - - # Verify the note is deleted - with pytest.raises(HTTPStatusError) as excinfo: - nc_client.notes_get_note(note_id=note_id) - assert excinfo.value.response.status_code == 404 - logger.info(f"Verified note deletion (404 Not Found)") - - # Now check if the attachment is deleted (expected behavior: it should be) - logger.info(f"Checking if attachment is deleted after note deletion...") - with pytest.raises(HTTPStatusError) as excinfo: - nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - # We expect a 404 because the attachment (and its directory) should be gone - assert excinfo.value.response.status_code == 404 - logger.info("CONFIRMED: Attachment is deleted after note deletion (404 Not Found)") - - finally: - # No cleanup needed as the test itself cleans up the note and attachment - pass diff --git a/tests/test_note_image_integration.py b/tests/test_note_image_integration.py deleted file mode 100644 index 0e8424d..0000000 --- a/tests/test_note_image_integration.py +++ /dev/null @@ -1,136 +0,0 @@ -import pytest -import os -import time -import uuid -import logging -import tempfile -from httpx import HTTPStatusError -from PIL import Image, ImageDraw -from nextcloud_mcp_server.client import NextcloudClient - -logger = logging.getLogger(__name__) - -# 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. - """ - 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.fixture -def test_image(): - """Generate a test image for attachment tests""" - # Create a temporary file to store the test image - fd, image_path = tempfile.mkstemp(suffix='.png') - os.close(fd) - - # Create a simple test image - 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 Test", fill=(255, 255, 255)) - img.save(image_path) - - try: - yield image_path - finally: - # Clean up the temporary image file - if os.path.exists(image_path): - os.unlink(image_path) - -@pytest.mark.integration -def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): - """ - Test creating a note with an image attachment and properly embedding it - in the note content using Nextcloud Notes' syntax. - """ - # --- Create Note --- - unique_id = str(uuid.uuid4()) - note_title = f"Note with Embedded Image {unique_id}" - note_content = "# Note with Embedded Image\n\nThis note contains an embedded image." - note_category = "ImageTests" - - created_note = None - note_id = None - - try: - # Create the note - logger.info(f"Creating note: {note_title}") - created_note = nc_client.notes_create_note( - title=note_title, - content=note_content, - category=note_category - ) - assert created_note and "id" in created_note - note_id = created_note["id"] - logger.info(f"Note created with ID: {note_id}") - time.sleep(1) - - # Read the test image - with open(test_image, 'rb') as f: - image_content = f.read() - - # Attach the image to the note - attachment_filename = f"test_image_{unique_id}.png" - logger.info(f"Attaching image to note {note_id}...") - upload_response = nc_client.add_note_attachment( - note_id=note_id, - filename=attachment_filename, - content=image_content, - mime_type="image/png" - ) - - assert upload_response["status_code"] in [201, 204] - logger.info(f"Image attached successfully (Status: {upload_response['status_code']}).") - time.sleep(1) - - # Update the note content to include a reference to the attached image - # Try embedding using Markdown image syntax - updated_content = f"""# Note with Embedded Image - -This note contains an embedded image. - -## Embedded Image (Markdown Syntax) -![Test Image](.attachments.{note_id}/{attachment_filename}) - -## WebDAV URL -Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` -""" - - # Update the note content - logger.info("Updating note content to include image reference...") - updated_note = nc_client.notes_update_note( - note_id=note_id, - etag=created_note["etag"], - content=updated_content - ) - - # Retrieve the note to verify content - retrieved_note = nc_client.notes_get_note(note_id=note_id) - logger.info("Retrieved note content:") - logger.info(retrieved_note["content"]) - - # Verify the image attachment can be retrieved - content, mime_type = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename - ) - - assert content == image_content, "Attachment content mismatch" - assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" - logger.info("Image attachment verified") - - finally: - # Cleanup - if note_id: - logger.info(f"Cleaning up - deleting note ID: {note_id}") - try: - nc_client.notes_delete_note(note_id=note_id) - logger.info(f"Note {note_id} deleted") - except Exception as e: - logger.info(f"Error during cleanup: {e}") From c1763ebc6acbf407b595f0ec074086914d88896b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 7 May 2025 23:06:22 +0200 Subject: [PATCH 5/8] ADR search and handling categories in notes --- docs/ADR-001-enhanced-note-search.md | 168 ++++++++++++++ nextcloud_mcp_server/client.py | 269 ++++++++++++++++++---- pyproject.toml | 4 +- tests/conftest.py | 5 +- tests/integration/test_attachments.py | 171 +++++++++++++- tests/integration/test_embedded_images.py | 52 ++++- tests/integration/test_webdav_cleanup.py | 161 +++++++++++++ 7 files changed, 776 insertions(+), 54 deletions(-) create mode 100644 docs/ADR-001-enhanced-note-search.md create mode 100644 tests/integration/test_webdav_cleanup.py 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/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index e62233e..303b8e3 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -109,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}) @@ -121,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", @@ -137,26 +149,130 @@ 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) + + # Remove score field before returning (optional) + 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 @@ -172,6 +288,19 @@ class NextcloudClient: 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) @@ -200,21 +329,58 @@ class NextcloudClient: raise e def notes_delete_note(self, *, note_id: int): - # First delete the note through the Notes API - response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") - response.raise_for_status() - json_response = response.json() - - # Now, attempt to delete the associated attachments directory via WebDAV - # Add a trailing slash as suggested - attachment_dir_path = f"Notes/.attachments.{note_id}/" - logger.info(f"Attempting to delete attachment directory for note {note_id} via WebDAV: {attachment_dir_path}") + """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: - self.delete_webdav_resource(path=attachment_dir_path) - logger.info(f"Successfully attempted to delete attachment directory for note {note_id}.") - except Exception as e: - # Log the error but don't re-raise, as note deletion itself was successful - logger.error(f"Failed to delete attachment directory for note {note_id}: {e}") + 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() # 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 @@ -225,13 +391,23 @@ class NextcloudClient: # Use the stored username return f"/remote.php/dav/files/{self.username}" - def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, mime_type: str | None = None): - """Add/Update an attachment to a note via WebDAV PUT.""" - # Attachments are stored in a hidden folder .attachments.{note_id} within the Notes folder + # 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() - attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" - logger.info("Uploading attachment to WebDAV path: %s", attachment_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__) @@ -275,12 +451,12 @@ class NextcloudClient: notes_dir_response.status_code) # Ensure the parent directory exists using MKCOL - parent_dir_path = f"{webdav_base}/Notes/.attachments.{note_id}" - logger.info("Creating attachments directory: %s", parent_dir_path) + # 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 exists) + # 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( @@ -321,11 +497,18 @@ class NextcloudClient: ) raise e - def get_note_attachment(self, *, note_id: int, filename: str): - """Fetch a specific attachment from a note via WebDAV GET.""" + 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() - attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" - logger.info("Fetching attachment from WebDAV path: %s", attachment_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) diff --git a/pyproject.toml b/pyproject.toml index 1f1643b..a59cea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,8 @@ nc-mcp-server = "nextcloud_mcp_server.server:run" [tool.pytest.ini_options] log_cli = 1 -log_cli_level = "INFO" -log_level = "INFO" +log_cli_level = "WARN" +log_level = "WARN" markers = [ "integration: marks tests as slow (deselect with '-m \"not slow\"')" ] diff --git a/tests/conftest.py b/tests/conftest.py index 845e911..12cca15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,17 +76,20 @@ def temporary_note_with_attachment(nc_client: NextcloudClient, temporary_note: d """ 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}") + 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}" diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index 120f07f..6745575 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -20,11 +20,14 @@ def test_attachments_add_and_get(nc_client: NextcloudClient, temporary_note_with """ 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 + filename=attachment_filename, + category=note_category ) logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes") @@ -49,10 +52,12 @@ def test_attachments_add_to_note_with_category(nc_client: NextcloudClient, tempo 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 @@ -62,9 +67,11 @@ def test_attachments_add_to_note_with_category(nc_client: NextcloudClient, tempo # 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 + 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") @@ -80,6 +87,7 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar """ 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. @@ -105,13 +113,168 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar # 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 + filename=attachment_filename, + category=note_category # Pass category, though note fetch should fail first ) - # Expect 404 because the parent directory (.attachments.NOTE_ID) should be gone + # 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 index e6b3386..565543f 100644 --- a/tests/integration/test_embedded_images.py +++ b/tests/integration/test_embedded_images.py @@ -53,17 +53,34 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di attachment_filename = f"test_image_{unique_suffix}.png" # Make filename unique per run # 1. Upload the image as an attachment - logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") + 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']} @@ -94,13 +111,40 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di 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}'...") + 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 + 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.") - # Note cleanup is handled by the temporary_note fixture + # 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_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}") From b0012d6e4a67da002cd15b578446247c3d977b48 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 10 May 2025 12:47:10 +0200 Subject: [PATCH 6/8] wip Move testing to container --- .gitignore | 1 + docker-compose.yml | 10 ++++ nextcloud_mcp_server/client.py | 8 +-- nextcloud_mcp_server/config.py | 2 +- nextcloud_mcp_server/server.py | 12 +++-- poetry.lock | 97 +++++++++++++++++++++++++++++++++- pyproject.toml | 1 + uv.lock | 61 +++++++++++++++++++++ 8 files changed, 183 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index c18dd8d..c1e64c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/ +.coverage diff --git a/docker-compose.yml b/docker-compose.yml index dc9ec6e..74ef32d 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/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index 303b8e3..fc52f4b 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -195,10 +195,10 @@ class NextcloudClient: # 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"] + # Keep score field for debugging + # for result in search_results: + # if "_score" in result: + # del result["_score"] return search_results 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 740fdd2..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: @@ -115,14 +119,16 @@ def nc_notes_get_attachment(note_id: int, attachment_filename: str): 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) + 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 + "mimeType": mime_type, # Client needs to determine this + "data": content, # Return raw bytes/data } ] } diff --git a/poetry.lock b/poetry.lock index 209e9f0..9b497a1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -135,6 +135,82 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +[[package]] +name = "coverage" +version = "7.8.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, + {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, + {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, + {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, + {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, + {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, + {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, + {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, + {file = "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"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, + {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, + {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, + {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, + {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, + {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, + {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, + {file = "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"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, + {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, + {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, + {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, + {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, + {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, + {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, + {file = "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"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, + {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, + {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, + {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, + {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, + {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, + {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, + {file = "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"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, + {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, + {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, + {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, + {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, + {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, + {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, + {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, + {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, + {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "decorator" version = "5.2.1" @@ -848,6 +924,25 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "6.1.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, + {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -1073,4 +1168,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "38328edadd5d23977a5c867229a1445013862796853bc5e8e9de08106ecba60f" +content-hash = "778e697fea873950ab778e50fbfe0860d92ed5dad069cb7e097b55ef3b164338" diff --git a/pyproject.toml b/pyproject.toml index a59cea7..f077092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,3 +33,4 @@ build-backend = "poetry.core.masonry.api" black = "25.1.0" ipython = "9.2.0" pytest = "8.3.5" +pytest-cov = "^6.1.1" diff --git a/uv.lock b/uv.lock index 3af3ac9..c446353 100644 --- a/uv.lock +++ b/uv.lock @@ -164,12 +164,73 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp", extra = ["cli"] }, + { name = "pillow" }, ] [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]] +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]] From b1517317faa49271d25fedcff6ef0c12f66b4b56 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 16 May 2025 00:22:18 +0200 Subject: [PATCH 7/8] Move to uv --- .github/workflows/test.yml | 6 +- poetry.lock | 1171 ------------------------------------ pyproject.toml | 12 +- uv.lock | 388 ++++++++++++ 4 files changed, 398 insertions(+), 1179 deletions(-) delete mode 100644 poetry.lock 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/poetry.lock b/poetry.lock deleted file mode 100644 index 9b497a1..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1171 +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 = "coverage" -version = "7.8.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, - {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, - {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, - {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, - {file = "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"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, - {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, - {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, - {file = "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"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, - {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, - {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, - {file = "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"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, - {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, - {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, - {file = "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"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, - {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, - {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, - {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, - {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, - {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, - {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, - {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[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 = "pillow" -version = "11.2.1" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, - {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, - {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, - {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, - {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, - {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, - {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, - {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, - {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, - {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, - {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, - {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, - {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, - {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, - {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, - {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, - {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, - {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, - {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, - {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, - {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] -xmp = ["defusedxml"] - -[[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 = "pytest-cov" -version = "6.1.1" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, - {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, -] - -[package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[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 = "778e697fea873950ab778e50fbfe0860d92ed5dad069cb7e097b55ef3b164338" diff --git a/pyproject.toml b/pyproject.toml index f077092..ea54334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +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" -pytest-cov = "^6.1.1" +[dependency-groups] +dev = [ + "black>=25.1.0", + "ipython>=9.2.0", + "pytest>=8.3.5", + "pytest-cov>=6.1.1", +] diff --git a/uv.lock b/uv.lock index c446353..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" @@ -167,6 +353,14 @@ dependencies = [ { 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" }, @@ -174,6 +368,53 @@ requires-dist = [ { 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" @@ -233,6 +474,54 @@ wheels = [ { 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]] name = "pydantic" version = "2.11.4" @@ -336,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" @@ -398,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" @@ -410,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" @@ -458,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" }, +] From 81c5016e5c3098f31f13edbf4b37ccda6660989b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 16 May 2025 00:32:38 +0200 Subject: [PATCH 8/8] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ea54334..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"}