Compare commits

...

74 Commits

Author SHA1 Message Date
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 1349 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: on:
push: push:
branches: [ "master" ]
tags: ["*"] tags: ["*"]
jobs: jobs:
@@ -11,7 +10,6 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -22,7 +20,6 @@ jobs:
with: with:
# list of Docker images to use as base name for tags # list of Docker images to use as base name for tags
images: | images: |
#cbcoutinho/nextcloud-mcp-server
ghcr.io/cbcoutinho/nextcloud-mcp-server ghcr.io/cbcoutinho/nextcloud-mcp-server
# generate Docker tags based on the following events/attributes # generate Docker tags based on the following events/attributes
tags: | tags: |
@@ -47,7 +44,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+18 -14
View File
@@ -6,7 +6,21 @@ on:
- master - master
jobs: 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 runs-on: ubuntu-latest
steps: steps:
@@ -16,6 +30,8 @@ jobs:
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0 uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
with: with:
compose-file: "./docker-compose.yml" 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 - name: Wait for service to be ready
run: | run: |
@@ -33,18 +49,6 @@ jobs:
done done
echo "Service is ready (returned 401)." 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 # Add subsequent steps here, e.g., running tests
- name: Run tests - name: Run tests
env: env:
@@ -52,4 +56,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin" NEXTCLOUD_PASSWORD: "admin"
run: | 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
+50
View File
@@ -0,0 +1,50 @@
## 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 WORKDIR /app
COPY . . COPY . .
RUN uv sync --locked RUN uv sync --locked --no-dev
ENV VIRTUAL_ENV=/app/.venv CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV FASTMCP_LOG_LEVEL=DEBUG
CMD ["mcp", "run", "--transport", "sse", "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_create_note`: Create a new note.
* `nc_notes_update_note`: Update an existing note by ID. * `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_delete_note`: Delete a note by ID.
* `nc_notes_search_notes`: Search notes by title or content. * `nc_notes_search_notes`: Search notes by title or content.
* `nc_get_note`: Get a specific note by ID. * `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 # https://hub.docker.com/_/mariadb
db: db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server # 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 restart: always
command: --transaction-isolation=READ-COMMITTED command: --transaction-isolation=READ-COMMITTED
volumes: volumes:
@@ -17,11 +17,11 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here: # Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis # https://hub.docker.com/_/redis
redis: redis:
image: redis:alpine@sha256:62b5498c91778f738f0efbf0a6fd5b434011235a3e7b5f2ed4a2c0c63bb1c786 image: redis:alpine@sha256:48501c5ad00d5563bc30c075c7bcef41d7d98de3e9a1e6c752068c66f0a8463b
restart: always restart: always
app: app:
image: nextcloud:31.0.5 image: nextcloud:31.0.5@sha256:3f71577339ef1db0d1900c8574853d11fa7100452bf24f0a06fae5d9ee019cb4
#user: www-data:www-data #user: www-data:www-data
restart: always restart: always
#post_start: #post_start:
@@ -34,6 +34,7 @@ services:
- db - db
volumes: volumes:
- nextcloud:/var/www/html - nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
environment: environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app - NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin - NEXTCLOUD_ADMIN_USER=admin
+251 -115
View File
@@ -1,16 +1,13 @@
import os import os
import time # Import time for sleep
import mimetypes import mimetypes
from io import BytesIO
from httpx import ( from httpx import (
Client, AsyncClient,
Auth, Auth,
BasicAuth, BasicAuth,
Headers,
Request, Request,
Response, Response,
HTTPStatusError, HTTPStatusError,
) # Import HTTPStatusError )
import logging import logging
@@ -19,7 +16,7 @@ logger = logging.getLogger(__name__)
def log_request(request: Request): def log_request(request: Request):
logger.info( logger.info(
"Request event hook ****: %s %s - Waiting for content", "Request event hook: %s %s - Waiting for content",
request.method, request.method,
request.url, request.url,
) )
@@ -33,18 +30,16 @@ def log_response(response: Response):
class NextcloudClient: class NextcloudClient:
def __init__(self, base_url: str, username: str, auth: Auth | None = None): def __init__(self, base_url: str, username: str, auth: Auth | None = None):
self.username = username # Store username self.username = username # Store username
self._client = Client( self._client = AsyncClient(
base_url=base_url, base_url=base_url,
auth=auth, auth=auth,
event_hooks={"request": [log_request], "response": [log_response]}, # event_hooks={"request": [log_request], "response": [log_response]},
) )
@classmethod @classmethod
def from_env(cls): def from_env(cls):
logger.info("Creating NC Client using env vars") logger.info("Creating NC Client using env vars")
host = os.environ["NEXTCLOUD_HOST"] host = os.environ["NEXTCLOUD_HOST"]
@@ -53,9 +48,8 @@ class NextcloudClient:
# Pass username to constructor # Pass username to constructor
return cls(base_url=host, username=username, auth=BasicAuth(username, password)) return cls(base_url=host, username=username, auth=BasicAuth(username, password))
def capabilities(self): async def capabilities(self):
response = await self._client.get(
response = self._client.get(
"/ocs/v2.php/cloud/capabilities", "/ocs/v2.php/cloud/capabilities",
headers={"OCS-APIRequest": "true", "Accept": "application/json"}, headers={"OCS-APIRequest": "true", "Accept": "application/json"},
) )
@@ -63,22 +57,22 @@ class NextcloudClient:
return response.json() return response.json()
def notes_get_settings(self): async def notes_get_settings(self):
response = self._client.get("/apps/notes/api/v1/settings") response = await self._client.get("/apps/notes/api/v1/settings")
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def notes_get_all(self): async def notes_get_all(self):
response = self._client.get("/apps/notes/api/v1/notes") response = await self._client.get("/apps/notes/api/v1/notes")
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def notes_get_note(self, *, note_id: int): async def notes_get_note(self, *, note_id: int):
response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}") response = await self._client.get(f"/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def notes_create_note( async def notes_create_note(
self, self,
*, *,
title: str | None = None, title: str | None = None,
@@ -93,14 +87,14 @@ class NextcloudClient:
if category: if category:
body.update({"category": category}) body.update({"category": category})
response = self._client.post( response = await self._client.post(
url="/apps/notes/api/v1/notes", url="/apps/notes/api/v1/notes",
json=body, json=body,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def notes_update_note( async def notes_update_note(
self, self,
*, *,
note_id: int, note_id: int,
@@ -113,11 +107,13 @@ class NextcloudClient:
old_note = None old_note = None
try: try:
if category is not None: # Only fetch if category might change 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", "") old_category = old_note.get("category", "")
logger.info(f"Current category for note {note_id}: '{old_category}'") logger.info(f"Current category for note {note_id}: '{old_category}'")
except Exception as e: 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 # Continue with update even if we couldn't fetch current details
old_note = None old_note = None
@@ -137,7 +133,7 @@ class NextcloudClient:
body, body,
) )
# Ensure conditional PUT using If-Match header is active # 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}", url=f"/apps/notes/api/v1/notes/{note_id}",
json=body, json=body,
headers={"If-Match": f'"{etag}"'}, headers={"If-Match": f'"{etag}"'},
@@ -152,22 +148,66 @@ class NextcloudClient:
updated_note = response.json() updated_note = response.json()
# Check for category change and clean up old attachment directory if needed # 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: if (
logger.info(f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory") 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: 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: 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 # Continue with update even if cleanup failed
return updated_note 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. Search notes using token-based matching with relevance ranking.
Returns notes sorted by relevance score. Returns notes sorted by relevance score.
""" """
all_notes = self.notes_get_all() all_notes = await self.notes_get_all()
search_results = [] search_results = []
# Process the query # Process the query
@@ -183,14 +223,16 @@ class NextcloudClient:
score = self.calculate_score(query_tokens, title_tokens, content_tokens) score = self.calculate_score(query_tokens, title_tokens, content_tokens)
# Only include notes with a non-zero score # Only include notes with a non-zero score
if score > 0: if score >= 0.5:
search_results.append({ search_results.append(
"id": note.get("id"), {
"title": note.get("title"), "id": note.get("id"),
"category": note.get("category"), "title": note.get("title"),
"modified": note.get("modified"), "category": note.get("category"),
"_score": score # Include score for sorting (optional field) "modified": note.get("modified"),
}) "_score": score, # Include score for sorting (optional field)
}
)
# Sort by score in descending order # Sort by score in descending order
search_results.sort(key=lambda x: x["_score"], reverse=True) search_results.sort(key=lambda x: x["_score"], reverse=True)
@@ -227,7 +269,12 @@ class NextcloudClient:
return title_tokens, content_tokens 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. Calculate a relevance score for a note based on query tokens.
""" """
@@ -255,33 +302,39 @@ class NextcloudClient:
return score 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. Clean up the attachment directory for a note in its old category location.
Called after a category change to prevent orphaned directories. Called after a category change to prevent orphaned directories.
""" """
# Construct path to old attachment directory # Construct path to old attachment directory
old_category_path_part = f"{old_category}/" if old_category else "" 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}") logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
try: 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}") logger.info(f"Cleanup of old attachment directory result: {delete_result}")
return delete_result return delete_result
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup of old attachment directory: {e}") logger.error(f"Error during cleanup of old attachment directory: {e}")
raise 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.""" """Delete a resource (file or directory) via WebDAV DELETE."""
# Ensure path ends with a slash if it's a directory # Ensure path ends with a slash if it's a directory
if not path.endswith('/'): if not path.endswith("/"):
# This is a heuristic; a more robust solution would check resource type first # 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. # but for the specific case of deleting the attachment directory, this is acceptable.
path_with_slash = f"{path}/" path_with_slash = f"{path}/"
else: else:
path_with_slash = path path_with_slash = path
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}" webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
logger.info("Deleting WebDAV resource: %s", webdav_path) logger.info("Deleting WebDAV resource: %s", webdav_path)
@@ -291,24 +344,34 @@ class NextcloudClient:
# First try a PROPFIND to verify resource exists # First try a PROPFIND to verify resource exists
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: try:
propfind_resp = self._client.request("PROPFIND", webdav_path, headers=propfind_headers) propfind_resp = await self._client.request(
logger.info(f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}") "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 # If we get here with 2xx, the resource exists
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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} return {"status_code": 404}
# For other errors, continue with deletion attempt # For other errors, continue with deletion attempt
# Proceed with deletion # Proceed with deletion
response = self._client.delete(webdav_path, headers=headers) response = await self._client.delete(webdav_path, headers=headers)
response.raise_for_status() # Raises for 4xx/5xx status codes response.raise_for_status() # Raises for 4xx/5xx status codes
logger.info("Successfully deleted WebDAV resource '%s' (Status: %s)", webdav_path, response.status_code) logger.info(
"Successfully deleted WebDAV resource '%s' (Status: %s)",
webdav_path,
response.status_code,
)
# DELETE typically returns 204 No Content on success # DELETE typically returns 204 No Content on success
return {"status_code": response.status_code} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
logger.error( logger.warning(
"HTTP error deleting WebDAV resource '%s': %s", "HTTP error deleting WebDAV resource '%s': %s",
webdav_path, webdav_path,
e, e,
@@ -319,68 +382,82 @@ class NextcloudClient:
raise e raise e
else: else:
logger.info("Resource '%s' not found, no deletion needed.", webdav_path) 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: except Exception as e:
logger.error( logger.warning(
"Unexpected error deleting WebDAV resource '%s': %s", "Unexpected error deleting WebDAV resource '%s': %s",
webdav_path, webdav_path,
e, e,
) )
raise 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.""" """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 # Fetch note details first to get the category for path construction
try: 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", "") category = note_details.get("category", "")
# Check for other potential categories (if any note was moved between categories) # 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 # 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 # implement a basic check for common category names and empty category
potential_categories = [] potential_categories = []
if category: if category:
potential_categories.append(category) # Current category first potential_categories.append(category) # Current category first
# Add empty category (uncategorized notes) # Add empty category (uncategorized notes)
if category != "": if category != "":
potential_categories.append("") potential_categories.append("")
# We could add logic here to check for other common categories if needed # 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: except HTTPStatusError as e:
# If note doesn't exist (404), we can't delete attachments anyway. # If note doesn't exist (404), we can't delete attachments anyway.
# Re-raise other errors. # Re-raise other errors.
if e.response.status_code == 404: 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 # Still raise the 404 as the primary delete operation failed
raise e raise e
else: else:
logger.error(f"Error fetching note {note_id} details before deleting attachments: {e}") logger.error(
raise e # Re-raise unexpected errors during fetch f"Error fetching note {note_id} details before deleting attachments: {e}"
)
raise e # Re-raise unexpected errors during fetch
# Proceed with API note deletion # Proceed with API note deletion
logger.info(f"Deleting note {note_id} via API.") logger.info(f"Deleting note {note_id} via API.")
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") response = await self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status() # Raise if API deletion fails response.raise_for_status() # Raise if API deletion fails
logger.info(f"Note {note_id} deleted successfully via API.") 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 # Now, attempt to delete the associated attachments directory via WebDAV for each potential category
for cat in potential_categories: for cat in potential_categories:
cat_path_part = f"{cat}/" if cat else "" cat_path_part = f"{cat}/" if cat else ""
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/" 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: try:
# delete_webdav_resource expects path relative to user's files dir # delete_webdav_resource expects path relative to user's files dir
delete_result = self.delete_webdav_resource(path=attachment_dir_path) delete_result = await self.delete_webdav_resource(
logger.info(f"WebDAV deletion for category '{cat}' attachment directory: {delete_result}") path=attachment_dir_path
)
logger.info(
f"WebDAV deletion for category '{cat}' attachment directory: {delete_result}"
)
except Exception as e: except Exception as e:
# Log the error but don't re-raise, as API note deletion itself was successful # 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 # 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 return json_response
@@ -393,7 +470,15 @@ class NextcloudClient:
# Removed _get_note_attachment_webdav_path helper # 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. Add/Update an attachment to a note via WebDAV PUT.
Requires the caller to provide the note's category. Requires the caller to provide the note's category.
@@ -402,20 +487,29 @@ class NextcloudClient:
webdav_base = self._get_webdav_base_path() webdav_base = self._get_webdav_base_path()
category_path_part = f"{category}/" if category else "" category_path_part = f"{category}/" if category else ""
attachment_dir_segment = f".attachments.{note_id}" attachment_dir_segment = f".attachments.{note_id}"
parent_dir_webdav_rel_path = f"Notes/{category_path_part}{attachment_dir_segment}" parent_dir_webdav_rel_path = (
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" # Full path for MKCOL f"Notes/{category_path_part}{attachment_dir_segment}"
attachment_path = f"{parent_dir_path}/{filename}" # Full path for PUT )
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 # Log current auth settings to diagnose the issue
logger.info("WebDAV auth settings - Username: %s, Auth Type: %s", logger.info(
self.username, type(self._client.auth).__name__) "WebDAV auth settings - Username: %s, Auth Type: %s",
self.username,
type(self._client.auth).__name__,
)
if not mime_type: if not mime_type:
mime_type, _ = mimetypes.guess_type(filename) mime_type, _ = mimetypes.guess_type(filename)
if not mime_type: 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"} headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
try: try:
@@ -423,62 +517,95 @@ class NextcloudClient:
# by checking the Notes directory # by checking the Notes directory
notes_dir_path = f"{webdav_base}/Notes" notes_dir_path = f"{webdav_base}/Notes"
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path) 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 # Log details of the auth being used by the client for this specific request
if self._client.auth: if self._client.auth:
auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization") auth_header = (
logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow") 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: 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers) logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers)
notes_dir_response = self._client.request("PROPFIND", notes_dir_path, notes_dir_response = await self._client.request(
headers=propfind_headers) "PROPFIND", notes_dir_path, headers=propfind_headers
)
if notes_dir_response.status_code == 401: 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( raise HTTPStatusError(
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}", f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
request=notes_dir_response.request, request=notes_dir_response.request,
response=notes_dir_response response=notes_dir_response,
) )
elif notes_dir_response.status_code >= 400: 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() notes_dir_response.raise_for_status()
else: else:
logger.info("Successfully accessed WebDAV Notes directory (Status: %s)", logger.info(
notes_dir_response.status_code) "Successfully accessed WebDAV Notes directory (Status: %s)",
notes_dir_response.status_code,
)
# Ensure the parent directory exists using MKCOL # Ensure the parent directory exists using MKCOL
# parent_dir_path is now determined by the helper method # parent_dir_path is now determined by the helper method
logger.info("Ensuring attachments directory exists: %s", parent_dir_path) logger.info("Ensuring attachments directory exists: %s", parent_dir_path)
mkcol_headers = {"OCS-APIRequest": "true"} mkcol_headers = {"OCS-APIRequest": "true"}
logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers) 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) # MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
# We can ignore 405, but raise for other errors # We can ignore 405, but raise for other errors
if mkcol_response.status_code not in [201, 405]: if mkcol_response.status_code not in [201, 405]:
logger.warning( logger.warning(
"Unexpected status code %s when creating attachments directory", "Unexpected status code %s when creating attachments directory",
mkcol_response.status_code mkcol_response.status_code,
) )
mkcol_response.raise_for_status() mkcol_response.raise_for_status()
else: else:
logger.info("Created/verified directory: %s (Status: %s)", logger.info(
parent_dir_path, mkcol_response.status_code) "Created/verified directory: %s (Status: %s)",
parent_dir_path,
mkcol_response.status_code,
)
# Proceed with the PUT request # Proceed with the PUT request
logger.info("Putting attachment file to: %s", attachment_path) logger.info("Putting attachment file to: %s", attachment_path)
response = self._client.put( response = await self._client.put(
attachment_path, attachment_path, content=content, headers=headers
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 # 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: except HTTPStatusError as e:
logger.error( logger.error(
@@ -497,7 +624,9 @@ class NextcloudClient:
) )
raise e 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. Fetch a specific attachment from a note via WebDAV GET.
Requires the caller to provide the note's category. Requires the caller to provide the note's category.
@@ -508,16 +637,23 @@ class NextcloudClient:
attachment_dir_segment = f".attachments.{note_id}" attachment_dir_segment = f".attachments.{note_id}"
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}" 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: try:
response = self._client.get(attachment_path) response = await self._client.get(attachment_path)
response.raise_for_status() response.raise_for_status()
content = response.content content = response.content
mime_type = response.headers.get("content-type", "application/octet-stream") 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 return content, mime_type
except HTTPStatusError as e: except HTTPStatusError as e:
+2 -5
View File
@@ -4,11 +4,8 @@ LOGGING_CONFIG = {
"version": 1, "version": 1,
"handlers": { "handlers": {
"default": { "default": {
"class": "logging.FileHandler", "class": "logging.StreamHandler",
"formatter": "http", "formatter": "http",
# "stream": "ext://sys.stderr"
"filename": "/tmp/nextcloud-mcp-server.log",
"mode": "a",
} }
}, },
"formatters": { "formatters": {
@@ -20,7 +17,7 @@ LOGGING_CONFIG = {
"loggers": { "loggers": {
"": { "": {
"handlers": ["default"], "handlers": ["default"],
"level": "DEBUG", "level": "INFO",
}, },
"httpx": { "httpx": {
"handlers": ["default"], "handlers": ["default"],
+30 -26
View File
@@ -4,15 +4,11 @@ from nextcloud_mcp_server.config import setup_logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from mcp.server import Server
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
import asyncio # Import asyncio
setup_logging() setup_logging()
logger = logging.getLogger(__name__)
@dataclass @dataclass
class AppContext: class AppContext:
@@ -23,55 +19,55 @@ class AppContext:
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Manage application lifecycle with type-safe context""" """Manage application lifecycle with type-safe context"""
# Initialize on startup # Initialize on startup
logger.info("Creating Nextcloud client") logging.info("Creating Nextcloud client")
client = NextcloudClient.from_env() client = NextcloudClient.from_env()
# Add a small delay to allow client initialization to complete logging.info("Client initialization wait complete.")
logger.info("Waiting 2 seconds for client initialization...")
logger.info("Client initialization wait complete.")
try: try:
yield AppContext(client=client) yield AppContext(client=client)
finally: finally:
# Cleanup on shutdown # Cleanup on shutdown
client._client.close() await client._client.aclose()
# Create an MCP server # Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan) mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
logger = logging.getLogger(__name__)
@mcp.resource("nc://capabilities") @mcp.resource("nc://capabilities")
def nc_get_capabilities(): async def nc_get_capabilities():
"""Get the Nextcloud Host capabilities""" """Get the Nextcloud Host capabilities"""
# client = NextcloudClient.from_env() # client = NextcloudClient.from_env()
ctx = ( ctx = (
mcp.get_context() mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244 ) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
return client.capabilities() return await client.capabilities()
@mcp.resource("notes://settings") @mcp.resource("notes://settings")
def notes_get_settings(): async def notes_get_settings():
"""Get the Notes App settings""" """Get the Notes App settings"""
ctx = ( ctx = (
mcp.get_context() mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244 ) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
return client.notes_get_settings() return await client.notes_get_settings()
@mcp.tool() @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""" """Get user note using note id"""
client: NextcloudClient = ctx.request_context.lifespan_context.client 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() @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""" """Create a new note"""
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
return client.notes_create_note( return await client.notes_create_note(
title=title, title=title,
content=content, content=content,
category=category, category=category,
@@ -79,7 +75,7 @@ def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
@mcp.tool() @mcp.tool()
def nc_notes_update_note( async def nc_notes_update_note(
note_id: int, note_id: int,
etag: str, etag: str,
title: str | None, title: str | None,
@@ -89,7 +85,7 @@ def nc_notes_update_note(
): ):
logger.info("Updating note %s", note_id) logger.info("Updating note %s", note_id)
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
return client.notes_update_note( return await client.notes_update_note(
note_id=note_id, note_id=note_id,
etag=etag, etag=etag,
title=title, title=title,
@@ -99,27 +95,35 @@ def nc_notes_update_note(
@mcp.tool() @mcp.tool()
def nc_notes_search_notes(query: str, ctx: Context): async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
"""Search notes by title or content, returning only id, title, and category.""" """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 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() @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) logger.info("Deleting note %s", note_id)
client: NextcloudClient = ctx.request_context.lifespan_context.client 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}") @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""" """Get a specific attachment from a note"""
ctx = mcp.get_context() ctx = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
# Assuming a method get_note_attachment exists in the client # Assuming a method get_note_attachment exists in the client
# This method should return the raw content and determine the mime type # 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 note_id=note_id, filename=attachment_filename
) )
return { return {
+14 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.1.3" version = "0.2.5"
description = "" description = ""
authors = [ authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"} {name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -17,6 +17,9 @@ dependencies = [
nc-mcp-server = "nextcloud_mcp_server.server:run" nc-mcp-server = "nextcloud_mcp_server.server:run"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
log_cli = 1 log_cli = 1
log_cli_level = "WARN" log_cli_level = "WARN"
log_level = "WARN" log_level = "WARN"
@@ -24,6 +27,13 @@ markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')" "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] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
@@ -31,8 +41,10 @@ build-backend = "poetry.core.masonry.api"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"black>=25.1.0", "commitizen>=4.8.2",
"ipython>=9.2.0", "ipython>=9.2.0",
"pytest>=8.3.5", "pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1", "pytest-cov>=6.1.1",
"ruff>=0.11.13",
] ]
+39 -20
View File
@@ -2,17 +2,21 @@ import pytest
import os import os
import logging import logging
import uuid import uuid
import time
from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# pytestmark = pytest.mark.asyncio(loop_scope="package")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def nc_client() -> NextcloudClient: async def nc_client() -> NextcloudClient:
""" """
Fixture to create a NextcloudClient instance for integration tests. Fixture to create a NextcloudClient instance for integration tests.
Uses environment variables for configuration. Uses environment variables for configuration.
""" """
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" 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_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD 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() client = NextcloudClient.from_env()
# Optional: Perform a quick check like getting capabilities to ensure connection works # Optional: Perform a quick check like getting capabilities to ensure connection works
try: try:
client.capabilities() await client.capabilities()
logger.info("NextcloudClient session fixture initialized and capabilities checked.") logger.info(
"NextcloudClient session fixture initialized and capabilities checked."
)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}") logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}") pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
return client return client
@pytest.fixture @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. Fixture to create a temporary note for a test and ensure its deletion afterward.
Yields the created note dictionary. Yields the created note dictionary.
""" """
asyncio.new_event_loop()
note_id = None note_id = None
unique_suffix = uuid.uuid4().hex[:8] unique_suffix = uuid.uuid4().hex[:8]
note_title = f"Temporary Test Note {unique_suffix}" 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}") logger.info(f"Creating temporary note: {note_title}")
try: 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 title=note_title, content=note_content, category=note_category
) )
note_id = created_note_data.get("id") note_id = created_note_data.get("id")
if not note_id: if not note_id:
pytest.fail("Failed to get ID from created temporary note.") pytest.fail("Failed to get ID from created temporary note.")
logger.info(f"Temporary note created with ID: {note_id}") 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: finally:
if note_id: if note_id:
logger.info(f"Cleaning up temporary note ID: {note_id}") logger.info(f"Cleaning up temporary note ID: {note_id}")
try: 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}") logger.info(f"Successfully deleted temporary note ID: {note_id}")
except HTTPStatusError as e: except HTTPStatusError as e:
# Ignore 404 if note was already deleted by the test itself # 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: except Exception as e:
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}") logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
@pytest.fixture @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. Fixture that creates a temporary note, adds an attachment, and cleans up both.
Yields a tuple: (note_data, attachment_filename, attachment_content). Yields a tuple: (note_data, attachment_filename, attachment_content).
Depends on the temporary_note fixture. Depends on the temporary_note fixture.
""" """
asyncio.new_event_loop()
note_data = temporary_note note_data = temporary_note
note_id = note_data["id"] 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] unique_suffix = uuid.uuid4().hex[:8]
attachment_filename = f"temp_attach_{unique_suffix}.txt" 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" 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: try:
# Pass the category to add_note_attachment # 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, note_id=note_id,
filename=attachment_filename, filename=attachment_filename,
content=attachment_content, content=attachment_content,
category=note_category, # Pass the fetched category category=note_category, # Pass the fetched category
mime_type=attachment_mime 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.") logger.info(f"Attachment '{attachment_filename}' added successfully.")
yield note_data, attachment_filename, attachment_content yield note_data, attachment_filename, attachment_content
# Cleanup for the attachment is handled by the notes_delete_note call # 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) # 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 # Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration 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. Tests adding an attachment (via fixture) and retrieving it.
""" """
note_data, attachment_filename, attachment_content = temporary_note_with_attachment note_data, attachment_filename, attachment_content = temporary_note_with_attachment
note_id = note_data["id"] note_id = note_data["id"]
note_category = note_data.get("category") # Get category from fixture data note_category = note_data.get("category") # Get category from fixture data
logger.info(f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}") logger.info(
# Pass category to get_note_attachment f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
retrieved_content, retrieved_mime = nc_client.get_note_attachment( )
note_id=note_id, # Pass category to get_note_attachment
filename=attachment_filename, retrieved_content, retrieved_mime = await nc_client.get_note_attachment(
category=note_category note_id=note_id, filename=attachment_filename, category=note_category
)
logger.info(
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
) )
logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
assert retrieved_content == attachment_content assert 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.") 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. 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. 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_id = note_data["id"]
note_category = note_data["category"] 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 # Add attachment within the test
unique_suffix = uuid.uuid4().hex[:8] unique_suffix = uuid.uuid4().hex[:8]
attachment_filename = f"category_attach_{unique_suffix}.txt" 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" 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 # 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, note_id=note_id,
filename=attachment_filename, filename=attachment_filename,
content=attachment_content, content=attachment_content,
category=note_category, # Pass the note's category category=note_category, # Pass the note's category
mime_type=attachment_mime mime_type=attachment_mime,
) )
assert upload_response and "status_code" in upload_response assert upload_response and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204] 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) time.sleep(1)
# Get and Verify Attachment # 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 # 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, note_id=note_id,
filename=attachment_filename, 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 retrieved_content == attachment_content
assert attachment_mime in retrieved_mime 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 # 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. 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. Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture.
""" """
note_data, attachment_filename, _ = temporary_note_with_attachment note_data, attachment_filename, _ = temporary_note_with_attachment
note_id = note_data["id"] 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 setup already added the attachment.
# Fixture teardown (from temporary_note) will delete the note. # 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. # checking state *after* cleanup.
# Instead, we will manually delete the note here and verify the attachment is gone. # 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 # Manually delete the note
logger.info(f"Manually deleting note ID: {note_id} within the test.") 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.") logger.info(f"Note ID: {note_id} deleted successfully.")
time.sleep(1) time.sleep(1)
# Verify Note Is Deleted # Verify Note Is Deleted
with pytest.raises(HTTPStatusError) as excinfo_note: 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 assert excinfo_note.value.response.status_code == 404
logger.info(f"Verified note {note_id} deletion (404 received).") logger.info(f"Verified note {note_id} deletion (404 received).")
# Verify Attachment Is Deleted (via 404 on GET) # 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: with pytest.raises(HTTPStatusError) as excinfo_attach:
# Pass category to get_note_attachment - although it should fail anyway # Pass category to get_note_attachment - although it should fail anyway
# because the note (and thus details) are gone. # because the note (and thus details) are gone.
# The client method will raise 404 from the initial notes_get_note call. # 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, note_id=note_id,
filename=attachment_filename, 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 # Expect 404 because the note itself is gone
assert excinfo_attach.value.response.status_code == 404 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 # 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() webdav_base = nc_client._get_webdav_base_path()
category_path_part = f"{note_category}/" if note_category else "" 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error(f"Attachment directory still exists! PROPFIND returned {status}") logger.error(
assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!" 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: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)") 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, # Note: The temporary_note fixture will still run its cleanup,
# but it will find the note already deleted (404) and handle it gracefully. # 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. Tests attachment handling when a note's category is changed.
Verifies attachment retrieval works before and after category change, 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] unique_suffix = uuid.uuid4().hex[:8]
note_title = f"Category Change Test {unique_suffix}" note_title = f"Category Change Test {unique_suffix}"
attachment_filename = f"cat_change_{unique_suffix}.txt" 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: try:
# 1. Create note with initial category # 1. Create note with initial category
logger.info(f"Creating note '{note_title}' in category '{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 title=note_title, content="Initial content", category=initial_category
) )
note_id = created_note["id"] note_id = created_note["id"]
@@ -170,27 +214,43 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient):
time.sleep(1) time.sleep(1)
# 2. Add attachment (passing initial category) # 2. Add attachment (passing initial category)
logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})") logger.info(
upload_response = nc_client.add_note_attachment( f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain" )
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] assert upload_response["status_code"] in [201, 204]
logger.info("Attachment added successfully.") logger.info("Attachment added successfully.")
time.sleep(1) time.sleep(1)
# 3. Verify attachment retrieval from initial category (passing initial category) # 3. Verify attachment retrieval from initial category (passing initial category)
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'") logger.info(
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category) 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 assert retrieved_content1 == attachment_content
logger.info("Attachment retrieved successfully from initial category.") logger.info("Attachment retrieved successfully from initial category.")
# 4. Update note 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) # 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"] current_etag = current_note_data["etag"]
updated_note = nc_client.notes_update_note( 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 note_id=note_id,
etag=current_etag,
category=new_category,
title=note_title,
content="Updated content", # Pass required fields
) )
etag3 = updated_note["etag"] etag3 = updated_note["etag"]
assert updated_note["category"] == new_category assert updated_note["category"] == new_category
@@ -198,83 +258,146 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient):
time.sleep(1) time.sleep(1)
# 5. Verify attachment retrieval from *new* category (passing new category) # 5. Verify attachment retrieval from *new* category (passing new category)
logger.info(f"Verifying attachment retrieval from new category '{new_category}'") logger.info(
retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category) 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 assert retrieved_content2 == attachment_content
logger.info("Attachment retrieved successfully from new category.") logger.info("Attachment retrieved successfully from new category.")
# 5.1 Verify old category attachment directory is gone via WebDAV PROPFIND # 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() 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error(f"Old attachment directory still exists! PROPFIND returned {status}") logger.error(
assert False, f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!" 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: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)") 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 # 5.2 Verify new category attachment directory exists via WebDAV PROPFIND
logger.info(f"Directly checking if new attachment directory exists in WebDAV") logger.info("Directly checking if new attachment directory exists in WebDAV")
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}"
)
try: 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 status = propfind_resp.status_code
assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}" assert status in [
logger.info(f"Verified new attachment directory exists via PROPFIND ({status} received)") 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: except HTTPStatusError as e:
logger.error(f"New attachment directory not found! PROPFIND failed with {e.response.status_code}") logger.error(
assert False, f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}" 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: finally:
# 6. Cleanup: Delete the note (client should use the *final* category for cleanup path) # 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
if note_id: 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: 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.") logger.info(f"Note {note_id} deleted.")
time.sleep(1) time.sleep(1)
# Verify note deletion # Verify note deletion
with pytest.raises(HTTPStatusError) as excinfo_note_del: 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 assert excinfo_note_del.value.response.status_code == 404
logger.info("Verified note deleted (404).") logger.info("Verified note deleted (404).")
# Verify attachment deletion (should fail with 404 on the initial note fetch) # Verify attachment deletion (should fail with 404 on the initial note fetch)
with pytest.raises(HTTPStatusError) as excinfo_attach_del: with pytest.raises(HTTPStatusError) as excinfo_attach_del:
# Pass the *last known* category, although the note fetch should fail first # 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 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 # 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() webdav_base = nc_client._get_webdav_base_path()
# Check new category attachment directory # 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: try:
resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers) resp = await nc_client._client.request(
if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists "PROPFIND", new_attachment_dir_path, headers=propfind_headers
assert False, f"New category attachment directory still exists!" )
if resp.status_code in [
200,
207,
]: # Successful PROPFIND means directory exists
assert False, "New category attachment directory still exists!"
except HTTPStatusError as e: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info("Verified new category attachment directory is gone via PROPFIND") 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 # 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: try:
resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers) resp = await nc_client._client.request(
if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists "PROPFIND", old_attachment_dir_path, headers=propfind_headers
assert False, f"Old category attachment directory still exists!" )
if resp.status_code in [
200,
207,
]: # Successful PROPFIND means directory exists
assert False, "Old category attachment directory still exists!"
except HTTPStatusError as e: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info("Verified old category attachment directory is gone via PROPFIND") f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
)
logger.info("Verified all attachment directories are properly cleaned up.") 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: except Exception as e:
logger.error(f"Error during cleanup for note {note_id}: {e}") logger.error(f"Error during cleanup for note {note_id}: {e}")
+86 -50
View File
@@ -1,12 +1,10 @@
import pytest import pytest
import os
import time import time
import uuid import uuid
import logging import logging
import tempfile
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from io import BytesIO 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 from nextcloud_mcp_server.client import NextcloudClient
@@ -18,71 +16,95 @@ logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests # Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
# Keep the test_image fixture as it's specific to generating image data # 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]: def test_image_data() -> tuple[bytes, str]:
""" """
Generate test image data (bytes) and suggest a filename. Generate test image data (bytes) and suggest a filename.
Returns (image_bytes, suggested_filename). Returns (image_bytes, suggested_filename).
""" """
logger.info("Generating test image data in memory.") 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 = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle 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.text(
(50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)
) # White text
img_byte_arr = BytesIO() img_byte_arr = BytesIO()
img.save(img_byte_arr, format='PNG') img.save(img_byte_arr, format="PNG")
image_bytes = img_byte_arr.getvalue() image_bytes = img_byte_arr.getvalue()
suggested_filename = "test_image.png" suggested_filename = "test_image.png"
logger.info(f"Generated test image data ({len(image_bytes)} bytes).") logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
return image_bytes, suggested_filename 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, Tests creating a note, attaching an image, embedding it in the content,
and verifying the attachment can be retrieved. 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_id = note_data["id"]
note_etag = note_data["etag"] 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] 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 # 1. Upload the image as an attachment
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"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')...") logger.info(
upload_response = nc_client.add_note_attachment( 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, note_id=note_id,
filename=attachment_filename, filename=attachment_filename,
content=image_content, content=image_content,
category=note_category, # Pass the category category=note_category, # Pass the category
mime_type="image/png" mime_type="image/png",
) )
assert upload_response and upload_response.get("status_code") in [201, 204] assert upload_response and upload_response.get("status_code") in [201, 204]
logger.info(f"Image uploaded successfully (Status: {upload_response.get('status_code')}).") logger.info(
time.sleep(1) # Allow potential processing time 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 # 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() webdav_base = nc_client._get_webdav_base_path()
category_path_part = f"{note_category}/" if note_category else "" 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: 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 status = propfind_resp.status_code
assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}" assert status in [
logger.info(f"Verified attachment directory exists via PROPFIND ({status} received)") 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: except HTTPStatusError as e:
logger.error(f"Attachment directory not found! PROPFIND failed with {e.response.status_code}") logger.error(
assert False, f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}" 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 # 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 ## 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" /> <img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="150" />
""" """
logger.info("Updating note content with image references...") 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, 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, content=updated_content,
title=note_data['title'], # Pass required fields title=note_data["title"], # Pass required fields
category=note_data['category'] # Pass required fields category=note_data["category"], # Pass required fields
) )
new_etag = updated_note["etag"] new_etag = updated_note["etag"]
assert new_etag != 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) time.sleep(1)
# 3. Verify the updated note content # 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"] assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
logger.info("Verified image reference exists in updated note content.") logger.info("Verified image reference exists in updated note content.")
# 4. Verify the image attachment can be retrieved # 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 # Pass category to get_note_attachment
retrieved_img_content, mime_type = nc_client.get_note_attachment( retrieved_img_content, mime_type = await nc_client.get_note_attachment(
note_id=note_id, note_id=note_id, filename=attachment_filename, category=note_category
filename=attachment_filename,
category=note_category
) )
assert retrieved_img_content == image_content assert retrieved_img_content == image_content
assert mime_type.startswith("image/png") 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) # 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
logger.info(f"Manually deleting note ID: {note_id} to verify proper attachment cleanup") logger.info(
nc_client.notes_delete_note(note_id=note_id) 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.") logger.info(f"Note ID: {note_id} deleted successfully.")
time.sleep(1) time.sleep(1)
# 6. Verify note is deleted # 6. Verify note is deleted
with pytest.raises(HTTPStatusError) as excinfo_note: 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 assert excinfo_note.value.response.status_code == 404
logger.info(f"Verified note {note_id} deletion (404 received).") logger.info(f"Verified note {note_id} deletion (404 received).")
# 7. Verify attachment directory is deleted via WebDAV PROPFIND # 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: 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error(f"Attachment directory still exists! PROPFIND returned {status}") logger.error(
assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!" 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: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)") 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, # Note: The temporary_note fixture will still run its cleanup,
# but it will find the note already deleted (404) and handle it gracefully. # but it will find the note already deleted (404) and handle it gracefully.
+174 -26
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import logging import logging
import time import asyncio
import uuid # Keep uuid if needed for generating unique data within tests import uuid # Keep uuid if needed for generating unique data within tests
from httpx import HTTPStatusError from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
@@ -13,23 +13,27 @@ logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests # Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration 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. 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"] note_id = created_note_data["id"]
logger.info(f"Reading note created by fixture, ID: {note_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["id"] == note_id
assert read_note["title"] == created_note_data["title"] assert read_note["title"] == created_note_data["title"]
assert read_note["content"] == created_note_data["content"] assert read_note["content"] == created_note_data["content"]
assert read_note["category"] == created_note_data["category"] assert read_note["category"] == created_note_data["category"]
logger.info(f"Successfully read and verified note ID: {note_id}") 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. 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_title = f"Updated Title {uuid.uuid4().hex[:8]}"
update_content = f"Updated Content {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}") 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, note_id=note_id,
etag=original_etag, etag=original_etag,
title=update_title, 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 # category=original_category # Explicitly pass category if required by update
) )
logger.info(f"Note updated: {updated_note}") logger.info(f"Note updated: {updated_note}")
assert updated_note["id"] == note_id assert updated_note["id"] == note_id
assert updated_note["title"] == update_title assert updated_note["title"] == update_title
assert updated_note["content"] == update_content 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 "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 # Optional: Verify update by reading again
time.sleep(1) # Allow potential propagation delay await asyncio.sleep(1) # Allow potential propagation delay
read_updated_note = nc_client.notes_get_note(note_id=note_id) read_updated_note = await nc_client.notes_get_note(note_id=note_id)
assert read_updated_note["title"] == update_title assert read_updated_note["title"] == update_title
assert read_updated_note["content"] == update_content assert read_updated_note["content"] == update_content
logger.info(f"Successfully updated and verified note ID: {note_id}") 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. 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 # Perform a first update to change the etag
first_update_title = f"First Update {uuid.uuid4().hex[:8]}" first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
logger.info(f"Performing first update on note ID: {note_id} to change etag.") 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, note_id=note_id,
etag=original_etag, etag=original_etag,
title=first_update_title, 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"] new_etag = first_updated_note["etag"]
assert new_etag != original_etag assert new_etag != original_etag
logger.info(f"Note ID: {note_id} updated, new etag: {new_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 # 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: with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_update_note( await nc_client.notes_update_note(
note_id=note_id, 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", title="This update should fail due to conflict",
# category=created_note_data["category"] # Pass category if required # 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.") 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. 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}") logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
with pytest.raises(HTTPStatusError) as excinfo: 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 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 --- # --- 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 # Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration 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. 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] unique_suffix = uuid.uuid4().hex[:8]
note_title = f"Category Cleanup Test {unique_suffix}" note_title = f"Category Cleanup Test {unique_suffix}"
attachment_filename = f"cleanup_test_{unique_suffix}.txt" 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: try:
# 1. Create note with initial category # 1. Create note with initial category
logger.info(f"Creating note '{note_title}' in category '{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 title=note_title, content="Initial content", category=initial_category
) )
note_id = created_note["id"] note_id = created_note["id"]
@@ -35,32 +38,48 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou
time.sleep(1) time.sleep(1)
# 2. Add attachment (passing initial category) # 2. Add attachment (passing initial category)
logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})") logger.info(
upload_response = nc_client.add_note_attachment( f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain" )
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] assert upload_response["status_code"] in [201, 204]
logger.info("Attachment added successfully.") logger.info("Attachment added successfully.")
time.sleep(1) time.sleep(1)
# 3. Verify attachment retrieval from initial category # 3. Verify attachment retrieval from initial category
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'") logger.info(
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category) 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 assert retrieved_content1 == attachment_content
logger.info("Attachment retrieved successfully from initial category.") logger.info("Attachment retrieved successfully from initial category.")
# 4. Construct and check the WebDAV path for the initial category's attachment directory # 4. Construct and check the WebDAV path for the initial category's attachment directory
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}" initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}") 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 # expose directory listing functionality, so we'll infer from attachment retrieval success
# 5. Update note category # 5. Update note category
logger.info(f"Updating note {note_id} category from '{initial_category}' to '{new_category}'") logger.info(
current_note_data = nc_client.notes_get_note(note_id=note_id) 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"] current_etag = current_note_data["etag"]
updated_note = nc_client.notes_update_note( updated_note = await nc_client.notes_update_note(
note_id=note_id, etag=current_etag, category=new_category, title=note_title, content="Updated content" note_id=note_id,
etag=current_etag,
category=new_category,
title=note_title,
content="Updated content",
) )
etag3 = updated_note["etag"] etag3 = updated_note["etag"]
assert updated_note["category"] == new_category 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) time.sleep(1)
# 6. Verify attachment retrieval from new category # 6. Verify attachment retrieval from new category
logger.info(f"Verifying attachment retrieval from new category '{new_category}'") logger.info(
retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category) 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 assert retrieved_content2 == attachment_content
logger.info("Attachment retrieved successfully from new category.") logger.info("Attachment retrieved successfully from new category.")
# 7. Try to retrieve from old category - this should fail # 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: 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) # 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!") logger.error(
assert False, "Old category attachment directory still exists and accessible!" "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: except HTTPStatusError as e:
# This is the expected outcome - old directory should be gone # This is the expected outcome - old directory should be gone
logger.info(f"Correctly got error accessing old category path: {e.response.status_code}") logger.info(
assert e.response.status_code == 404, f"Expected 404, got {e.response.status_code}" f"Correctly got error accessing old category path: {e.response.status_code}"
logger.info("Verified old category attachment directory is not accessible (good!)") )
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 # 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() 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: 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 status = propfind_resp.status_code
if status in [200, 207]: # Success codes indicate the directory exists (a problem) if status in [
logger.error(f"Old attachment directory still exists! PROPFIND returned {status}") 200,
assert False, f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # 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: except HTTPStatusError as e:
# 404 is expected - directory should not exist # 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}" assert e.response.status_code == 404, (
logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)") 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: finally:
# 8. Cleanup: Delete the note # 8. Cleanup: Delete the note
if note_id: if note_id:
logger.info(f"Cleaning up note ID: {note_id}") logger.info(f"Cleaning up note ID: {note_id}")
try: 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.") logger.info(f"Note {note_id} deleted.")
time.sleep(1) time.sleep(1)
# 9. Verify both old and new attachment paths are gone # 9. Verify both old and new attachment paths are gone
logger.info("Verifying all attachment paths are gone") logger.info("Verifying all attachment paths are gone")
with pytest.raises(HTTPStatusError) as excinfo_new: 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 assert excinfo_new.value.response.status_code == 404
with pytest.raises(HTTPStatusError) as excinfo_old: 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 assert excinfo_old.value.response.status_code == 404
# 9.1 Directly verify directories don't exist using WebDAV PROPFIND # 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() webdav_base = nc_client._get_webdav_base_path()
# Check new category attachment directory # 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"} propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try: 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 status = propfind_resp.status_code
if status in [200, 207]: # Success codes indicate the directory exists (a problem) if status in [
logger.error(f"New category attachment directory still exists! PROPFIND returned {status}") 200,
assert False, f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # 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: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info("Verified new category attachment directory is gone via PROPFIND") 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 # 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: 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 status = propfind_resp.status_code
if status in [200, 207]: # Success codes indicate the directory exists (a problem) if status in [
logger.error(f"Old category attachment directory still exists! PROPFIND returned {status}") 200,
assert False, f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # 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: except HTTPStatusError as e:
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}" assert e.response.status_code == 404, (
logger.info("Verified old category attachment directory is gone via PROPFIND") f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
)
logger.info("Verified all attachment directories are properly cleaned up.") 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: except Exception as e:
logger.error(f"Error during cleanup for note {note_id}: {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" }, { 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]] [[package]]
name = "asttokens" name = "asttokens"
version = "3.0.0" 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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.4.26" 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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.1.8" 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" }, { 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]] [[package]]
name = "coverage" name = "coverage"
version = "7.8.0" version = "7.8.0"
@@ -147,6 +197,15 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" }, { 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]] [[package]]
name = "decorator" name = "decorator"
version = "5.2.1" 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" }, { 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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" 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" }, { 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]] [[package]]
name = "matplotlib-inline" name = "matplotlib-inline"
version = "0.1.7" 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" }, { 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]] [[package]]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.1.3" version = "0.2.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -355,10 +465,12 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "black" }, { name = "commitizen" },
{ name = "ipython" }, { name = "ipython" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -370,10 +482,12 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "black", specifier = ">=25.1.0" }, { name = "commitizen", specifier = ">=4.8.2" },
{ name = "ipython", specifier = ">=9.2.0" }, { name = "ipython", specifier = ">=9.2.0" },
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "ruff", specifier = ">=0.11.13" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "pexpect" name = "pexpect"
version = "4.9.0" 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" 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" }, { 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]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "6.1.1" 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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.0.0" 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" }, { 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]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" 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" }, { 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]] [[package]]
name = "tomli" name = "tomli"
version = "2.2.1" 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" }, { 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]] [[package]]
name = "traitlets" name = "traitlets"
version = "5.14.3" version = "5.14.3"