Compare commits

..

76 Commits

Author SHA1 Message Date
github-actions[bot] 33c8623d5c bump: version 0.2.5 → 0.3.0 2025-06-06 17:20:50 +00:00
Chris Coutinho 150e656a36 Merge pull request #43 from cbcoutinho/feature/async
Switch to using async client
2025-06-06 19:20:25 +02:00
Chris Coutinho 2708d708b0 chore: ruff check --fix 2025-06-06 19:11:48 +02:00
Chris Coutinho c1e3a6aeaa chore: ruff format 2025-06-06 19:11:31 +02:00
Chris Coutinho 5ee9435741 test: Update tests with async 2025-06-06 19:10:10 +02:00
Chris Coutinho 110df3d7b9 chore: ruff check --fix 2025-06-06 18:44:09 +02:00
Chris Coutinho fd61c2de56 chore: format 2025-06-06 18:43:32 +02:00
Chris Coutinho ee32a1bfe8 feat: Switch to using async client 2025-06-06 18:41:57 +02:00
Chris Coutinho c918284927 Merge pull request #42 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.11
2025-06-05 00:53:46 +02:00
renovate-bot-cbcoutinho[bot] 98586a3684 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.11 2025-06-04 22:06:06 +00:00
Chris Coutinho 7e02527531 Merge pull request #41 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.10
2025-06-04 23:00:57 +02:00
renovate-bot-cbcoutinho[bot] 60af7ae255 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.10 2025-06-03 22:08:09 +00:00
Chris Coutinho 2437d5fb12 Merge pull request #40 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 1d18f91
2025-06-03 12:30:16 +02:00
renovate-bot-cbcoutinho[bot] 615d27a9c9 chore(deps): update mariadb:lts docker digest to 1d18f91 2025-06-03 10:06:28 +00:00
Chris Coutinho 088f6aec3f Merge pull request #39 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.9
2025-05-31 09:58:32 +02:00
renovate-bot-cbcoutinho[bot] 80c55d5bdc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.9 2025-05-30 22:14:05 +00:00
Chris Coutinho 63ccc9dc6c Merge pull request #38 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 48501c5
2025-05-30 13:41:06 +02:00
renovate-bot-cbcoutinho[bot] ec81f932ee chore(deps): update redis:alpine docker digest to 48501c5 2025-05-30 08:32:17 +00:00
Chris Coutinho 88e6e865f6 Merge pull request #37 from cbcoutinho/renovate/docker-build-push-action-digest
chore(deps): update docker/build-push-action digest to 2634353
2025-05-28 17:01:22 +02:00
renovate-bot-cbcoutinho[bot] e6a5e235ea chore(deps): update docker/build-push-action digest to 2634353 2025-05-27 22:07:43 +00:00
Chris Coutinho 85a5014479 Merge pull request #36 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin astral-sh/setup-uv action to f0ec1fc
2025-05-25 18:10:39 +02:00
renovate-bot-cbcoutinho[bot] 14da0f2451 chore(deps): pin astral-sh/setup-uv action to f0ec1fc 2025-05-25 16:05:38 +00:00
github-actions[bot] dfa0d50497 bump: version 0.2.4 → 0.2.5 2025-05-25 10:55:21 +00:00
Chris Coutinho 266c8bf90d Merge pull request #35 from cbcoutinho/fix/release
Commitizen release process
2025-05-25 12:55:00 +02:00
Chris Coutinho 2b5bb1cc81 fix: Commitizen release process
https://commitizen-tools.github.io/commitizen/tutorials/github_actions/
2025-05-25 12:47:10 +02:00
github-actions[bot] 847a69e2ba bump: version 0.2.3 → 0.2.4 2025-05-25 10:33:45 +00:00
Chris Coutinho 186d2c1d94 Merge pull request #34 from cbcoutinho/feature/logging
fix: Configure logging
2025-05-25 12:33:20 +02:00
Chris Coutinho 96d5789200 fix: Do not update dependencies when running in Dockerfile 2025-05-25 12:26:01 +02:00
Chris Coutinho b332c54330 ci: Setup uv using action, make sure uv runs tests without updating 2025-05-25 12:02:57 +02:00
Chris Coutinho 9a05b171ae ci: Install uv using curl 2025-05-25 11:55:21 +02:00
Chris Coutinho e93eb9d302 fix: Configure logging 2025-05-25 11:46:41 +02:00
Chris Coutinho 5af7c25dab Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2025-05-25 10:59:26 +02:00
Chris Coutinho a0b9482915 build: Only build on tags 2025-05-25 10:59:14 +02:00
github-actions[bot] 85b9a14fc6 bump: version 0.2.2 → 0.2.3 2025-05-25 08:57:33 +00:00
Chris Coutinho e53f4dc2dc Merge pull request #33 from cbcoutinho/feature/search
Limit search results to notes with score > 0.5
2025-05-25 10:57:05 +02:00
Chris Coutinho 8147f237cd fix: Limit search results to notes with score > 0.5
Add hooks to docker-compose rather than in CICD step
2025-05-25 10:48:59 +02:00
Chris Coutinho d4966fc925 Merge pull request #31 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-05-25 08:51:19 +02:00
renovate-bot-cbcoutinho[bot] f173e957f3 chore(deps): pin dependencies 2025-05-25 04:03:51 +00:00
github-actions[bot] 78fd4eb54c bump: version 0.2.1 → 0.2.2 2025-05-24 22:37:16 +00:00
Chris Coutinho 93092a94cc Merge pull request #32 from cbcoutinho/renovate/actions-checkout-4.x
chore(deps): update actions/checkout action to v4
2025-05-25 00:36:44 +02:00
renovate-bot-cbcoutinho[bot] 914aef2861 chore(deps): update actions/checkout action to v4 2025-05-24 22:05:46 +00:00
Chris Coutinho fab0f3ef05 bump: version 0.2.0 → 0.2.1 2025-05-24 22:54:30 +02:00
Chris Coutinho 0e6ff3bdda Merge pull request #30 from cbcoutinho/feature/locked
Feature/locked
2025-05-24 22:52:03 +02:00
Chris Coutinho 37f031d13e fix: Install deps before checking service 2025-05-24 22:51:13 +02:00
Chris Coutinho 02e05dc8d0 ci: comments 2025-05-24 22:41:09 +02:00
Chris Coutinho 21019c6cff ci: use locked in test 2025-05-24 22:36:33 +02:00
Chris Coutinho 050d236312 build: Add v prefix to version 2025-05-24 22:29:23 +02:00
github-actions[bot] 4b57d4e5c9 bump: version 0.1.3 → 0.2.0 2025-05-24 20:27:25 +00:00
Chris Coutinho a0dddbe7df ci: Add permissions to commitizen action 2025-05-24 22:26:52 +02:00
Chris Coutinho d19b1ad680 ci: Pin action version 2025-05-24 22:23:50 +02:00
Chris Coutinho db34473218 ci: update branch in workflow 2025-05-24 22:22:53 +02:00
Chris Coutinho 20ebd7bbcb ci: Add workflow for bumping versions and handling releases 2025-05-24 22:21:50 +02:00
Chris Coutinho d48e151e95 Merge pull request #29 from cbcoutinho/feature/notes-append
Add append to note functionality
2025-05-24 21:31:34 +02:00
Chris Coutinho 892e0b4c01 feat(notes): Add append to note functionality 2025-05-24 21:28:10 +02:00
Chris Coutinho dd7eab05db Merge pull request #28 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin ghcr.io/astral-sh/uv docker tag to e7a2eb4
2025-05-24 16:15:47 +02:00
renovate-bot-cbcoutinho[bot] 23735aad85 chore(deps): pin ghcr.io/astral-sh/uv docker tag to e7a2eb4 2025-05-24 10:05:29 +00:00
Chris Coutinho f6d4695180 Merge pull request #27 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to e7a2eb4
2025-05-24 10:41:21 +02:00
Chris Coutinho 0a138caff4 Update Dockerfile 2025-05-24 10:39:45 +02:00
renovate-bot-cbcoutinho[bot] afb08a7533 chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to e7a2eb4 2025-05-24 04:05:07 +00:00
Chris Coutinho cbed6f2b41 Merge pull request #25 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 621987f
2025-05-23 10:51:54 +02:00
Chris Coutinho 463d90a778 Merge pull request #26 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to c5c82dd
2025-05-23 10:51:03 +02:00
Chris Coutinho 8ee2f684ec Merge pull request #24 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to 3f71577
2025-05-23 10:50:49 +02:00
renovate-bot-cbcoutinho[bot] 6288e50766 chore(deps): update nextcloud:31.0.5 docker digest to 3f71577 2025-05-23 04:06:28 +00:00
renovate-bot-cbcoutinho[bot] 17b539dc21 chore(deps): update mariadb:lts docker digest to c5c82dd 2025-05-23 04:06:24 +00:00
renovate-bot-cbcoutinho[bot] cf20948999 chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 621987f 2025-05-22 22:08:09 +00:00
Chris Coutinho 7a7d627efc Merge pull request #23 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to 2a11ee3
2025-05-22 14:24:59 +02:00
renovate-bot-cbcoutinho[bot] effa1890aa chore(deps): update nextcloud:31.0.5 docker digest to 2a11ee3 2025-05-22 10:07:56 +00:00
Chris Coutinho 8e1f265e3f Merge pull request #22 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to b43c713
2025-05-22 09:36:34 +02:00
renovate-bot-cbcoutinho[bot] 7f39b9e07d chore(deps): update nextcloud:31.0.5 docker digest to b43c713 2025-05-22 04:06:52 +00:00
Chris Coutinho 6ca9efbb8a Merge pull request #21 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 94b38a2
2025-05-21 10:01:15 +02:00
renovate-bot-cbcoutinho[bot] eff0f441cb chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 94b38a2 2025-05-20 04:06:34 +00:00
Chris Coutinho 588cb1cb70 Merge pull request #19 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin nextcloud docker tag to 4294807
2025-05-17 11:45:03 +02:00
Chris Coutinho b85351cb24 Merge pull request #20 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 4d72837
2025-05-17 11:44:50 +02:00
renovate-bot-cbcoutinho[bot] 089bcf92ba chore(deps): update ghcr.io/astral-sh/uv:python3.11-alpine docker digest to 4d72837 2025-05-17 08:39:57 +00:00
renovate-bot-cbcoutinho[bot] cf6d2cfed7 chore(deps): pin nextcloud docker tag to 4294807 2025-05-17 08:39:50 +00:00
Chris Coutinho 5ab01f3459 Merge pull request #18 from cbcoutinho/cbcoutinho-patch-1
Cbcoutinho patch 1
2025-05-17 01:07:51 +02:00
19 changed files with 1355 additions and 482 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Bump version
on:
push:
branches:
- master
jobs:
bump-version:
if: "!startsWith(github.event.head_commit.message, 'bump:')"
runs-on: ubuntu-latest
name: "Bump version and create changelog with commitizen"
permissions:
contents: write
packages: write
steps:
- name: Check out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Create bump and changelog
uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 # 0.24.0
with:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
token: ${{ secrets.GITHUB_TOKEN }}
+1 -4
View File
@@ -2,7 +2,6 @@ name: Build and Publish Docker Image
on:
push:
branches: [ "master" ]
tags: ["*"]
jobs:
@@ -11,7 +10,6 @@ jobs:
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -22,7 +20,6 @@ jobs:
with:
# list of Docker images to use as base name for tags
images: |
#cbcoutinho/nextcloud-mcp-server
ghcr.io/cbcoutinho/nextcloud-mcp-server
# generate Docker tags based on the following events/attributes
tags: |
@@ -47,7 +44,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+18 -14
View File
@@ -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:
@@ -16,6 +30,8 @@ jobs:
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
with:
compose-file: "./docker-compose.yml"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6
- name: Wait for service to be ready
run: |
@@ -33,18 +49,6 @@ jobs:
done
echo "Service is ready (returned 401)."
- name: Install notes app
run: |
docker compose exec app php occ app:enable notes
- name: Install dependencies
run: |
sudo apt update -y && sudo apt install -y pipx
pipx install uv
uv sync
env:
DEBIAN_FRONTEND: "noninteractive"
# Add subsequent steps here, e.g., running tests
- name: Run tests
env:
@@ -52,4 +56,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run python -m pytest
uv run --frozen python -m pytest
+8
View File
@@ -0,0 +1,8 @@
repos:
- hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
repo: https://github.com/commitizen-tools/commitizen
rev: v4.8.2
+56
View File
@@ -0,0 +1,56 @@
## v0.3.0 (2025-06-06)
### Feat
- Switch to using async client
## v0.2.5 (2025-05-25)
### Fix
- Commitizen release process
## v0.2.4 (2025-05-25)
### Fix
- Do not update dependencies when running in Dockerfile
- Configure logging
## v0.2.3 (2025-05-25)
### Fix
- Limit search results to notes with score > 0.5
## v0.2.2 (2025-05-24)
### Fix
- Install deps before checking service
## v0.2.1 (2025-05-24)
### Fix
- Install deps before checking service
## v0.2.1 (2025-05-24)
## v0.2.0 (2025-05-24)
### Feat
- **notes**: Add append to note functionality
### Fix
- **deps**: update dependency mcp to >=1.9,<1.10
## v0.1.3 (2025-05-16)
## v0.1.2 (2025-05-05)
## v0.1.1 (2025-05-05)
## v0.1.0 (2025-05-05)
+3 -7
View File
@@ -1,13 +1,9 @@
FROM ghcr.io/astral-sh/uv:python3.11-alpine@sha256:2d9058ac1ecdd9b1baacae5362c8f40aa20137c6a1596e24eb956ff7469a9537
FROM ghcr.io/astral-sh/uv:0.7.11-python3.11-alpine@sha256:66d4d13288afecfeb2173b267a6c0765957d2122935c447d6963ea7b38929a99
WORKDIR /app
COPY . .
RUN uv sync --locked
RUN uv sync --locked --no-dev
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV FASTMCP_LOG_LEVEL=DEBUG
CMD ["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"]
+1
View File
@@ -12,6 +12,7 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
* `nc_notes_create_note`: Create a new note.
* `nc_notes_update_note`: Update an existing note by ID.
* `nc_notes_append_content`: Append content to an existing note with a clear separator.
* `nc_notes_delete_note`: Delete a note by ID.
* `nc_notes_search_notes`: Search notes by title or content.
* `nc_get_note`: Get a specific note by ID.
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
php /var/www/html/occ app:enable notes
+4 -3
View File
@@ -3,7 +3,7 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: mariadb:lts@sha256:49117dcc565cf51aa57ac5fca59ab31213402ff0eae6ffc13c46a37b938f7e4b
image: mariadb:lts@sha256:1d18f91deb21136d1881705720071d1b474a9904ecca827058bf1c0fc64d3118
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -17,11 +17,11 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: redis:alpine@sha256:62b5498c91778f738f0efbf0a6fd5b434011235a3e7b5f2ed4a2c0c63bb1c786
image: redis:alpine@sha256:48501c5ad00d5563bc30c075c7bcef41d7d98de3e9a1e6c752068c66f0a8463b
restart: always
app:
image: nextcloud:31.0.5
image: nextcloud:31.0.5@sha256:3f71577339ef1db0d1900c8574853d11fa7100452bf24f0a06fae5d9ee019cb4
#user: www-data:www-data
restart: always
#post_start:
@@ -34,6 +34,7 @@ services:
- db
volumes:
- nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
+251 -115
View File
@@ -1,16 +1,13 @@
import os
import time # Import time for sleep
import mimetypes
from io import BytesIO
from httpx import (
Client,
AsyncClient,
Auth,
BasicAuth,
Headers,
Request,
Response,
HTTPStatusError,
) # Import HTTPStatusError
)
import logging
@@ -19,7 +16,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,
)
@@ -33,18 +30,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"]
@@ -53,9 +48,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"},
)
@@ -63,22 +57,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,
@@ -93,14 +87,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,
@@ -113,11 +107,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
@@ -137,7 +133,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}"'},
@@ -152,22 +148,66 @@ 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_search_notes(self, *, query: str):
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 and a delimiter `---`, so
one will not be required in the content provided to this tool
"""
logger.info(f"Appending content to note {note_id}")
# Get current note
current_note = await self.notes_get_note(note_id=note_id)
# Use fixed separator for consistency
separator = "\n---\n"
# Combine content
existing_content = current_note.get("content", "")
if existing_content:
new_content = existing_content + separator + content
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)"
)
# Update with combined content
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
)
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
@@ -183,14 +223,16 @@ class NextcloudClient:
score = self.calculate_score(query_tokens, title_tokens, content_tokens)
# Only include notes with a non-zero score
if score > 0:
search_results.append({
"id": note.get("id"),
"title": note.get("title"),
"category": note.get("category"),
"modified": note.get("modified"),
"_score": score # Include score for sorting (optional field)
})
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)
}
)
# Sort by score in descending order
search_results.sort(key=lambda x: x["_score"], reverse=True)
@@ -227,7 +269,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.
"""
@@ -255,33 +302,39 @@ 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)
@@ -291,24 +344,34 @@ 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}
except HTTPStatusError as e:
logger.error(
logger.warning(
"HTTP error deleting WebDAV resource '%s': %s",
webdav_path,
e,
@@ -319,68 +382,82 @@ 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.error(
logger.warning(
"Unexpected error deleting WebDAV resource '%s': %s",
webdav_path,
e,
)
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)
# We can't reliably detect this without a dedicated tracking mechanism, but we can
# implement a basic check for common category names and empty category
potential_categories = []
if category:
potential_categories.append(category) # Current category first
# Add empty category (uncategorized notes)
if category != "":
potential_categories.append("")
# We could add logic here to check for other common categories if needed
logger.info(f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}")
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.error(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
@@ -393,7 +470,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.
@@ -402,20 +487,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:
@@ -423,62 +517,95 @@ class NextcloudClient:
# by checking the Notes directory
notes_dir_path = f"{webdav_base}/Notes"
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
# Log details of the auth being used by the client for this specific request
if self._client.auth:
auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization")
logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow")
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(
@@ -497,7 +624,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.
@@ -508,16 +637,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:
+2 -5
View File
@@ -4,11 +4,8 @@ LOGGING_CONFIG = {
"version": 1,
"handlers": {
"default": {
"class": "logging.FileHandler",
"class": "logging.StreamHandler",
"formatter": "http",
# "stream": "ext://sys.stderr"
"filename": "/tmp/nextcloud-mcp-server.log",
"mode": "a",
}
},
"formatters": {
@@ -20,7 +17,7 @@ LOGGING_CONFIG = {
"loggers": {
"": {
"handlers": ["default"],
"level": "DEBUG",
"level": "INFO",
},
"httpx": {
"handlers": ["default"],
+30 -26
View File
@@ -4,15 +4,11 @@ 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()
logger = logging.getLogger(__name__)
@dataclass
class AppContext:
@@ -23,55 +19,55 @@ class AppContext:
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
logger.info("Creating Nextcloud client")
logging.info("Creating Nextcloud client")
client = NextcloudClient.from_env()
# Add a small delay to allow client initialization to complete
logger.info("Waiting 2 seconds for client initialization...")
logger.info("Client initialization wait complete.")
logging.info("Client initialization wait complete.")
try:
yield AppContext(client=client)
finally:
# Cleanup on shutdown
client._client.close()
await client._client.aclose()
# Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
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,
@@ -79,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,
@@ -89,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,
@@ -99,27 +95,35 @@ def nc_notes_update_note(
@mcp.tool()
def nc_notes_search_notes(query: str, ctx: Context):
"""Search notes by title or content, returning only id, title, and category."""
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_search_notes(query=query)
return await client.notes_append_content(note_id=note_id, content=content)
@mcp.tool()
def nc_notes_delete_note(note_id: int, 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 await client.notes_search_notes(query=query)
@mcp.tool()
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 {
+14 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.1.3"
version = "0.3.0"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -17,6 +17,9 @@ dependencies = [
nc-mcp-server = "nextcloud_mcp_server.server:run"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
log_cli = 1
log_cli_level = "WARN"
log_level = "WARN"
@@ -24,6 +27,13 @@ markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
]
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "pep440"
version_provider = "uv"
update_changelog_on_bump = true
major_version_zero = true
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
@@ -31,8 +41,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",
]
+39 -20
View File
@@ -2,17 +2,21 @@ import pytest
import os
import logging
import uuid
import time
from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
import asyncio
logger = logging.getLogger(__name__)
# pytestmark = pytest.mark.asyncio(loop_scope="package")
@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.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
@@ -20,19 +24,24 @@ 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.
"""
asyncio.new_event_loop()
note_id = None
unique_suffix = uuid.uuid4().hex[:8]
note_title = f"Temporary Test Note {unique_suffix}"
@@ -42,21 +51,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,36 +76,46 @@ 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).
Depends on the temporary_note fixture.
"""
asyncio.new_event_loop()
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)
+215 -92
View File
@@ -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):
async 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}")
# Pass category to get_note_attachment
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename,
category=note_category
logger.info(
f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
)
# Pass category to get_note_attachment
retrieved_content, retrieved_mime = await nc_client.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=note_category
)
logger.info(
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
)
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):
async 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(
upload_response = await 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(
retrieved_content, retrieved_mime = await 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):
async 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,55 +121,74 @@ 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.")
nc_client.notes_delete_note(note_id=note_id)
await nc_client.notes_delete_note(note_id=note_id)
logger.info(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
# Verify Note Is Deleted
with pytest.raises(HTTPStatusError) as excinfo_note:
nc_client.notes_get_note(note_id=note_id)
await nc_client.notes_get_note(note_id=note_id)
assert excinfo_note.value.response.status_code == 404
logger.info(f"Verified note {note_id} deletion (404 received).")
# Verify Attachment Is Deleted (via 404 on GET)
logger.info(f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}")
logger.info(
f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}"
)
with pytest.raises(HTTPStatusError) as excinfo_attach:
# Pass category to get_note_attachment - although it should fail anyway
# because the note (and thus details) are gone.
# The client method will raise 404 from the initial notes_get_note call.
nc_client.get_note_attachment(
await 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 = await 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):
async def test_attachments_category_change_handling(nc_client: NextcloudClient):
"""
Tests attachment handling when a note's category is changed.
Verifies attachment retrieval works before and after category change,
@@ -156,12 +200,12 @@ 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
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
created_note = nc_client.notes_create_note(
created_note = await nc_client.notes_create_note(
title=note_title, content="Initial content", category=initial_category
)
note_id = created_note["id"]
@@ -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})")
upload_response = nc_client.add_note_attachment(
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
logger.info(
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
)
upload_response = await nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
category=initial_category,
mime_type="text/plain",
)
assert upload_response["status_code"] in [201, 204]
logger.info("Attachment added successfully.")
time.sleep(1)
# 3. Verify attachment retrieval from initial category (passing initial category)
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
logger.info(
f"Verifying attachment retrieval from initial category '{initial_category}'"
)
retrieved_content1, _ = await 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_note_data = await 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
updated_note = await nc_client.notes_update_note(
note_id=note_id,
etag=current_etag,
category=new_category,
title=note_title,
content="Updated content", # Pass required fields
)
etag3 = updated_note["etag"]
assert updated_note["category"] == new_category
@@ -198,83 +258,146 @@ 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, _ = await 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 = await 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 = await 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)
await nc_client.notes_delete_note(note_id=note_id)
logger.info(f"Note {note_id} deleted.")
time.sleep(1)
# Verify note deletion
with pytest.raises(HTTPStatusError) as excinfo_note_del:
nc_client.notes_get_note(note_id=note_id)
await nc_client.notes_get_note(note_id=note_id)
assert excinfo_note_del.value.response.status_code == 404
logger.info("Verified note deleted (404).")
# Verify attachment deletion (should fail with 404 on the initial note fetch)
with pytest.raises(HTTPStatusError) as excinfo_attach_del:
# Pass the *last known* category, although the note fetch should fail first
nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
await 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 = await 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 = await 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}")
+86 -50
View File
@@ -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):
async 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 ''}')...")
upload_response = nc_client.add_note_attachment(
note_category = note_data.get("category") # Get category from fixture data
logger.info(
f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..."
)
upload_response = await 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 = await 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
@@ -93,12 +115,12 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="150" />
"""
logger.info("Updating note content with image references...")
updated_note = nc_client.notes_update_note(
updated_note = await 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
@@ -106,45 +128,59 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di
time.sleep(1)
# 3. Verify the updated note content
retrieved_note = nc_client.notes_get_note(note_id=note_id)
retrieved_note = await nc_client.notes_get_note(note_id=note_id)
assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
logger.info("Verified image reference exists in updated note content.")
# 4. Verify the image attachment can be retrieved
logger.info(f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')...")
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
retrieved_img_content, mime_type = await nc_client.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=note_category
)
assert retrieved_img_content == image_content
assert mime_type.startswith("image/png")
logger.info("Successfully retrieved and verified image attachment content and mime type.")
logger.info(
"Successfully retrieved and verified image attachment content and mime type."
)
# 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
logger.info(f"Manually deleting note ID: {note_id} to verify proper attachment cleanup")
nc_client.notes_delete_note(note_id=note_id)
logger.info(
f"Manually deleting note ID: {note_id} to verify proper attachment cleanup"
)
await nc_client.notes_delete_note(note_id=note_id)
logger.info(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
# 6. Verify note is deleted
with pytest.raises(HTTPStatusError) as excinfo_note:
nc_client.notes_get_note(note_id=note_id)
await nc_client.notes_get_note(note_id=note_id)
assert excinfo_note.value.response.status_code == 404
logger.info(f"Verified note {note_id} deletion (404 received).")
# 7. Verify attachment directory is deleted via WebDAV PROPFIND
logger.info(f"Directly verifying attachment directory doesn't exist via PROPFIND")
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 = await 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.
+174 -26
View File
@@ -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,23 +13,27 @@ 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):
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"]
assert read_note["content"] == created_note_data["content"]
assert read_note["category"] == created_note_data["category"]
logger.info(f"Successfully read and verified note ID: {note_id}")
def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
"""
Tests updating a note created by the fixture.
"""
@@ -40,9 +44,9 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
updated_note = nc_client.notes_update_note(
updated_note = await nc_client.notes_update_note(
note_id=note_id,
etag=original_etag,
title=update_title,
@@ -50,22 +54,27 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
# category=original_category # Explicitly pass category if required by update
)
logger.info(f"Note updated: {updated_note}")
assert updated_note["id"] == note_id
assert updated_note["title"] == update_title
assert updated_note["content"] == update_content
assert updated_note["category"] == original_category # Verify category didn't change
assert (
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):
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 +85,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,29 +95,168 @@ 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):
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."
)
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.
"""
created_note_data = temporary_note
note_id = created_note_data["id"]
original_content = created_note_data["content"]
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
logger.info(f"Appending content to note ID: {note_id}")
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
# 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
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}")
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).
"""
# Create an empty note
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
test_category = "Test"
logger.info("Creating empty note for append test")
empty_note = await nc_client.notes_create_note(
title=test_title,
content="",
category=test_category, # Empty content
)
note_id = empty_note["id"]
try:
append_text = f"First content {uuid.uuid4().hex[:8]}"
logger.info(f"Appending content to empty note ID: {note_id}")
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
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:
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}")
async def test_notes_api_append_content_multiple_times(
nc_client: NextcloudClient, temporary_note: dict
):
"""
Tests appending content multiple times to verify separator behavior.
"""
created_note_data = temporary_note
note_id = created_note_data["id"]
original_content = created_note_data["content"]
first_append = f"First append {uuid.uuid4().hex[:8]}"
second_append = f"Second append {uuid.uuid4().hex[:8]}"
logger.info(f"Performing multiple appends to note ID: {note_id}")
# 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 = 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
)
assert updated_note["content"] == expected_content_after_second
# Verify by reading the note again
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}")
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
"""
Tests that appending to a non-existent note fails with 404.
"""
non_existent_id = 999999999
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
with pytest.raises(HTTPStatusError) as excinfo:
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."
)
# --- Attachment tests moved to test_attachments.py ---
+162 -60
View File
@@ -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):
async 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,12 +24,12 @@ 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
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
created_note = nc_client.notes_create_note(
created_note = await nc_client.notes_create_note(
title=note_title, content="Initial content", category=initial_category
)
note_id = created_note["id"]
@@ -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})")
upload_response = nc_client.add_note_attachment(
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
logger.info(
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
)
upload_response = await nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
category=initial_category,
mime_type="text/plain",
)
assert upload_response["status_code"] in [201, 204]
logger.info("Attachment added successfully.")
time.sleep(1)
# 3. Verify attachment retrieval from initial category
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
logger.info(
f"Verifying attachment retrieval from initial category '{initial_category}'"
)
retrieved_content1, _ = await 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}'")
current_note_data = nc_client.notes_get_note(note_id=note_id)
logger.info(
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
)
current_note_data = await 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"
updated_note = await nc_client.notes_update_note(
note_id=note_id,
etag=current_etag,
category=new_category,
title=note_title,
content="Updated content",
)
etag3 = updated_note["etag"]
assert updated_note["category"] == new_category
@@ -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, _ = await 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)
await 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 = await 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)
await 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)
await 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)
await 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 = await 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 = await 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}")
Generated
+256 -58
View File
@@ -25,6 +25,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "argcomplete"
version = "3.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" },
]
[[package]]
name = "asttokens"
version = "3.0.0"
@@ -34,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"
@@ -71,6 +52,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
{ url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
{ url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
{ url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
{ url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "click"
version = "8.1.8"
@@ -92,6 +121,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "commitizen"
version = "4.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
{ name = "charset-normalizer" },
{ name = "colorama" },
{ name = "decli" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "questionary" },
{ name = "termcolor" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/15/c2fe85c0224886109b5061419acea2e20539be1b4bff619a16d7295fe0f2/commitizen-4.8.2.tar.gz", hash = "sha256:4fc73126c7300f715f11b85242550677722c57767b579100e869ccd45143e2c5", size = 53235, upload-time = "2025-05-22T03:16:39.915Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/40/2b81df1b3ec24c41004512feba0884895b84748775d21642690120539a30/commitizen-4.8.2-py3-none-any.whl", hash = "sha256:86cae0bd8e1da889389d828b30a5acb79b62f9290f9274b127ee9d8c189eb16c", size = 76074, upload-time = "2025-05-22T03:16:38.431Z" },
]
[[package]]
name = "coverage"
version = "7.8.0"
@@ -147,6 +197,15 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "decli"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/a0/a4658f93ecb589f479037b164dc13c68d108b50bf6594e54c820749f97ac/decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f", size = 7424, upload-time = "2024-04-28T17:41:05.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/70/3ea48dc9e958d7d66c44c9944809181f1ca79aaef25703c023b5092d34ff/decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed", size = 7854, upload-time = "2024-04-28T17:41:04.663Z" },
]
[[package]]
name = "decorator"
version = "5.2.1"
@@ -275,6 +334,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -287,6 +358,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" },
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" },
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" },
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" },
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" },
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" },
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
@@ -334,18 +453,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nextcloud-mcp-server"
version = "0.1.3"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -355,10 +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]
@@ -370,10 +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]]
@@ -394,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"
@@ -474,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"
@@ -640,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"
@@ -671,6 +779,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "questionary"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/b8/d16eb579277f3de9e56e5ad25280fab52fc5774117fb70362e8c2e016559/questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587", size = 26775, upload-time = "2024-12-29T11:49:17.802Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747, upload-time = "2024-12-29T11:49:16.734Z" },
]
[[package]]
name = "rich"
version = "14.0.0"
@@ -684,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"
@@ -741,6 +921,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
]
[[package]]
name = "termcolor"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057, upload-time = "2024-10-06T19:50:04.115Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755, upload-time = "2024-10-06T19:50:02.097Z" },
]
[[package]]
name = "tomli"
version = "2.2.1"
@@ -780,6 +969,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "tomlkit"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885, upload-time = "2024-08-14T08:19:41.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955, upload-time = "2024-08-14T08:19:40.05Z" },
]
[[package]]
name = "traitlets"
version = "5.14.3"