From ee32a1bfe8da1417de0c82bd1d848092fdcd1ccc Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 6 Jun 2025 18:41:57 +0200 Subject: [PATCH] feat: Switch to using async client --- .github/workflows/test.yml | 16 +- Dockerfile | 4 +- nextcloud_mcp_server/client.py | 315 +++++++++++++++------- nextcloud_mcp_server/config.py | 2 +- nextcloud_mcp_server/server.py | 40 ++- pyproject.toml | 4 +- tests/conftest.py | 49 ++-- tests/integration/test_attachments.py | 279 +++++++++++++------ tests/integration/test_embedded_images.py | 124 ++++++--- tests/integration/test_notes_api.py | 136 ++++++---- tests/integration/test_webdav_cleanup.py | 212 +++++++++++---- uv.lock | 98 +++---- 12 files changed, 838 insertions(+), 441 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f60df94..9e42ae5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,21 @@ on: - master jobs: - build: + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 + - name: Check format + run: | + uv run --frozen ruff format --diff + - name: Linting + run: | + uv run --frozen ruff check + + + integration-test: runs-on: ubuntu-latest steps: diff --git a/Dockerfile b/Dockerfile index 0fcbe24..cb0674f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,6 @@ WORKDIR /app COPY . . -RUN uv sync --locked +RUN uv sync --locked --no-dev -CMD ["uv", "run", "--locked", "mcp", "run", "--transport", "sse", "nextcloud_mcp_server/server.py:mcp"] +CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"] diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index 916ac2f..df9d6c2 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -1,6 +1,8 @@ import os +import datetime as dt import mimetypes from httpx import ( + AsyncClient, Client, Auth, BasicAuth, @@ -16,7 +18,7 @@ logger = logging.getLogger(__name__) def log_request(request: Request): logger.info( - "Request event hook ****: %s %s - Waiting for content", + "Request event hook: %s %s - Waiting for content", request.method, request.url, ) @@ -30,18 +32,16 @@ def log_response(response: Response): class NextcloudClient: - def __init__(self, base_url: str, username: str, auth: Auth | None = None): - self.username = username # Store username - self._client = Client( + self.username = username # Store username + self._client = AsyncClient( base_url=base_url, auth=auth, - event_hooks={"request": [log_request], "response": [log_response]}, + # event_hooks={"request": [log_request], "response": [log_response]}, ) @classmethod def from_env(cls): - logger.info("Creating NC Client using env vars") host = os.environ["NEXTCLOUD_HOST"] @@ -50,9 +50,8 @@ class NextcloudClient: # Pass username to constructor return cls(base_url=host, username=username, auth=BasicAuth(username, password)) - def capabilities(self): - - response = self._client.get( + async def capabilities(self): + response = await self._client.get( "/ocs/v2.php/cloud/capabilities", headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) @@ -60,22 +59,22 @@ class NextcloudClient: return response.json() - def notes_get_settings(self): - response = self._client.get("/apps/notes/api/v1/settings") + async def notes_get_settings(self): + response = await self._client.get("/apps/notes/api/v1/settings") response.raise_for_status() return response.json() - def notes_get_all(self): - response = self._client.get("/apps/notes/api/v1/notes") + async def notes_get_all(self): + response = await self._client.get("/apps/notes/api/v1/notes") response.raise_for_status() return response.json() - def notes_get_note(self, *, note_id: int): - response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}") + async def notes_get_note(self, *, note_id: int): + response = await self._client.get(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() return response.json() - def notes_create_note( + async def notes_create_note( self, *, title: str | None = None, @@ -90,14 +89,14 @@ class NextcloudClient: if category: body.update({"category": category}) - response = self._client.post( + response = await self._client.post( url="/apps/notes/api/v1/notes", json=body, ) response.raise_for_status() return response.json() - def notes_update_note( + async def notes_update_note( self, *, note_id: int, @@ -110,11 +109,13 @@ class NextcloudClient: 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_note = await 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}") + 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 @@ -134,7 +135,7 @@ class NextcloudClient: body, ) # Ensure conditional PUT using If-Match header is active - response = self._client.put( + response = await self._client.put( url=f"/apps/notes/api/v1/notes/{note_id}", json=body, headers={"If-Match": f'"{etag}"'}, @@ -149,25 +150,39 @@ class NextcloudClient: 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") + 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", "")) + await 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}") + 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_append_content(self, *, note_id: int, content: str): - """Append content to an existing note with a standard separator""" + async def notes_append_content(self, *, note_id: int, content: str): + """Append content to an existing note. + + The content will be separated by a newline, delimiter `---`, and + timestemp so callers do not need to append metadata themselves. + """ logger.info(f"Appending content to note {note_id}") # Get current note - current_note = self.notes_get_note(note_id=note_id) + current_note = await self.notes_get_note(note_id=note_id) # Use fixed separator for consistency - separator = "\n---\n" + separator = f"\n---\n## Content appended: {dt.datetime.now():%Y-%m-%d %H:%M}\n" # Combine content existing_content = current_note.get("content", "") @@ -176,23 +191,25 @@ class NextcloudClient: else: new_content = content # No separator needed for empty notes - logger.info(f"Combining existing content ({len(existing_content)} chars) with new content ({len(content)} chars)") + logger.info( + f"Combining existing content ({len(existing_content)} chars) with new content ({len(content)} chars)" + ) # Update with combined content - return self.notes_update_note( + return await self.notes_update_note( note_id=note_id, etag=current_note["etag"], content=new_content, title=None, # Keep existing title - category=None # Keep existing category + category=None, # Keep existing category ) - def notes_search_notes(self, *, query: str): + async 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() + all_notes = await self.notes_get_all() search_results = [] # Process the query @@ -209,13 +226,15 @@ class NextcloudClient: # Only include notes with a non-zero score if score >= 0.5: - 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) - }) + 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) @@ -252,7 +271,12 @@ class NextcloudClient: return title_tokens, content_tokens - def calculate_score(self, query_tokens: list[str], title_tokens: list[str], content_tokens: list[str]) -> float: + 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. """ @@ -280,33 +304,35 @@ class NextcloudClient: return score - def _cleanup_old_attachment_directory(self, *, note_id: int, old_category: str): + async 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}/" + 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) + delete_result = await 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): + async 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}/" + 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 + path_with_slash = path webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}" logger.info("Deleting WebDAV resource: %s", webdav_path) @@ -316,19 +342,29 @@ class NextcloudClient: # 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}") + propfind_resp = await 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.") + 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) + response = await 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} @@ -344,7 +380,7 @@ class NextcloudClient: raise e else: logger.info("Resource '%s' not found, no deletion needed.", webdav_path) - return {"status_code": 404} # Indicate resource was not found + return {"status_code": 404} # Indicate resource was not found except Exception as e: logger.warning( "Unexpected error deleting WebDAV resource '%s': %s", @@ -353,11 +389,11 @@ class NextcloudClient: ) raise e - def notes_delete_note(self, *, note_id: int): + async def notes_delete_note(self, *, note_id: int): """Deletes a note via API and attempts to delete its attachment directory via WebDAV.""" # Fetch note details first to get the category for path construction try: - note_details = self.notes_get_note(note_id=note_id) + note_details = await 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) @@ -373,39 +409,51 @@ class NextcloudClient: # 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}") + 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.") + 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 + 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 + response = await 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 + 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}") + 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}") + delete_result = await 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.warning(f"Failed during WebDAV deletion for category '{cat}' attachment directory: {e}") + logger.warning( + f"Failed during WebDAV deletion for category '{cat}' attachment directory: {e}" + ) return json_response @@ -418,7 +466,15 @@ class NextcloudClient: # 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): + async 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. @@ -427,20 +483,29 @@ class NextcloudClient: webdav_base = self._get_webdav_base_path() category_path_part = f"{category}/" if category else "" attachment_dir_segment = f".attachments.{note_id}" - parent_dir_webdav_rel_path = f"Notes/{category_path_part}{attachment_dir_segment}" - parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" # Full path for MKCOL - attachment_path = f"{parent_dir_path}/{filename}" # Full path for PUT + 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}") + 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__) + 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 + mime_type = "application/octet-stream" # Default if guessing fails headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"} try: @@ -451,59 +516,92 @@ class NextcloudClient: # 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") + 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).") + logger.info( + "No httpx.Auth object configured on the client for PROPFIND (Notes dir)." + ) propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"} logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers) - notes_dir_response = self._client.request("PROPFIND", notes_dir_path, - headers=propfind_headers) + notes_dir_response = await self._client.request( + "PROPFIND", notes_dir_path, headers=propfind_headers + ) if notes_dir_response.status_code == 401: - logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.") + 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 + response=notes_dir_response, ) elif notes_dir_response.status_code >= 400: - logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code) + 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) + logger.info( + "Successfully accessed WebDAV Notes directory (Status: %s)", + notes_dir_response.status_code, + ) # Ensure the parent directory exists using MKCOL # parent_dir_path is now determined by the helper method logger.info("Ensuring attachments directory exists: %s", parent_dir_path) mkcol_headers = {"OCS-APIRequest": "true"} logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers) - mkcol_response = self._client.request("MKCOL", parent_dir_path, headers=mkcol_headers) + mkcol_response = await self._client.request( + "MKCOL", parent_dir_path, headers=mkcol_headers + ) # MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists) # We can ignore 405, but raise for other errors if mkcol_response.status_code not in [201, 405]: logger.warning( "Unexpected status code %s when creating attachments directory", - mkcol_response.status_code + mkcol_response.status_code, ) mkcol_response.raise_for_status() else: - logger.info("Created/verified directory: %s (Status: %s)", - parent_dir_path, mkcol_response.status_code) + 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 = await 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, ) - 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 + return { + "status_code": response.status_code + } # Return status or relevant info except HTTPStatusError as e: logger.error( @@ -522,7 +620,9 @@ class NextcloudClient: ) raise e - def get_note_attachment(self, *, note_id: int, filename: str, category: str | None = None): + async 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. @@ -533,16 +633,23 @@ class NextcloudClient: 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}") + logger.info( + f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}" + ) try: - response = self._client.get(attachment_path) + response = await 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)) + logger.info( + "Successfully fetched attachment '%s' (%s, %d bytes)", + filename, + mime_type, + len(content), + ) return content, mime_type except HTTPStatusError as e: diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 5f5e978..e434344 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -17,7 +17,7 @@ LOGGING_CONFIG = { "loggers": { "": { "handlers": ["default"], - "level": "DEBUG", + "level": "INFO", }, "httpx": { "handlers": ["default"], diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py index abbc4ae..3102ecf 100644 --- a/nextcloud_mcp_server/server.py +++ b/nextcloud_mcp_server/server.py @@ -4,10 +4,8 @@ from nextcloud_mcp_server.config import setup_logging from contextlib import asynccontextmanager from dataclasses import dataclass 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() @@ -28,7 +26,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: yield AppContext(client=client) finally: # Cleanup on shutdown - client._client.close() + await client._client.aclose() # Create an MCP server @@ -38,38 +36,38 @@ logger = logging.getLogger(__name__) @mcp.resource("nc://capabilities") -def nc_get_capabilities(): +async def nc_get_capabilities(): """Get the Nextcloud Host capabilities""" # client = NextcloudClient.from_env() ctx = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.capabilities() + return await client.capabilities() @mcp.resource("notes://settings") -def notes_get_settings(): +async def notes_get_settings(): """Get the Notes App settings""" ctx = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_get_settings() + return await client.notes_get_settings() @mcp.tool() -def nc_get_note(note_id: int, ctx: Context): +async def nc_get_note(note_id: int, ctx: Context): """Get user note using note id""" client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_get_note(note_id=note_id) + return await client.notes_get_note(note_id=note_id) @mcp.tool() -def nc_notes_create_note(title: str, content: str, category: str, ctx: Context): +async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context): """Create a new note""" client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_create_note( + return await client.notes_create_note( title=title, content=content, category=category, @@ -77,7 +75,7 @@ def nc_notes_create_note(title: str, content: str, category: str, ctx: Context): @mcp.tool() -def nc_notes_update_note( +async def nc_notes_update_note( note_id: int, etag: str, title: str | None, @@ -87,7 +85,7 @@ def nc_notes_update_note( ): logger.info("Updating note %s", note_id) client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_update_note( + return await client.notes_update_note( note_id=note_id, etag=etag, title=title, @@ -97,35 +95,35 @@ def nc_notes_update_note( @mcp.tool() -def nc_notes_append_content(note_id: int, content: str, ctx: Context): +async def nc_notes_append_content(note_id: int, content: str, ctx: Context): """Append content to an existing note with a clear separator""" logger.info("Appending content to note %s", note_id) client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_append_content(note_id=note_id, content=content) + return await client.notes_append_content(note_id=note_id, content=content) @mcp.tool() -def nc_notes_search_notes(query: str, ctx: Context): +async def nc_notes_search_notes(query: str, ctx: Context): """Search notes by title or content, returning only id, title, and category.""" client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_search_notes(query=query) + return await client.notes_search_notes(query=query) @mcp.tool() -def nc_notes_delete_note(note_id: int, ctx: Context): +async def nc_notes_delete_note(note_id: int, ctx: Context): logger.info("Deleting note %s", note_id) client: NextcloudClient = ctx.request_context.lifespan_context.client - return client.notes_delete_note(note_id=note_id) + return await 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): +async 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( + content, mime_type = await client.get_note_attachment( note_id=note_id, filename=attachment_filename ) return { diff --git a/pyproject.toml b/pyproject.toml index 67178b0..944cfef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ nc-mcp-server = "nextcloud_mcp_server.server:run" [tool.pytest.ini_options] +asyncio_mode = "auto" log_cli = 1 log_cli_level = "WARN" log_level = "WARN" @@ -38,9 +39,10 @@ build-backend = "poetry.core.masonry.api" [dependency-groups] dev = [ - "black>=25.1.0", "commitizen>=4.8.2", "ipython>=9.2.0", "pytest>=8.3.5", + "pytest-asyncio>=1.0.0", "pytest-cov>=6.1.1", + "ruff>=0.11.13", ] diff --git a/tests/conftest.py b/tests/conftest.py index 12cca15..dc5bb45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,13 @@ import pytest import os import logging import uuid -import time from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError logger = logging.getLogger(__name__) + @pytest.fixture(scope="session") -def nc_client() -> NextcloudClient: +async def nc_client() -> NextcloudClient: """ Fixture to create a NextcloudClient instance for integration tests. Uses environment variables for configuration. @@ -20,15 +20,18 @@ def nc_client() -> NextcloudClient: 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.") + await 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): +async 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. @@ -42,21 +45,21 @@ def temporary_note(nc_client: NextcloudClient): logger.info(f"Creating temporary note: {note_title}") try: - created_note_data = nc_client.notes_create_note( + created_note_data = await 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 + 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) + await 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 @@ -67,8 +70,9 @@ def temporary_note(nc_client: NextcloudClient): 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): +async 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). @@ -76,27 +80,32 @@ 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 + 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_content = f"Content for {attachment_filename}".encode("utf-8") attachment_mime = "text/plain" - - logger.info(f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')") + + 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( + upload_response = await 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 + 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}" + 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) diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index 6745575..26b1849 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -14,80 +14,105 @@ 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): + +def test_attachments_add_and_get( + nc_client: NextcloudClient, temporary_note_with_attachment: tuple +): """ Tests adding an attachment (via fixture) and retrieving it. """ note_data, attachment_filename, attachment_content = temporary_note_with_attachment note_id = note_data["id"] - note_category = note_data.get("category") # Get category from fixture data + 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}") + logger.info( + f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}" + ) # Pass category to get_note_attachment retrieved_content, retrieved_mime = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename, - category=note_category + note_id=note_id, filename=attachment_filename, category=note_category + ) + logger.info( + 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") assert retrieved_content == attachment_content - assert "text/plain" in retrieved_mime # Fixture uses text/plain + 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): + +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_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.") + 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_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}") + 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 + category=note_category, # Pass the note's category + mime_type=attachment_mime, ) assert upload_response and "status_code" in upload_response assert upload_response["status_code"] in [201, 204] - logger.info(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).") + 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}") + logger.info( + f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}" + ) # Pass category to get_note_attachment retrieved_content, retrieved_mime = nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename, - category=note_category # Pass the note's category + category=note_category, # Pass the note's category + ) + logger.info( + 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") 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.") + 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): + +def test_attachments_cleanup_on_note_delete( + nc_client: NextcloudClient, temporary_note_with_attachment: tuple +): """ Tests that the attachment (and its directory) are deleted when the parent note is deleted. Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture. """ note_data, attachment_filename, _ = temporary_note_with_attachment note_id = note_data["id"] - note_category = note_data.get("category") # Get category from fixture data + 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. @@ -96,7 +121,9 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar # 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).") + 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.") @@ -111,7 +138,9 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar 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}") + 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. @@ -119,31 +148,46 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar nc_client.get_note_attachment( note_id=note_id, filename=attachment_filename, - category=note_category # Pass category, though note fetch should fail first + category=note_category, # Pass category, though note fetch should fail first ) # Expect 404 because the note itself is gone assert excinfo_attach.value.response.status_code == 404 - logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.") + 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") + logger.info("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}" + 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) + 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}!" + 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)") + assert e.response.status_code == 404, ( + f"Expected PROPFIND to fail with 404, got {e.response.status_code}" + ) + logger.info( + "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. @@ -156,7 +200,7 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient): 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') + attachment_content = f"Content for {attachment_filename}".encode("utf-8") try: # 1. Create note with initial category @@ -170,27 +214,43 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient): time.sleep(1) # 2. Add attachment (passing initial category) - logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {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" + 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) + 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}'") + 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 + 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 @@ -198,42 +258,73 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient): 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) + 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") + logger.info("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}" + 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) + 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!" + 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)") + assert e.response.status_code == 404, ( + f"Expected PROPFIND to fail with 404, got {e.response.status_code}" + ) + logger.info( + "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}" + logger.info("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) + 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)") + 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}" + 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}')") + 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.") @@ -246,35 +337,67 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient): # 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) + 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).") - + 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") + 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}" + 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!" + 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, "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") - + 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}" + 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!" + 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, "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.") + 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 565543f..182ba68 100644 --- a/tests/integration/test_embedded_images.py +++ b/tests/integration/test_embedded_images.py @@ -1,12 +1,10 @@ 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 httpx import HTTPStatusError # Import if needed for specific error checks from nextcloud_mcp_server.client import NextcloudClient @@ -18,71 +16,95 @@ 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 +@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)) + 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 - + 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') + 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): +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_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 + 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 + attachment_filename = ( + f"test_image_{unique_suffix}.png" # Make filename unique per run + ) # 1. Upload the image as an attachment - note_category = note_data.get("category") # Get category from fixture data - logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')...") + 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" + 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 + 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") + logger.info("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}" + 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) + 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)") + 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}" + 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']} + updated_content = f"""{note_data["content"]} ## Image Embedding Test @@ -95,10 +117,10 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di 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 + 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 + title=note_data["title"], # Pass required fields + category=note_data["category"], # Pass required fields ) new_etag = updated_note["etag"] assert new_etag != note_etag @@ -111,19 +133,23 @@ 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}' (category: '{note_category or ''}')...") + logger.info( + f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..." + ) # Pass category to get_note_attachment retrieved_img_content, mime_type = nc_client.get_note_attachment( - note_id=note_id, - filename=attachment_filename, - category=note_category + note_id=note_id, filename=attachment_filename, category=note_category ) assert retrieved_img_content == image_content assert mime_type.startswith("image/png") - logger.info("Successfully retrieved and verified image attachment content and mime type.") + logger.info( + "Successfully retrieved and verified image attachment content and mime type." + ) # 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown) - logger.info(f"Manually deleting note ID: {note_id} to verify proper attachment cleanup") + 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) @@ -135,16 +161,26 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di 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") + logger.info("Directly verifying attachment directory doesn't exist via PROPFIND") try: - propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers) + 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}!" + 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)") - + assert e.response.status_code == 404, ( + f"Expected PROPFIND to fail with 404, got {e.response.status_code}" + ) + logger.info( + "Verified attachment directory does not exist via PROPFIND (404 received)" + ) + # Note: The temporary_note fixture will still run its cleanup, # but it will find the note already deleted (404) and handle it gracefully. diff --git a/tests/integration/test_notes_api.py b/tests/integration/test_notes_api.py index 3126434..278dd45 100644 --- a/tests/integration/test_notes_api.py +++ b/tests/integration/test_notes_api.py @@ -1,7 +1,7 @@ import pytest import logging -import time -import uuid # Keep uuid if needed for generating unique data within tests +import asyncio +import uuid # Keep uuid if needed for generating unique data within tests from httpx import HTTPStatusError from nextcloud_mcp_server.client import NextcloudClient @@ -13,15 +13,17 @@ 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): + +@pytest.mark.asyncio +async 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 + 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) + read_note = await nc_client.notes_get_note(note_id=note_id) assert read_note["id"] == note_id assert read_note["title"] == created_note_data["title"] @@ -29,7 +31,9 @@ def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: d 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): + +@pytest.mark.asyncio +async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict): """ Tests updating a note created by the fixture. """ @@ -42,7 +46,7 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict): 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( + updated_note = await nc_client.notes_update_note( note_id=note_id, etag=original_etag, title=update_title, @@ -54,18 +58,22 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict): 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 ( + updated_note["category"] == original_category + ) # Verify category didn't change assert "etag" in updated_note - assert updated_note["etag"] != original_etag # Etag must change + 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) + await asyncio.sleep(1) # Allow potential propagation delay + read_updated_note = await 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): + +@pytest.mark.asyncio +async def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: dict): """ Tests that attempting to update with an old etag fails with 412. """ @@ -76,7 +84,7 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d # 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( + first_updated_note = await nc_client.notes_update_note( note_id=note_id, etag=original_etag, title=first_update_title, @@ -86,32 +94,42 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d 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) + await asyncio.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}") + 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( + await nc_client.notes_update_note( note_id=note_id, - etag=original_etag, # Use the stale etag + 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 + 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): + +@pytest.mark.asyncio +async 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 + 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) + await 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.") + logger.info( + f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404." + ) -def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, temporary_note: dict): + +@pytest.mark.asyncio +async def test_notes_api_append_content_to_existing_note( + nc_client: NextcloudClient, temporary_note: dict +): """ Tests appending content to an existing note using the new append functionality. """ @@ -122,28 +140,27 @@ def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, t append_text = f"Appended content {uuid.uuid4().hex[:8]}" logger.info(f"Appending content to note ID: {note_id}") - updated_note = nc_client.notes_append_content( - note_id=note_id, - content=append_text - ) + updated_note = await nc_client.notes_append_content(note_id=note_id, content=append_text) logger.info(f"Note after append: {updated_note}") # Verify the note was updated assert updated_note["id"] == note_id assert "etag" in updated_note - assert updated_note["etag"] != created_note_data["etag"] # Etag must change + assert updated_note["etag"] != created_note_data["etag"] # Etag must change # Verify content has the separator and appended text expected_content = original_content + "\n---\n" + append_text assert updated_note["content"] == expected_content # Verify by reading the note again - time.sleep(1) # Allow potential propagation delay - read_note = nc_client.notes_get_note(note_id=note_id) + await asyncio.sleep(1) # Allow potential propagation delay + read_note = await nc_client.notes_get_note(note_id=note_id) assert read_note["content"] == expected_content logger.info(f"Successfully appended content to note ID: {note_id}") -def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient): + +@pytest.mark.asyncio +async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient): """ Tests appending content to an empty note (no separator should be added). """ @@ -151,11 +168,11 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient): test_title = f"Empty Note {uuid.uuid4().hex[:8]}" test_category = "Test" - logger.info(f"Creating empty note for append test") - empty_note = nc_client.notes_create_note( + logger.info("Creating empty note for append test") + empty_note = await nc_client.notes_create_note( title=test_title, - content="", # Empty content - category=test_category + content="", + category=test_category, # Empty content ) note_id = empty_note["id"] @@ -163,29 +180,32 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient): append_text = f"First content {uuid.uuid4().hex[:8]}" logger.info(f"Appending content to empty note ID: {note_id}") - updated_note = nc_client.notes_append_content( - note_id=note_id, - content=append_text + updated_note = await nc_client.notes_append_content( + note_id=note_id, content=append_text ) # For empty notes, content should just be the appended text (no separator) assert updated_note["content"] == append_text # Verify by reading the note again - time.sleep(1) - read_note = nc_client.notes_get_note(note_id=note_id) + await asyncio.sleep(1) + read_note = await nc_client.notes_get_note(note_id=note_id) assert read_note["content"] == append_text logger.info(f"Successfully appended content to empty note ID: {note_id}") finally: # Clean up the test note try: - nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes_delete_note(note_id=note_id) logger.info(f"Cleaned up test note ID: {note_id}") except Exception as e: logger.warning(f"Failed to clean up test note ID: {note_id}: {e}") -def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, temporary_note: dict): + +@pytest.mark.asyncio +async def test_notes_api_append_content_multiple_times( + nc_client: NextcloudClient, temporary_note: dict +): """ Tests appending content multiple times to verify separator behavior. """ @@ -199,30 +219,30 @@ def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, tem logger.info(f"Performing multiple appends to note ID: {note_id}") # First append - updated_note = nc_client.notes_append_content( - note_id=note_id, - content=first_append - ) + updated_note = await nc_client.notes_append_content(note_id=note_id, content=first_append) expected_content_after_first = original_content + "\n---\n" + first_append assert updated_note["content"] == expected_content_after_first # Second append - updated_note = nc_client.notes_append_content( - note_id=note_id, - content=second_append + updated_note = await nc_client.notes_append_content( + note_id=note_id, content=second_append ) - expected_content_after_second = expected_content_after_first + "\n---\n" + second_append + expected_content_after_second = ( + expected_content_after_first + "\n---\n" + second_append + ) assert updated_note["content"] == expected_content_after_second # Verify by reading the note again - time.sleep(1) - read_note = nc_client.notes_get_note(note_id=note_id) + await asyncio.sleep(1) + read_note = await nc_client.notes_get_note(note_id=note_id) assert read_note["content"] == expected_content_after_second logger.info(f"Successfully performed multiple appends to note ID: {note_id}") -def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient): + +@pytest.mark.asyncio +async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient): """ Tests that appending to a non-existent note fails with 404. """ @@ -230,11 +250,13 @@ def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient): logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}") with pytest.raises(HTTPStatusError) as excinfo: - nc_client.notes_append_content( - note_id=non_existent_id, - content="This should fail" + await nc_client.notes_append_content( + note_id=non_existent_id, content="This should fail" ) assert excinfo.value.response.status_code == 404 - logger.info(f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404.") + logger.info( + f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404." + ) + # --- Attachment tests moved to test_attachments.py --- diff --git a/tests/integration/test_webdav_cleanup.py b/tests/integration/test_webdav_cleanup.py index 15a91c7..72ee633 100644 --- a/tests/integration/test_webdav_cleanup.py +++ b/tests/integration/test_webdav_cleanup.py @@ -11,7 +11,10 @@ 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): + +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. """ @@ -21,7 +24,7 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou 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') + attachment_content = f"Content for {attachment_filename}".encode("utf-8") try: # 1. Create note with initial category @@ -35,32 +38,48 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou time.sleep(1) # 2. Add attachment (passing initial category) - logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {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" + 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) + 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 + # 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}'") + 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" + 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 @@ -68,94 +87,177 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou 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) + 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") + 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) + 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!" + 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!)") - + 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") + logger.info( + "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}" + 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) + 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 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})") + 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)") + assert e.response.status_code == 404, ( + f"Expected PROPFIND to fail with 404, got {e.response.status_code}" + ) + logger.info( + "Verified old attachment directory does not exist via PROPFIND (404 received)" + ) finally: - # 8. Cleanup: Delete the note + # 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) + 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) + 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") + 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}" + 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) + 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 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})") + 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") - + 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}" + 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) + 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 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})") + 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.") + 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/uv.lock b/uv.lock index 2a524fb..d53b4c0 100644 --- a/uv.lock +++ b/uv.lock @@ -43,34 +43,6 @@ 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" @@ -481,15 +453,6 @@ 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.2.5" @@ -502,11 +465,12 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "black" }, { name = "commitizen" }, { name = "ipython" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -518,11 +482,12 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=25.1.0" }, { name = "commitizen", specifier = ">=4.8.2" }, { name = "ipython", specifier = ">=9.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "ruff", specifier = ">=0.11.13" }, ] [[package]] @@ -543,15 +508,6 @@ 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" @@ -623,15 +579,6 @@ 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" @@ -789,6 +736,18 @@ 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-asyncio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, +] + [[package]] name = "pytest-cov" version = "6.1.1" @@ -880,6 +839,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"