Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae966710a9 | |||
| 9b14135dd3 | |||
| 6f92cd8157 | |||
| 6545f8165f | |||
| 4a742442fb | |||
| f84144fcaa | |||
| e09f373f84 | |||
| e50be7db07 | |||
| f03ab4ef55 | |||
| 3d26c6c145 | |||
| a4b0c84f79 | |||
| e67e7c4246 | |||
| e0c4cc5d77 | |||
| b43ffad708 | |||
| cab7a59d2b | |||
| ca5bbb783a | |||
| d47e2bb8f0 | |||
| a1c186aa95 | |||
| 57440f845f | |||
| a57c12591a | |||
| 5b512f83bd | |||
| 4a2fd67e51 | |||
| da3a0049a0 | |||
| bb53ba6275 | |||
| 7a6c7c6efa | |||
| 266d2dac8d | |||
| d64c6e112e | |||
| 167517b95d | |||
| 33aa778713 | |||
| ded48acd31 | |||
| 0dacd84cc2 | |||
| c0782dc69e | |||
| 4a8f9f7f7e | |||
| db9f2cad43 | |||
| d52860c86d | |||
| 4992f700c6 | |||
| cc2777210b | |||
| ad1320319b | |||
| 9d9f1e1eaa | |||
| 7b3b624403 | |||
| 5c908bf8d2 | |||
| fe16f4db54 | |||
| 7b10296058 | |||
| e6890ab24d | |||
| cf49866a87 | |||
| d8e7d0b465 | |||
| c336c5d2a2 | |||
| 45c0622459 | |||
| 7dfbe9dd62 | |||
| 3d5da56d83 | |||
| 2b1dbfef39 | |||
| 2e016080fd | |||
| e0a966b4a6 | |||
| 07a8b6e704 | |||
| 659da9a770 | |||
| 18f8b73982 | |||
| 2bc0988e8d | |||
| 74235ed8bb | |||
| 89a9af7c25 | |||
| d247a07643 | |||
| 794d4184d2 | |||
| cc17b28eab | |||
| 5626f6fd6f | |||
| 79a466d16c | |||
| 6aa06b4c9d | |||
| c993872ab5 | |||
| e69819a49b | |||
| 49868d2bb5 | |||
| 33c8623d5c | |||
| 150e656a36 | |||
| 2708d708b0 | |||
| c1e3a6aeaa | |||
| 5ee9435741 | |||
| 110df3d7b9 | |||
| fd61c2de56 | |||
| ee32a1bfe8 | |||
| c918284927 | |||
| 98586a3684 | |||
| 7e02527531 | |||
| 60af7ae255 | |||
| 2437d5fb12 | |||
| 615d27a9c9 | |||
| 088f6aec3f | |||
| 80c55d5bdc | |||
| 63ccc9dc6c | |||
| ec81f932ee | |||
| 88e6e865f6 | |||
| e6a5e235ea | |||
| 85a5014479 | |||
| 14da0f2451 | |||
| dfa0d50497 | |||
| 266c8bf90d | |||
| 2b5bb1cc81 | |||
| 847a69e2ba | |||
| 186d2c1d94 | |||
| 96d5789200 | |||
| b332c54330 | |||
| 9a05b171ae | |||
| e93eb9d302 | |||
| 5af7c25dab | |||
| a0b9482915 | |||
| 85b9a14fc6 | |||
| e53f4dc2dc | |||
| 8147f237cd | |||
| d4966fc925 | |||
| f173e957f3 | |||
| 78fd4eb54c | |||
| 93092a94cc | |||
| 914aef2861 | |||
| fab0f3ef05 | |||
| 0e6ff3bdda | |||
| 37f031d13e | |||
| 02e05dc8d0 | |||
| 21019c6cff | |||
| 050d236312 | |||
| 4b57d4e5c9 | |||
| a0dddbe7df | |||
| d19b1ad680 | |||
| db34473218 | |||
| 20ebd7bbcb | |||
| d48e151e95 | |||
| 892e0b4c01 | |||
| dd7eab05db | |||
| 23735aad85 | |||
| f6d4695180 | |||
| 0a138caff4 | |||
| afb08a7533 | |||
| cbed6f2b41 | |||
| 463d90a778 | |||
| 8ee2f684ec | |||
| 6288e50766 | |||
| 17b539dc21 | |||
| cf20948999 | |||
| 7a7d627efc | |||
| effa1890aa | |||
| 8e1f265e3f | |||
| 7f39b9e07d | |||
| 6ca9efbb8a | |||
| eff0f441cb | |||
| 588cb1cb70 | |||
| b85351cb24 | |||
| 089bcf92ba | |||
| cf6d2cfed7 | |||
| 5ab01f3459 | |||
| b348ce9ea1 | |||
| ee9474bf06 | |||
| 4bb1e4cf50 | |||
| 02e55a4636 | |||
| e1ecf0cdbf | |||
| a9db4fb0af | |||
| 26a6f154a9 | |||
| 8d8e6d9c99 | |||
| d41076b1a0 | |||
| 65a704869d | |||
| cfd4df971d | |||
| 83d4c33b31 | |||
| 76381c3365 | |||
| a91d6ae9f7 | |||
| 56d8d7b8f0 | |||
| c3e2c28f6b | |||
| 3a39e525e1 | |||
| c289646d27 |
@@ -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@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,7 +2,6 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
@@ -11,7 +10,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -22,7 +20,6 @@ jobs:
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
#cbcoutinho/nextcloud-mcp-server
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
@@ -36,7 +33,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -47,7 +44,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
+19
-15
@@ -6,16 +6,32 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ruff check
|
||||
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
|
||||
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -33,18 +49,6 @@ jobs:
|
||||
done
|
||||
echo "Service is ready (returned 401)."
|
||||
|
||||
- name: Install notes app
|
||||
run: |
|
||||
docker compose exec app php occ app:enable notes
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt update -y && sudo apt install -y pipx
|
||||
pipx install uv
|
||||
uv sync
|
||||
env:
|
||||
DEBIAN_FRONTEND: "noninteractive"
|
||||
|
||||
# Add subsequent steps here, e.g., running tests
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -52,4 +56,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run python -m pytest
|
||||
uv run --frozen python -m pytest
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,70 @@
|
||||
## v0.4.0 (2025-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add TablesClient and associated tools
|
||||
|
||||
### Fix
|
||||
|
||||
- update tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
## v0.3.0 (2025-06-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Switch to using async client
|
||||
|
||||
## v0.2.5 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Commitizen release process
|
||||
|
||||
## v0.2.4 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Do not update dependencies when running in Dockerfile
|
||||
- Configure logging
|
||||
|
||||
## v0.2.3 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Limit search results to notes with score > 0.5
|
||||
|
||||
## v0.2.2 (2025-05-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- Install deps before checking service
|
||||
|
||||
## v0.2.1 (2025-05-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- Install deps before checking service
|
||||
|
||||
## v0.2.1 (2025-05-24)
|
||||
|
||||
## v0.2.0 (2025-05-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- **notes**: Add append to note functionality
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.9,<1.10
|
||||
|
||||
## v0.1.3 (2025-05-16)
|
||||
|
||||
## v0.1.2 (2025-05-05)
|
||||
|
||||
## v0.1.1 (2025-05-05)
|
||||
|
||||
## v0.1.0 (2025-05-05)
|
||||
+3
-7
@@ -1,13 +1,9 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine@sha256:c77e10ca22ef1021e1cafcbaee9595b5f9d8d9f2b1fe4cc7e908b981bab73ee7
|
||||
FROM ghcr.io/astral-sh/uv:0.7.19-python3.11-alpine@sha256:03f28e0c1ffc6dd8061a4e79c890bfd0441fb9ec1d648ce8ad5abfcaaa68789a
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV FASTMCP_LOG_LEVEL=DEBUG
|
||||
|
||||
CMD ["mcp", "run", "--transport", "sse", "nextcloud_mcp_server/server.py:mcp"]
|
||||
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]
|
||||
|
||||
@@ -6,23 +6,46 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (
|
||||
|
||||
## Features
|
||||
|
||||
Currently, the server primarily interacts with the Nextcloud Notes API, providing tools and resources to manage notes.
|
||||
The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources.
|
||||
|
||||
### Available Tools
|
||||
## Supported Nextcloud Apps
|
||||
|
||||
* `nc_notes_create_note`: Create a new note.
|
||||
* `nc_notes_update_note`: Update an existing note by ID.
|
||||
* `nc_notes_delete_note`: Delete a note by ID.
|
||||
* `nc_notes_search_notes`: Search notes by title or content.
|
||||
* `nc_get_note`: Get a specific note by ID.
|
||||
| App | Support Status | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
|
||||
### Available Resources
|
||||
## Available Tools
|
||||
|
||||
* `notes://{note_id}`: Access a specific note by its ID.
|
||||
* `notes://all`: Access all notes.
|
||||
* `notes://settings`: Access note settings.
|
||||
* `nc://capabilities`: Access Nextcloud server capabilities.
|
||||
* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes.
|
||||
### Notes Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_get_note` | Get a specific note by ID |
|
||||
| `nc_notes_create_note` | Create a new note with title, content, and category |
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content |
|
||||
|
||||
### Tables Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_tables_list_tables` | List all tables available to the user |
|
||||
| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views |
|
||||
| `nc_tables_read_table` | Read rows from a table with optional pagination |
|
||||
| `nc_tables_insert_row` | Insert a new row into a table |
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
|
||||
## Available Resources
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://capabilities` | Access Nextcloud server capabilities |
|
||||
| `notes://settings` | Access Notes app settings |
|
||||
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
|
||||
|
||||
### Note Attachments
|
||||
|
||||
@@ -116,4 +139,6 @@ Contributions are welcome! Please feel free to submit issues or pull requests on
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable notes
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable tables
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 487 # ID of the note we just created
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Check if image exists
|
||||
image_path = 'sample_image.png'
|
||||
if not os.path.exists(image_path):
|
||||
print(f"Error: Image file '{image_path}' not found")
|
||||
return 1
|
||||
|
||||
# Read the image
|
||||
with open(image_path, 'rb') as f:
|
||||
image_content = f.read()
|
||||
|
||||
print(f"Attaching image to note {note_id}...")
|
||||
try:
|
||||
# Attach the image to the note
|
||||
upload_response = client.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="sample_image.png",
|
||||
content=image_content,
|
||||
mime_type="image/png"
|
||||
)
|
||||
|
||||
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
|
||||
|
||||
# Now get the current note to get its etag
|
||||
note = client.notes_get_note(note_id=note_id)
|
||||
etag = note["etag"]
|
||||
|
||||
# Update the note content to include the image references
|
||||
updated_content = f"""# Note with Visible Image Demo
|
||||
|
||||
This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
|
||||
|
||||
We'll include the sample red square image we created earlier using both Markdown and HTML methods.
|
||||
|
||||
## Method 1: Markdown Image Syntax
|
||||

|
||||
|
||||
## Method 2: HTML Image Tag
|
||||
<img src=".attachments.{note_id}/sample_image.png" alt="Sample Red Square Image" width="300" />
|
||||
|
||||
## Image Path Details
|
||||
The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
|
||||
"""
|
||||
|
||||
# Update the note with the references to the image
|
||||
updated_note = client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
content=updated_content
|
||||
)
|
||||
|
||||
print(f"Note updated with image references. You can now view it in the browser.")
|
||||
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 420 # ID of the note we created earlier
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Check if image exists
|
||||
image_path = 'sample_image.png'
|
||||
if not os.path.exists(image_path):
|
||||
print(f"Error: Image file '{image_path}' not found")
|
||||
return 1
|
||||
|
||||
# Read the image
|
||||
with open(image_path, 'rb') as f:
|
||||
image_content = f.read()
|
||||
|
||||
print(f"Attaching image to note {note_id}...")
|
||||
try:
|
||||
# Attach the image to the note
|
||||
upload_response = client.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="sample_image.png",
|
||||
content=image_content,
|
||||
mime_type="image/png"
|
||||
)
|
||||
|
||||
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
|
||||
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error attaching image: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 420 # ID of the note with the image attachment
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Get the note again to see the updated content
|
||||
try:
|
||||
note = client.notes_get_note(note_id=note_id)
|
||||
print(f"Retrieved note: {note['title']}")
|
||||
print("\nCURRENT NOTE CONTENT:")
|
||||
print("-" * 50)
|
||||
print(note['content'])
|
||||
print("-" * 50)
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error retrieving note: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+4
-3
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: mariadb:lts@sha256:663d4d3e652220e3c618564dd401ae33ee5ea2b31aafd13c6d4e8ed29b8df733
|
||||
image: mariadb:lts@sha256:1e4ec03d1b73af8e7a63137b8ef4820ac7d54c654a1e99eb76235f210f7f0a06
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:f773b35a95e170d92dd4214a3ec4859b1b7960bf56896ae687646d695f311187
|
||||
image: redis:alpine@sha256:73734b014e53b3067916918b70718ca188c16895511a272a020c9a71084eecda
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud@sha256:ad4da6574b6dcb75c185128b091e6ac613f0aabda7ce7f75c9730d9f706e37d0
|
||||
image: nextcloud:31.0.6@sha256:588609d76b217cfd0feda653eea9894eeb12e612b327d2f1dcd38221ad242be0
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
- db
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
import os
|
||||
import time # Import time for sleep
|
||||
import mimetypes
|
||||
from io import BytesIO
|
||||
from httpx import (
|
||||
Client,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Headers,
|
||||
Request,
|
||||
Response,
|
||||
HTTPStatusError,
|
||||
) # Import HTTPStatusError
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
"Request event hook ****: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
|
||||
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||
self.username = username # Store username
|
||||
self._client = Client(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
|
||||
logger.info("Creating NC Client using env vars")
|
||||
|
||||
host = os.environ["NEXTCLOUD_HOST"]
|
||||
username = os.environ["NEXTCLOUD_USERNAME"]
|
||||
password = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
# Pass username to constructor
|
||||
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
||||
|
||||
def capabilities(self):
|
||||
|
||||
response = self._client.get(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
def notes_get_settings(self):
|
||||
response = self._client.get("/apps/notes/api/v1/settings")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_get_all(self):
|
||||
response = self._client.get("/apps/notes/api/v1/notes")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_get_note(self, *, note_id: int):
|
||||
response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_create_note(
|
||||
self,
|
||||
*,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
body = {}
|
||||
if title:
|
||||
body.update({"title": title})
|
||||
if content:
|
||||
body.update({"content": content})
|
||||
if category:
|
||||
body.update({"category": category})
|
||||
|
||||
response = self._client.post(
|
||||
url="/apps/notes/api/v1/notes",
|
||||
json=body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_update_note(
|
||||
self,
|
||||
*,
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
# First, get the current note details to check for category change
|
||||
old_note = None
|
||||
try:
|
||||
if category is not None: # Only fetch if category might change
|
||||
old_note = self.notes_get_note(note_id=note_id)
|
||||
old_category = old_note.get("category", "")
|
||||
logger.info(f"Current category for note {note_id}: '{old_category}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch current note {note_id} details before update: {e}")
|
||||
# Continue with update even if we couldn't fetch current details
|
||||
old_note = None
|
||||
|
||||
# Prepare update body
|
||||
body = {}
|
||||
if title:
|
||||
body.update({"title": title})
|
||||
if content:
|
||||
body.update({"content": content})
|
||||
if category:
|
||||
body.update({"category": category})
|
||||
|
||||
logger.info(
|
||||
"Attempting to update note %s with etag %s. Body: %s",
|
||||
note_id,
|
||||
etag,
|
||||
body,
|
||||
)
|
||||
# Ensure conditional PUT using If-Match header is active
|
||||
response = self._client.put(
|
||||
url=f"/apps/notes/api/v1/notes/{note_id}",
|
||||
json=body,
|
||||
headers={"If-Match": f'"{etag}"'},
|
||||
)
|
||||
logger.info(
|
||||
"Update response for note %s: Status %s, Headers %s",
|
||||
note_id,
|
||||
response.status_code,
|
||||
response.headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
updated_note = response.json()
|
||||
|
||||
# Check for category change and clean up old attachment directory if needed
|
||||
if old_note and category is not None and old_note.get("category", "") != category:
|
||||
logger.info(f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory")
|
||||
try:
|
||||
self._cleanup_old_attachment_directory(note_id=note_id, old_category=old_note.get("category", ""))
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up old attachment directory for note {note_id}: {e}")
|
||||
# Continue with update even if cleanup failed
|
||||
|
||||
return updated_note
|
||||
|
||||
def notes_search_notes(self, *, query: str):
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
Returns notes sorted by relevance score.
|
||||
"""
|
||||
all_notes = self.notes_get_all()
|
||||
search_results = []
|
||||
|
||||
# Process the query
|
||||
query_tokens = self.process_query(query)
|
||||
|
||||
# If empty query after processing, return empty results
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in all_notes:
|
||||
title_tokens, content_tokens = self.process_note_content(note)
|
||||
score = self.calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
# Only include notes with a non-zero score
|
||||
if score > 0:
|
||||
search_results.append({
|
||||
"id": note.get("id"),
|
||||
"title": note.get("title"),
|
||||
"category": note.get("category"),
|
||||
"modified": note.get("modified"),
|
||||
"_score": score # Include score for sorting (optional field)
|
||||
})
|
||||
|
||||
# Sort by score in descending order
|
||||
search_results.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
# Keep score field for debugging
|
||||
# for result in search_results:
|
||||
# if "_score" in result:
|
||||
# del result["_score"]
|
||||
|
||||
return search_results
|
||||
|
||||
def process_query(self, query: str) -> list[str]:
|
||||
"""
|
||||
Tokenize and normalize the search query.
|
||||
"""
|
||||
# Convert to lowercase and split into tokens
|
||||
tokens = query.lower().split()
|
||||
# Filter out very short tokens (optional)
|
||||
tokens = [token for token in tokens if len(token) > 1]
|
||||
# Could add stop word removal here
|
||||
return tokens
|
||||
|
||||
def process_note_content(self, note: dict) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Tokenize and normalize note title and content.
|
||||
"""
|
||||
# Process title
|
||||
title = note.get("title", "").lower()
|
||||
title_tokens = title.split()
|
||||
|
||||
# Process content
|
||||
content = note.get("content", "").lower()
|
||||
content_tokens = content.split()
|
||||
|
||||
return title_tokens, content_tokens
|
||||
|
||||
def calculate_score(self, query_tokens: list[str], title_tokens: list[str], content_tokens: list[str]) -> float:
|
||||
"""
|
||||
Calculate a relevance score for a note based on query tokens.
|
||||
"""
|
||||
# Constants for weighting
|
||||
TITLE_WEIGHT = 3.0
|
||||
CONTENT_WEIGHT = 1.0
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Count matches in title
|
||||
title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
title_match_ratio = title_matches / len(query_tokens)
|
||||
score += TITLE_WEIGHT * title_match_ratio
|
||||
|
||||
# Count matches in content
|
||||
content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
content_match_ratio = content_matches / len(query_tokens)
|
||||
score += CONTENT_WEIGHT * content_match_ratio
|
||||
|
||||
# If no tokens matched at all, return zero
|
||||
if title_matches == 0 and content_matches == 0:
|
||||
return 0.0
|
||||
|
||||
return score
|
||||
|
||||
def _cleanup_old_attachment_directory(self, *, note_id: int, old_category: str):
|
||||
"""
|
||||
Clean up the attachment directory for a note in its old category location.
|
||||
Called after a category change to prevent orphaned directories.
|
||||
"""
|
||||
# Construct path to old attachment directory
|
||||
old_category_path_part = f"{old_category}/" if old_category else ""
|
||||
old_attachment_dir_path = f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = self.delete_webdav_resource(path=old_attachment_dir_path)
|
||||
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
raise e
|
||||
|
||||
def delete_webdav_resource(self, *, path: str):
|
||||
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
||||
# Ensure path ends with a slash if it's a directory
|
||||
if not path.endswith('/'):
|
||||
# This is a heuristic; a more robust solution would check resource type first
|
||||
# but for the specific case of deleting the attachment directory, this is acceptable.
|
||||
path_with_slash = f"{path}/"
|
||||
else:
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.info("Deleting WebDAV resource: %s", webdav_path)
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = self._client.request("PROPFIND", webdav_path, headers=propfind_headers)
|
||||
logger.info(f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}")
|
||||
# If we get here with 2xx, the resource exists
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.info(f"Resource '{webdav_path}' doesn't exist, no deletion needed.")
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = self._client.delete(webdav_path, headers=headers)
|
||||
response.raise_for_status() # Raises for 4xx/5xx status codes
|
||||
logger.info("Successfully deleted WebDAV resource '%s' (Status: %s)", webdav_path, response.status_code)
|
||||
# DELETE typically returns 204 No Content on success
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error deleting WebDAV resource '%s': %s",
|
||||
webdav_path,
|
||||
e,
|
||||
)
|
||||
# It's expected to get a 404 if the resource doesn't exist, which is fine.
|
||||
# We only re-raise if it's not a 404.
|
||||
if e.response.status_code != 404:
|
||||
raise e
|
||||
else:
|
||||
logger.info("Resource '%s' not found, no deletion needed.", webdav_path)
|
||||
return {"status_code": 404} # Indicate resource was not found
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error deleting WebDAV resource '%s': %s",
|
||||
webdav_path,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
|
||||
def notes_delete_note(self, *, note_id: int):
|
||||
"""Deletes a note via API and attempts to delete its attachment directory via WebDAV."""
|
||||
# Fetch note details first to get the category for path construction
|
||||
try:
|
||||
note_details = self.notes_get_note(note_id=note_id)
|
||||
category = note_details.get("category", "")
|
||||
|
||||
# Check for other potential categories (if any note was moved between categories)
|
||||
# We can't reliably detect this without a dedicated tracking mechanism, but we can
|
||||
# implement a basic check for common category names and empty category
|
||||
potential_categories = []
|
||||
if category:
|
||||
potential_categories.append(category) # Current category first
|
||||
|
||||
# Add empty category (uncategorized notes)
|
||||
if category != "":
|
||||
potential_categories.append("")
|
||||
|
||||
# We could add logic here to check for other common categories if needed
|
||||
|
||||
logger.info(f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}")
|
||||
except HTTPStatusError as e:
|
||||
# If note doesn't exist (404), we can't delete attachments anyway.
|
||||
# Re-raise other errors.
|
||||
if e.response.status_code == 404:
|
||||
logger.warning(f"Note {note_id} not found when attempting delete. Skipping attachment cleanup.")
|
||||
# Still raise the 404 as the primary delete operation failed
|
||||
raise e
|
||||
else:
|
||||
logger.error(f"Error fetching note {note_id} details before deleting attachments: {e}")
|
||||
raise e # Re-raise unexpected errors during fetch
|
||||
|
||||
# Proceed with API note deletion
|
||||
logger.info(f"Deleting note {note_id} via API.")
|
||||
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
|
||||
response.raise_for_status() # Raise if API deletion fails
|
||||
logger.info(f"Note {note_id} deleted successfully via API.")
|
||||
json_response = response.json() # Usually empty on success
|
||||
|
||||
# Now, attempt to delete the associated attachments directory via WebDAV for each potential category
|
||||
for cat in potential_categories:
|
||||
cat_path_part = f"{cat}/" if cat else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(f"Attempting to delete attachment directory for note {note_id} in category '{cat}' via WebDAV: {attachment_dir_path}")
|
||||
try:
|
||||
# delete_webdav_resource expects path relative to user's files dir
|
||||
delete_result = self.delete_webdav_resource(path=attachment_dir_path)
|
||||
logger.info(f"WebDAV deletion for category '{cat}' attachment directory: {delete_result}")
|
||||
except Exception as e:
|
||||
# Log the error but don't re-raise, as API note deletion itself was successful
|
||||
# Also, we want to try other potential categories even if one fails
|
||||
logger.error(f"Failed during WebDAV deletion for category '{cat}' attachment directory: {e}")
|
||||
|
||||
return json_response
|
||||
|
||||
# Removed incorrect get_note_attachment method that used Notes API
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
# Use the stored username
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
# Removed _get_note_attachment_webdav_path helper
|
||||
|
||||
def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, category: str | None = None, mime_type: str | None = None):
|
||||
"""
|
||||
Add/Update an attachment to a note via WebDAV PUT.
|
||||
Requires the caller to provide the note's category.
|
||||
"""
|
||||
# Construct paths based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
parent_dir_webdav_rel_path = f"Notes/{category_path_part}{attachment_dir_segment}"
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" # Full path for MKCOL
|
||||
attachment_path = f"{parent_dir_path}/{filename}" # Full path for PUT
|
||||
|
||||
logger.info(f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}")
|
||||
|
||||
# Log current auth settings to diagnose the issue
|
||||
logger.info("WebDAV auth settings - Username: %s, Auth Type: %s",
|
||||
self.username, type(self._client.auth).__name__)
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream" # Default if guessing fails
|
||||
|
||||
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First check if we can access WebDAV at all with current credentials
|
||||
# by checking the Notes directory
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
|
||||
|
||||
# Log details of the auth being used by the client for this specific request
|
||||
if self._client.auth:
|
||||
auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization")
|
||||
logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow")
|
||||
else:
|
||||
logger.info("No httpx.Auth object configured on the client for PROPFIND (Notes dir).")
|
||||
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers)
|
||||
notes_dir_response = self._client.request("PROPFIND", notes_dir_path,
|
||||
headers=propfind_headers)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.")
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
response=notes_dir_response
|
||||
)
|
||||
elif notes_dir_response.status_code >= 400:
|
||||
logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code)
|
||||
notes_dir_response.raise_for_status()
|
||||
else:
|
||||
logger.info("Successfully accessed WebDAV Notes directory (Status: %s)",
|
||||
notes_dir_response.status_code)
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
# parent_dir_path is now determined by the helper method
|
||||
logger.info("Ensuring attachments directory exists: %s", parent_dir_path)
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers)
|
||||
mkcol_response = self._client.request("MKCOL", parent_dir_path, headers=mkcol_headers)
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
# We can ignore 405, but raise for other errors
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.warning(
|
||||
"Unexpected status code %s when creating attachments directory",
|
||||
mkcol_response.status_code
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
else:
|
||||
logger.info("Created/verified directory: %s (Status: %s)",
|
||||
parent_dir_path, mkcol_response.status_code)
|
||||
|
||||
# Proceed with the PUT request
|
||||
logger.info("Putting attachment file to: %s", attachment_path)
|
||||
response = self._client.put(
|
||||
attachment_path,
|
||||
content=content,
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status() # Raises for 4xx/5xx status codes
|
||||
logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code)
|
||||
# PUT typically returns 201 Created or 204 No Content on success
|
||||
return {"status_code": response.status_code} # Return status or relevant info
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error uploading attachment '%s' to note %s: %s",
|
||||
filename,
|
||||
note_id,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error uploading attachment '%s' to note %s: %s",
|
||||
filename,
|
||||
note_id,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
|
||||
def get_note_attachment(self, *, note_id: int, filename: str, category: str | None = None):
|
||||
"""
|
||||
Fetch a specific attachment from a note via WebDAV GET.
|
||||
Requires the caller to provide the note's category.
|
||||
"""
|
||||
# Construct path based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.info(f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}")
|
||||
|
||||
try:
|
||||
response = self._client.get(attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content))
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error fetching attachment '%s' for note %s: %s",
|
||||
filename,
|
||||
note_id,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error fetching attachment '%s' for note %s: %s",
|
||||
filename,
|
||||
note_id,
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
import logging
|
||||
|
||||
from .notes import NotesClient
|
||||
from .webdav import WebDAVClient
|
||||
from .tables import TablesClient
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
"Request event hook: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
"""Main Nextcloud client that orchestrates all app clients."""
|
||||
|
||||
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||
self.username = username
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
# event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
logger.info("Creating NC Client using env vars")
|
||||
|
||||
host = os.environ["NEXTCLOUD_HOST"]
|
||||
username = os.environ["NEXTCLOUD_USERNAME"]
|
||||
password = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
# Pass username to constructor
|
||||
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
||||
|
||||
async def capabilities(self):
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Base client for Nextcloud operations with shared authentication."""
|
||||
|
||||
from abc import ABC
|
||||
from httpx import AsyncClient
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, username: str):
|
||||
"""Initialize with shared HTTP client and username.
|
||||
|
||||
Args:
|
||||
http_client: Authenticated AsyncClient instance
|
||||
username: Nextcloud username for WebDAV operations
|
||||
"""
|
||||
self._client = http_client
|
||||
self.username = username
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
url: Request URL
|
||||
**kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
logger.debug(f"Making {method} request to {url}")
|
||||
response = await self._client.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
async def get_settings(self) -> Dict[str, Any]:
|
||||
"""Get Notes app settings."""
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||
return response.json()
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/notes")
|
||||
return response.json()
|
||||
|
||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Get a specific note by ID."""
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/notes/api/v1/notes/{note_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_note(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new note."""
|
||||
body = {}
|
||||
if title:
|
||||
body["title"] = title
|
||||
if content:
|
||||
body["content"] = content
|
||||
if category:
|
||||
body["category"] = category
|
||||
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/notes/api/v1/notes", json=body
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing note."""
|
||||
# Get current note details to check for category change
|
||||
old_note = None
|
||||
try:
|
||||
if category is not None:
|
||||
old_note = await self.get_note(note_id)
|
||||
old_category = old_note.get("category", "")
|
||||
logger.info(f"Current category for note {note_id}: '{old_category}'")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch current note {note_id} details before update: {e}"
|
||||
)
|
||||
old_note = None
|
||||
|
||||
# Prepare update body
|
||||
body = {}
|
||||
if title:
|
||||
body["title"] = title
|
||||
if content:
|
||||
body["content"] = content
|
||||
if category:
|
||||
body["category"] = category
|
||||
|
||||
logger.info(
|
||||
f"Attempting to update note {note_id} with etag {etag}. Body: {body}"
|
||||
)
|
||||
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/notes/api/v1/notes/{note_id}",
|
||||
json=body,
|
||||
headers={"If-Match": f'"{etag}"'},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Update response for note {note_id}: Status {response.status_code}"
|
||||
)
|
||||
updated_note = response.json()
|
||||
|
||||
# Check for category change and cleanup old attachment directory if needed
|
||||
if (
|
||||
old_note
|
||||
and category is not None
|
||||
and old_note.get("category", "") != category
|
||||
):
|
||||
logger.info(
|
||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||
)
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
await webdav_client.cleanup_old_attachment_directory(
|
||||
note_id=note_id, old_category=old_note.get("category", "")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error cleaning up old attachment directory for note {note_id}: {e}"
|
||||
)
|
||||
|
||||
return updated_note
|
||||
|
||||
async def delete_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Delete a note and its attachments."""
|
||||
# Fetch note details first to get category for cleanup
|
||||
try:
|
||||
note_details = await self.get_note(note_id)
|
||||
category = note_details.get("category", "")
|
||||
|
||||
# Determine potential categories for cleanup
|
||||
potential_categories = []
|
||||
if category:
|
||||
potential_categories.append(category)
|
||||
if category != "":
|
||||
potential_categories.append("") # Empty category
|
||||
|
||||
logger.info(
|
||||
f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch note {note_id} details before deletion: {e}"
|
||||
)
|
||||
potential_categories = ["", "Unknown"] # Try common categories
|
||||
|
||||
# Delete the note via API
|
||||
logger.info(f"Deleting note {note_id} via API")
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/apps/notes/api/v1/notes/{note_id}"
|
||||
)
|
||||
logger.info(f"Note {note_id} deleted successfully via API")
|
||||
json_response = response.json()
|
||||
|
||||
# Clean up attachment directories
|
||||
try:
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
|
||||
for cat in potential_categories:
|
||||
try:
|
||||
await webdav_client.cleanup_note_attachments(note_id, cat)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup attachments for category '{cat}': {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during attachment cleanup: {e}")
|
||||
|
||||
return json_response
|
||||
|
||||
async def append_content(self, note_id: int, content: str) -> Dict[str, Any]:
|
||||
"""Append content to an existing note with a separator."""
|
||||
logger.info(f"Appending content to note {note_id}")
|
||||
|
||||
# Get current note
|
||||
current_note = await self.get_note(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.update(
|
||||
note_id=note_id,
|
||||
etag=current_note["etag"],
|
||||
content=new_content,
|
||||
title=None, # Keep existing title
|
||||
category=None, # Keep existing category
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TablesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
async def list_tables(self) -> List[Dict[str, Any]]:
|
||||
"""List all tables available to the user."""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/ocs/v2.php/apps/tables/api/2/tables",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def get_table_schema(self, table_id: int) -> Dict[str, Any]:
|
||||
"""Get the schema/structure of a specific table including columns and views."""
|
||||
# Using v1 API as v2 schema endpoint had issues during testing
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/scheme"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_table_rows(
|
||||
self, table_id: int, limit: Optional[int] = None, offset: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Read rows from a table with optional pagination."""
|
||||
params = {}
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/rows", params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_row(self, table_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Args:
|
||||
table_id: ID of the table to insert into
|
||||
data: Dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
# Transform data to API format: {"data": {"1": "text", "2": 42}}
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/apps/tables/api/2/tables/{table_id}/rows",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
json={"data": api_data},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def update_row(self, row_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Args:
|
||||
row_id: ID of the row to update
|
||||
data: Dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
# Transform data to API format for v1 endpoint
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/index.php/apps/tables/api/1/rows/{row_id}",
|
||||
json={"data": api_data},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_row(self, row_id: int) -> Dict[str, Any]:
|
||||
"""Delete a row from a table."""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/index.php/apps/tables/api/1/rows/{row_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def transform_row_data(
|
||||
self, rows: List[Dict[str, Any]], columns: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Transform raw row data into more readable format using column names.
|
||||
|
||||
Args:
|
||||
rows: Raw row data from the API
|
||||
columns: Column definitions from table schema
|
||||
|
||||
Returns:
|
||||
List of rows with column names as keys instead of column IDs
|
||||
"""
|
||||
# Create mapping from column ID to column title
|
||||
column_map = {col["id"]: col["title"] for col in columns}
|
||||
|
||||
transformed_rows = []
|
||||
for row in rows:
|
||||
transformed_row = {
|
||||
"id": row["id"],
|
||||
"tableId": row["tableId"],
|
||||
"createdBy": row["createdBy"],
|
||||
"createdAt": row["createdAt"],
|
||||
"lastEditBy": row["lastEditBy"],
|
||||
"lastEditAt": row["lastEditAt"],
|
||||
"data": {},
|
||||
}
|
||||
|
||||
# Transform data array to column_name: value mapping
|
||||
for item in row["data"]:
|
||||
column_id = item["columnId"]
|
||||
column_name = column_map.get(column_id, f"column_{column_id}")
|
||||
transformed_row["data"][column_name] = item["value"]
|
||||
|
||||
transformed_rows.append(transformed_row)
|
||||
|
||||
return transformed_rows
|
||||
@@ -0,0 +1,244 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud WebDAV operations."""
|
||||
|
||||
async def delete_resource(self, path: str) -> Dict[str, Any]:
|
||||
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
||||
# Ensure path ends with a slash if it's a directory
|
||||
if not path.endswith("/"):
|
||||
path_with_slash = f"{path}/"
|
||||
else:
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.info(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await self._client.request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.info(
|
||||
f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.info(
|
||||
f"Resource '{webdav_path}' doesn't exist, no deletion needed."
|
||||
)
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._client.delete(webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully deleted WebDAV resource '{webdav_path}' (Status: {response.status_code})"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.warning(f"HTTP error deleting WebDAV resource '{webdav_path}': {e}")
|
||||
if e.response.status_code != 404:
|
||||
raise e
|
||||
else:
|
||||
logger.info(f"Resource '{webdav_path}' not found, no deletion needed.")
|
||||
return {"status_code": 404}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error deleting WebDAV resource '{webdav_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
self, note_id: int, old_category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up the attachment directory for a note in its old category location."""
|
||||
old_category_path_part = f"{old_category}/" if old_category else ""
|
||||
old_attachment_dir_path = (
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_note_attachments(
|
||||
self, note_id: int, category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up attachment directory for a specific note and category."""
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to delete attachment directory for note {note_id} in category '{category}' via WebDAV: {attachment_dir_path}"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.info(
|
||||
f"WebDAV deletion for category '{category}' attachment directory: {delete_result}"
|
||||
)
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed during WebDAV deletion for category '{category}' attachment directory: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
self,
|
||||
note_id: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
category: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add/Update an attachment to a note via WebDAV PUT."""
|
||||
# Construct paths based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
parent_dir_webdav_rel_path = (
|
||||
f"Notes/{category_path_part}{attachment_dir_segment}"
|
||||
)
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
# Log current auth settings
|
||||
logger.info(
|
||||
f"WebDAV auth settings - Username: {self.username}, Auth Type: {type(self._client.auth).__name__}"
|
||||
)
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First check if we can access WebDAV at all
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
logger.info(f"Testing WebDAV access to Notes directory: {notes_dir_path}")
|
||||
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
notes_dir_response = await self._client.request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error(
|
||||
"WebDAV authentication failed for Notes directory. Please verify WebDAV permissions."
|
||||
)
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
response=notes_dir_response,
|
||||
)
|
||||
elif notes_dir_response.status_code >= 400:
|
||||
logger.error(
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully accessed WebDAV Notes directory (Status: {notes_dir_response.status_code})"
|
||||
)
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
logger.info(f"Ensuring attachments directory exists: {parent_dir_path}")
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
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)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.warning(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Created/verified directory: {parent_dir_path} (Status: {mkcol_response.status_code})"
|
||||
)
|
||||
|
||||
# Proceed with the PUT request
|
||||
logger.info(f"Putting attachment file to: {attachment_path}")
|
||||
response = await self._client.put(
|
||||
attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id} (Status: {response.status_code})"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def get_note_attachment(
|
||||
self, note_id: int, filename: str, category: Optional[str] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Fetch a specific attachment from a note via WebDAV GET."""
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.get(attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.info(
|
||||
f"Successfully fetched attachment '{filename}' ({mime_type}, {len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
@@ -4,11 +4,8 @@ LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.FileHandler",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "http",
|
||||
# "stream": "ext://sys.stderr"
|
||||
"filename": "/tmp/nextcloud-mcp-server.log",
|
||||
"mode": "a",
|
||||
}
|
||||
},
|
||||
"formatters": {
|
||||
@@ -20,7 +17,7 @@ LOGGING_CONFIG = {
|
||||
"loggers": {
|
||||
"": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
},
|
||||
"httpx": {
|
||||
"handlers": ["default"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Controllers for utility operations."""
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
"""Handles notes search logic and scoring."""
|
||||
|
||||
def search_notes(
|
||||
self, notes: List[Dict[str, Any]], query: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
Returns notes sorted by relevance score.
|
||||
"""
|
||||
search_results = []
|
||||
query_tokens = self._process_query(query)
|
||||
|
||||
# If empty query after processing, return empty results
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in notes:
|
||||
title_tokens, content_tokens = self._process_note_content(note)
|
||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
# Only include notes with a non-zero score
|
||||
if score >= 0.5:
|
||||
search_results.append(
|
||||
{
|
||||
"id": note.get("id"),
|
||||
"title": note.get("title"),
|
||||
"category": note.get("category"),
|
||||
"modified": note.get("modified"),
|
||||
"_score": score, # Include score for sorting
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score in descending order
|
||||
search_results.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
return search_results
|
||||
|
||||
def _process_query(self, query: str) -> List[str]:
|
||||
"""
|
||||
Tokenize and normalize the search query.
|
||||
"""
|
||||
# Convert to lowercase and split into tokens
|
||||
tokens = query.lower().split()
|
||||
# Filter out very short tokens
|
||||
tokens = [token for token in tokens if len(token) > 1]
|
||||
return tokens
|
||||
|
||||
def _process_note_content(
|
||||
self, note: Dict[str, Any]
|
||||
) -> tuple[List[str], List[str]]:
|
||||
"""
|
||||
Tokenize and normalize note title and content.
|
||||
"""
|
||||
# Process title
|
||||
title = note.get("title", "").lower()
|
||||
title_tokens = title.split()
|
||||
|
||||
# Process content
|
||||
content = note.get("content", "").lower()
|
||||
content_tokens = content.split()
|
||||
|
||||
return title_tokens, content_tokens
|
||||
|
||||
def _calculate_score(
|
||||
self,
|
||||
query_tokens: List[str],
|
||||
title_tokens: List[str],
|
||||
content_tokens: List[str],
|
||||
) -> float:
|
||||
"""
|
||||
Calculate a relevance score for a note based on query tokens.
|
||||
"""
|
||||
# Constants for weighting
|
||||
TITLE_WEIGHT = 3.0
|
||||
CONTENT_WEIGHT = 1.0
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Count matches in title
|
||||
title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
title_match_ratio = title_matches / len(query_tokens)
|
||||
score += TITLE_WEIGHT * title_match_ratio
|
||||
|
||||
# Count matches in content
|
||||
content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
content_match_ratio = content_matches / len(query_tokens)
|
||||
score += CONTENT_WEIGHT * content_match_ratio
|
||||
|
||||
# If no tokens matched at all, return zero
|
||||
if title_matches == 0 and content_matches == 0:
|
||||
return 0.0
|
||||
|
||||
return score
|
||||
@@ -4,15 +4,11 @@ from nextcloud_mcp_server.config import setup_logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from mcp.server import Server
|
||||
from collections.abc import AsyncIterator
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
import asyncio # Import asyncio
|
||||
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
@@ -23,55 +19,54 @@ class AppContext:
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logger.info("Creating Nextcloud client")
|
||||
logging.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
# Add a small delay to allow client initialization to complete
|
||||
logger.info("Waiting 2 seconds for client initialization...")
|
||||
logger.info("Client initialization wait complete.")
|
||||
logging.info("Client initialization wait complete.")
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
client._client.close()
|
||||
await client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
def nc_get_capabilities():
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
# client = NextcloudClient.from_env()
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.capabilities()
|
||||
return await client.capabilities()
|
||||
|
||||
|
||||
@mcp.resource("notes://settings")
|
||||
def notes_get_settings():
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_get_settings()
|
||||
return await client.notes.get_settings()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_get_note(note_id: int, ctx: Context):
|
||||
async def nc_get_note(note_id: int, ctx: Context):
|
||||
"""Get user note using note id"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_get_note(note_id=note_id)
|
||||
return await client.notes.get_note(note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_create_note(
|
||||
return await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
@@ -79,7 +74,7 @@ def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_update_note(
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
@@ -89,7 +84,7 @@ def nc_notes_update_note(
|
||||
):
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_update_note(
|
||||
return await client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
@@ -99,27 +94,89 @@ def nc_notes_update_note(
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_search_notes(query: str, ctx: Context):
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
|
||||
"""Append content to an existing note with a clear separator"""
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_search_notes(query=query)
|
||||
return await client.notes.append_content(note_id=note_id, content=content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
async def nc_notes_search_notes(query: str, ctx: Context):
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_search_notes(query=query)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_delete_note(note_id=note_id)
|
||||
return await client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
ctx: Context = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = client.get_note_attachment(
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.1.3"
|
||||
version = "0.4.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.7,<1.8)",
|
||||
"mcp[cli] (>=1.9,<1.10)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)"
|
||||
]
|
||||
@@ -17,6 +17,9 @@ dependencies = [
|
||||
nc-mcp-server = "nextcloud_mcp_server.server:run"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
log_cli = 1
|
||||
log_cli_level = "WARN"
|
||||
log_level = "WARN"
|
||||
@@ -24,6 +27,13 @@ markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
|
||||
]
|
||||
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
tag_format = "v$version"
|
||||
version_scheme = "pep440"
|
||||
version_provider = "uv"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
@@ -31,8 +41,10 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.1.0",
|
||||
"commitizen>=4.8.2",
|
||||
"ipython>=9.2.0",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=6.1.1",
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices"
|
||||
"config:best-practices",
|
||||
"mergeConfidence:all-badges"
|
||||
],
|
||||
"dependencyDashboard": true
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Create a simple image (a red square with some text)
|
||||
img = Image.new('RGB', (200, 200), color = (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0))
|
||||
draw.text((40, 100), "Nextcloud MCP", fill=(255, 255, 255))
|
||||
img.save('sample_image.png')
|
||||
|
||||
print("Image created successfully: sample_image.png")
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import time
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# 1. Create a new test note
|
||||
test_title = "Test Note for Deletion with Attachment"
|
||||
print(f"Creating test note: {test_title}...")
|
||||
note = client.notes_create_note(
|
||||
title=test_title,
|
||||
content="This note will be deleted but its attachment should remain.",
|
||||
category="Test"
|
||||
)
|
||||
note_id = note["id"]
|
||||
print(f"Note created with ID: {note_id}")
|
||||
|
||||
# 2. Attach the existing image to the note
|
||||
print(f"Attaching image to note {note_id}...")
|
||||
with open("sample_image.png", 'rb') as f:
|
||||
image_content = f.read()
|
||||
|
||||
upload_response = client.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="deletion_test_image.png",
|
||||
content=image_content,
|
||||
mime_type="image/png"
|
||||
)
|
||||
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
|
||||
|
||||
# 3. Verify the attachment exists
|
||||
print(f"Verifying attachment exists...")
|
||||
content, mime_type = client.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="deletion_test_image.png"
|
||||
)
|
||||
print(f"Attachment verified (Size: {len(content)} bytes)")
|
||||
|
||||
# 4. Delete the note
|
||||
print(f"\nDeleting note {note_id}...")
|
||||
response = client.notes_delete_note(note_id=note_id)
|
||||
print(f"Note deleted successfully.")
|
||||
|
||||
# Wait a moment for deletion to process
|
||||
time.sleep(1)
|
||||
|
||||
# 5. Verify the note is gone
|
||||
print("\nVerifying note is deleted...")
|
||||
try:
|
||||
client.notes_get_note(note_id=note_id)
|
||||
print("ERROR: Note still exists!")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Note confirmed deleted (404 Not Found expected): {e}")
|
||||
|
||||
# 6. Check if attachment still exists (expected behavior)
|
||||
print("\nChecking if attachment still exists (orphaned)...")
|
||||
try:
|
||||
content, mime_type = client.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="deletion_test_image.png"
|
||||
)
|
||||
print("EXPECTED BEHAVIOR: Attachment still exists after note deletion!")
|
||||
print(f"Attachment size: {len(content)} bytes")
|
||||
print(f"This matches the documented behavior of Nextcloud Notes.")
|
||||
|
||||
# Save the orphaned attachment to verify
|
||||
output_path = "orphaned_attachment.png"
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(content)
|
||||
print(f"Saved orphaned attachment to: {output_path}")
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Unexpected: Attachment was deleted with note: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+41
-21
@@ -2,17 +2,22 @@ import pytest
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from httpx import HTTPStatusError
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# pytestmark = pytest.mark.asyncio(loop_scope="package")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def nc_client() -> NextcloudClient:
|
||||
async def nc_client() -> NextcloudClient:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Uses environment variables for configuration.
|
||||
"""
|
||||
|
||||
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
||||
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
||||
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
||||
@@ -20,19 +25,24 @@ def nc_client() -> NextcloudClient:
|
||||
client = NextcloudClient.from_env()
|
||||
# Optional: Perform a quick check like getting capabilities to ensure connection works
|
||||
try:
|
||||
client.capabilities()
|
||||
logger.info("NextcloudClient session fixture initialized and capabilities checked.")
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"NextcloudClient session fixture initialized and capabilities checked."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temporary_note(nc_client: NextcloudClient):
|
||||
async def temporary_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Fixture to create a temporary note for a test and ensure its deletion afterward.
|
||||
Yields the created note dictionary.
|
||||
"""
|
||||
asyncio.new_event_loop()
|
||||
|
||||
note_id = None
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Temporary Test Note {unique_suffix}"
|
||||
@@ -42,21 +52,21 @@ def temporary_note(nc_client: NextcloudClient):
|
||||
|
||||
logger.info(f"Creating temporary note: {note_title}")
|
||||
try:
|
||||
created_note_data = nc_client.notes_create_note(
|
||||
created_note_data = await nc_client.notes.create_note(
|
||||
title=note_title, content=note_content, category=note_category
|
||||
)
|
||||
note_id = created_note_data.get("id")
|
||||
if not note_id:
|
||||
pytest.fail("Failed to get ID from created temporary note.")
|
||||
|
||||
|
||||
logger.info(f"Temporary note created with ID: {note_id}")
|
||||
yield created_note_data # Provide the created note data to the test
|
||||
yield created_note_data # Provide the created note data to the test
|
||||
|
||||
finally:
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up temporary note ID: {note_id}")
|
||||
try:
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Successfully deleted temporary note ID: {note_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if note was already deleted by the test itself
|
||||
@@ -67,36 +77,46 @@ def temporary_note(nc_client: NextcloudClient):
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temporary_note_with_attachment(nc_client: NextcloudClient, temporary_note: dict):
|
||||
async def temporary_note_with_attachment(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Fixture that creates a temporary note, adds an attachment, and cleans up both.
|
||||
Yields a tuple: (note_data, attachment_filename, attachment_content).
|
||||
Depends on the temporary_note fixture.
|
||||
"""
|
||||
asyncio.new_event_loop()
|
||||
|
||||
note_data = temporary_note
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from the note data
|
||||
note_category = note_data.get("category") # Get category from the note data
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = f"temp_attach_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
attachment_mime = "text/plain"
|
||||
|
||||
logger.info(f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')")
|
||||
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')"
|
||||
)
|
||||
try:
|
||||
# Pass the category to add_note_attachment
|
||||
upload_response = nc_client.add_note_attachment(
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=note_category, # Pass the fetched category
|
||||
mime_type=attachment_mime
|
||||
category=note_category, # Pass the fetched category
|
||||
mime_type=attachment_mime,
|
||||
)
|
||||
assert upload_response.get("status_code") in [201, 204], f"Failed to upload attachment: {upload_response}"
|
||||
assert upload_response.get("status_code") in [
|
||||
201,
|
||||
204,
|
||||
], f"Failed to upload attachment: {upload_response}"
|
||||
logger.info(f"Attachment '{attachment_filename}' added successfully.")
|
||||
|
||||
|
||||
yield note_data, attachment_filename, attachment_content
|
||||
|
||||
|
||||
# Cleanup for the attachment is handled by the notes_delete_note call
|
||||
# in the temporary_note fixture's finally block (which deletes the .attachments dir)
|
||||
|
||||
|
||||
@@ -14,80 +14,105 @@ logger = logging.getLogger(__name__)
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
def test_attachments_add_and_get(nc_client: NextcloudClient, temporary_note_with_attachment: tuple):
|
||||
|
||||
async def test_attachments_add_and_get(
|
||||
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
||||
):
|
||||
"""
|
||||
Tests adding an attachment (via fixture) and retrieving it.
|
||||
"""
|
||||
note_data, attachment_filename, attachment_content = temporary_note_with_attachment
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
|
||||
logger.info(f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}")
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
)
|
||||
logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
assert "text/plain" in retrieved_mime # Fixture uses text/plain
|
||||
assert "text/plain" in retrieved_mime # Fixture uses text/plain
|
||||
logger.info("Retrieved attachment content and mime type verified successfully.")
|
||||
|
||||
def test_attachments_add_to_note_with_category(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_attachments_add_to_note_with_category(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests adding and retrieving an attachment specifically for a note that has a category.
|
||||
Uses temporary_note fixture and adds attachment manually within the test.
|
||||
"""
|
||||
note_data = temporary_note # Note created by fixture (has category 'TemporaryTesting')
|
||||
note_data = (
|
||||
temporary_note # Note created by fixture (has category 'TemporaryTesting')
|
||||
)
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data["category"]
|
||||
logger.info(f"Using note ID: {note_id} with category '{note_category}' for attachment test.")
|
||||
logger.info(
|
||||
f"Using note ID: {note_id} with category '{note_category}' for attachment test."
|
||||
)
|
||||
|
||||
# Add attachment within the test
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = f"category_attach_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
attachment_mime = "text/plain"
|
||||
|
||||
logger.info(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
|
||||
logger.info(
|
||||
f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}"
|
||||
)
|
||||
# Pass category to add_note_attachment
|
||||
upload_response = nc_client.add_note_attachment(
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=note_category, # Pass the note's category
|
||||
mime_type=attachment_mime
|
||||
category=note_category, # Pass the note's category
|
||||
mime_type=attachment_mime,
|
||||
)
|
||||
assert upload_response and "status_code" in upload_response
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']})."
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Get and Verify Attachment
|
||||
logger.info(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}"
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category # Pass the note's category
|
||||
category=note_category, # Pass the note's category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
)
|
||||
logger.info(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
assert attachment_mime in retrieved_mime
|
||||
logger.info("Retrieved attachment content and mime type verified successfully for note with category.")
|
||||
logger.info(
|
||||
"Retrieved attachment content and mime type verified successfully for note with category."
|
||||
)
|
||||
# Cleanup is handled by the temporary_note fixture
|
||||
|
||||
def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporary_note_with_attachment: tuple):
|
||||
|
||||
async def test_attachments_cleanup_on_note_delete(
|
||||
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
||||
):
|
||||
"""
|
||||
Tests that the attachment (and its directory) are deleted when the parent note is deleted.
|
||||
Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture.
|
||||
"""
|
||||
note_data, attachment_filename, _ = temporary_note_with_attachment
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
|
||||
# Fixture setup already added the attachment.
|
||||
# Fixture teardown (from temporary_note) will delete the note.
|
||||
@@ -96,55 +121,74 @@ def test_attachments_cleanup_on_note_delete(nc_client: NextcloudClient, temporar
|
||||
# checking state *after* cleanup.
|
||||
# Instead, we will manually delete the note here and verify the attachment is gone.
|
||||
|
||||
logger.info(f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture).")
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture)."
|
||||
)
|
||||
|
||||
# Manually delete the note
|
||||
logger.info(f"Manually deleting note ID: {note_id} within the test.")
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# Verify Note Is Deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
nc_client.notes_get_note(note_id=note_id)
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
|
||||
# Verify Attachment Is Deleted (via 404 on GET)
|
||||
logger.info(f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}")
|
||||
logger.info(
|
||||
f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo_attach:
|
||||
# Pass category to get_note_attachment - although it should fail anyway
|
||||
# because the note (and thus details) are gone.
|
||||
# The client method will raise 404 from the initial notes_get_note call.
|
||||
nc_client.get_note_attachment(
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category # Pass category, though note fetch should fail first
|
||||
category=note_category, # Pass category, though note fetch should fail first
|
||||
)
|
||||
# Expect 404 because the note itself is gone
|
||||
assert excinfo_attach.value.response.status_code == 404
|
||||
logger.info(f"Attachment '{attachment_filename}' correctly not found (404) after note deletion.")
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' correctly not found (404) after note deletion."
|
||||
)
|
||||
|
||||
# Directly verify attachment directory doesn't exist using WebDAV PROPFIND
|
||||
logger.info(f"Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
category_path_part = f"{note_category}/" if note_category else ""
|
||||
attachment_dir_path = f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(f"Attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)")
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# Note: The temporary_note fixture will still run its cleanup,
|
||||
# but it will find the note already deleted (404) and handle it gracefully.
|
||||
|
||||
def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
|
||||
async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests attachment handling when a note's category is changed.
|
||||
Verifies attachment retrieval works before and after category change,
|
||||
@@ -156,12 +200,12 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Category Change Test {unique_suffix}"
|
||||
attachment_filename = f"cat_change_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
created_note = nc_client.notes_create_note(
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
@@ -170,27 +214,43 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})")
|
||||
upload_response = nc_client.add_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=initial_category,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info("Attachment added successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify attachment retrieval from initial category (passing initial category)
|
||||
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
|
||||
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
assert retrieved_content1 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from initial category.")
|
||||
|
||||
# 4. Update note category
|
||||
logger.info(f"Updating note {note_id} category from '{initial_category}' to '{new_category}'")
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
# Need to fetch the latest etag after attachment add (WebDAV ops don't update note etag)
|
||||
current_note_data = nc_client.notes_get_note(note_id=note_id)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
updated_note = nc_client.notes_update_note(
|
||||
note_id=note_id, etag=current_etag, category=new_category, title=note_title, content="Updated content" # Pass required fields
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=current_etag,
|
||||
category=new_category,
|
||||
title=note_title,
|
||||
content="Updated content", # Pass required fields
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
@@ -198,83 +258,146 @@ def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
time.sleep(1)
|
||||
|
||||
# 5. Verify attachment retrieval from *new* category (passing new category)
|
||||
logger.info(f"Verifying attachment retrieval from new category '{new_category}'")
|
||||
retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
)
|
||||
assert retrieved_content2 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from new category.")
|
||||
|
||||
|
||||
# 5.1 Verify old category attachment directory is gone via WebDAV PROPFIND
|
||||
logger.info(f"Directly checking if old attachment directory exists in WebDAV")
|
||||
logger.info("Directly checking if old attachment directory exists in WebDAV")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(f"Old attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)")
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# 5.2 Verify new category attachment directory exists via WebDAV PROPFIND
|
||||
logger.info(f"Directly checking if new attachment directory exists in WebDAV")
|
||||
new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
logger.info("Directly checking if new attachment directory exists in WebDAV")
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(f"Verified new attachment directory exists via PROPFIND ({status} received)")
|
||||
assert status in [
|
||||
207,
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified new attachment directory exists via PROPFIND ({status} received)"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"New attachment directory not found! PROPFIND failed with {e.response.status_code}")
|
||||
assert False, f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
logger.error(
|
||||
f"New attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
|
||||
finally:
|
||||
# 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up note ID: {note_id} (last known category: '{new_category}')")
|
||||
logger.info(
|
||||
f"Cleaning up note ID: {note_id} (last known category: '{new_category}')"
|
||||
)
|
||||
try:
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
time.sleep(1)
|
||||
# Verify note deletion
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note_del:
|
||||
nc_client.notes_get_note(note_id=note_id)
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note_del.value.response.status_code == 404
|
||||
logger.info("Verified note deleted (404).")
|
||||
# Verify attachment deletion (should fail with 404 on the initial note fetch)
|
||||
with pytest.raises(HTTPStatusError) as excinfo_attach_del:
|
||||
# Pass the *last known* category, although the note fetch should fail first
|
||||
nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=new_category,
|
||||
)
|
||||
assert excinfo_attach_del.value.response.status_code == 404
|
||||
logger.info("Verified attachment cannot be retrieved after note deletion (404).")
|
||||
|
||||
logger.info(
|
||||
"Verified attachment cannot be retrieved after note deletion (404)."
|
||||
)
|
||||
|
||||
# 6.1 Verify both old and new attachment directories are gone via WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directories don't exist via PROPFIND")
|
||||
logger.info(
|
||||
"Directly verifying attachment directories don't exist via PROPFIND"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
|
||||
|
||||
# Check new category attachment directory
|
||||
new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
|
||||
if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists
|
||||
assert False, f"New category attachment directory still exists!"
|
||||
resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
if resp.status_code in [
|
||||
200,
|
||||
207,
|
||||
]: # Successful PROPFIND means directory exists
|
||||
assert False, "New category attachment directory still exists!"
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info("Verified new category attachment directory is gone via PROPFIND")
|
||||
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified new category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
# Check old category attachment directory
|
||||
old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
|
||||
if resp.status_code in [200, 207]: # Successful PROPFIND means directory exists
|
||||
assert False, f"Old category attachment directory still exists!"
|
||||
resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
if resp.status_code in [
|
||||
200,
|
||||
207,
|
||||
]: # Successful PROPFIND means directory exists
|
||||
assert False, "Old category attachment directory still exists!"
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info("Verified old category attachment directory is gone via PROPFIND")
|
||||
|
||||
logger.info("Verified all attachment directories are properly cleaned up.")
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import pytest
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import tempfile
|
||||
from PIL import Image, ImageDraw
|
||||
from io import BytesIO
|
||||
from httpx import HTTPStatusError # Import if needed for specific error checks
|
||||
from httpx import HTTPStatusError # Import if needed for specific error checks
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -18,71 +16,95 @@ logger = logging.getLogger(__name__)
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# Keep the test_image fixture as it's specific to generating image data
|
||||
@pytest.fixture(scope="module") # Keep module scope if image generation is slow
|
||||
@pytest.fixture(scope="module") # Keep module scope if image generation is slow
|
||||
def test_image_data() -> tuple[bytes, str]:
|
||||
"""
|
||||
Generate test image data (bytes) and suggest a filename.
|
||||
Returns (image_bytes, suggested_filename).
|
||||
"""
|
||||
logger.info("Generating test image data in memory.")
|
||||
img = Image.new('RGB', (300, 200), color=(255, 255, 255))
|
||||
img = Image.new("RGB", (300, 200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle
|
||||
draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)) # White text
|
||||
|
||||
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle
|
||||
draw.text(
|
||||
(50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)
|
||||
) # White text
|
||||
|
||||
img_byte_arr = BytesIO()
|
||||
img.save(img_byte_arr, format='PNG')
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
image_bytes = img_byte_arr.getvalue()
|
||||
suggested_filename = "test_image.png"
|
||||
logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
|
||||
return image_bytes, suggested_filename
|
||||
|
||||
|
||||
def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: dict, test_image_data: tuple):
|
||||
async def test_note_with_embedded_image(
|
||||
nc_client: NextcloudClient, temporary_note: dict, test_image_data: tuple
|
||||
):
|
||||
"""
|
||||
Tests creating a note, attaching an image, embedding it in the content,
|
||||
and verifying the attachment can be retrieved.
|
||||
"""
|
||||
note_data = temporary_note # Use fixture for note creation/cleanup
|
||||
note_data = temporary_note # Use fixture for note creation/cleanup
|
||||
note_id = note_data["id"]
|
||||
note_etag = note_data["etag"]
|
||||
image_content, suggested_filename = test_image_data # Get image data from fixture
|
||||
image_content, suggested_filename = test_image_data # Get image data from fixture
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = f"test_image_{unique_suffix}.png" # Make filename unique per run
|
||||
attachment_filename = (
|
||||
f"test_image_{unique_suffix}.png" # Make filename unique per run
|
||||
)
|
||||
|
||||
# 1. Upload the image as an attachment
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
logger.info(f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')...")
|
||||
upload_response = nc_client.add_note_attachment(
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
logger.info(
|
||||
f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..."
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=image_content,
|
||||
category=note_category, # Pass the category
|
||||
mime_type="image/png"
|
||||
category=note_category, # Pass the category
|
||||
mime_type="image/png",
|
||||
)
|
||||
assert upload_response and upload_response.get("status_code") in [201, 204]
|
||||
logger.info(f"Image uploaded successfully (Status: {upload_response.get('status_code')}).")
|
||||
time.sleep(1) # Allow potential processing time
|
||||
logger.info(
|
||||
f"Image uploaded successfully (Status: {upload_response.get('status_code')})."
|
||||
)
|
||||
time.sleep(1) # Allow potential processing time
|
||||
|
||||
# 1.1 Verify attachment directory exists via WebDAV PROPFIND
|
||||
logger.info(f"Directly checking if attachment directory exists in WebDAV")
|
||||
logger.info("Directly checking if attachment directory exists in WebDAV")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
category_path_part = f"{note_category}/" if note_category else ""
|
||||
attachment_dir_path = f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
assert status in [207, 200], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(f"Verified attachment directory exists via PROPFIND ({status} received)")
|
||||
assert status in [
|
||||
207,
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified attachment directory exists via PROPFIND ({status} received)"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"Attachment directory not found! PROPFIND failed with {e.response.status_code}")
|
||||
assert False, f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
logger.error(
|
||||
f"Attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
|
||||
# 2. Update the note content to include the embedded image references
|
||||
updated_content = f"""{note_data['content']}
|
||||
updated_content = f"""{note_data["content"]}
|
||||
|
||||
## Image Embedding Test
|
||||
|
||||
@@ -93,12 +115,12 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di
|
||||
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="150" />
|
||||
"""
|
||||
logger.info("Updating note content with image references...")
|
||||
updated_note = nc_client.notes_update_note(
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=note_etag, # Use etag from the created note
|
||||
etag=note_etag, # Use etag from the created note
|
||||
content=updated_content,
|
||||
title=note_data['title'], # Pass required fields
|
||||
category=note_data['category'] # Pass required fields
|
||||
title=note_data["title"], # Pass required fields
|
||||
category=note_data["category"], # Pass required fields
|
||||
)
|
||||
new_etag = updated_note["etag"]
|
||||
assert new_etag != note_etag
|
||||
@@ -106,45 +128,59 @@ def test_note_with_embedded_image(nc_client: NextcloudClient, temporary_note: di
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify the updated note content
|
||||
retrieved_note = nc_client.notes_get_note(note_id=note_id)
|
||||
retrieved_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
|
||||
logger.info("Verified image reference exists in updated note content.")
|
||||
|
||||
# 4. Verify the image attachment can be retrieved
|
||||
logger.info(f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')...")
|
||||
logger.info(
|
||||
f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..."
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_img_content, mime_type = nc_client.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category
|
||||
retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
assert retrieved_img_content == image_content
|
||||
assert mime_type.startswith("image/png")
|
||||
logger.info("Successfully retrieved and verified image attachment content and mime type.")
|
||||
logger.info(
|
||||
"Successfully retrieved and verified image attachment content and mime type."
|
||||
)
|
||||
|
||||
# 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
|
||||
logger.info(f"Manually deleting note ID: {note_id} to verify proper attachment cleanup")
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
logger.info(
|
||||
f"Manually deleting note ID: {note_id} to verify proper attachment cleanup"
|
||||
)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify note is deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
nc_client.notes_get_note(note_id=note_id)
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
|
||||
# 7. Verify attachment directory is deleted via WebDAV PROPFIND
|
||||
logger.info(f"Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(f"Attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info(f"Verified attachment directory does not exist via PROPFIND (404 received)")
|
||||
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# Note: The temporary_note fixture will still run its cleanup,
|
||||
# but it will find the note already deleted (404) and handle it gracefully.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import logging
|
||||
import time
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
import asyncio
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
@@ -13,23 +13,27 @@ logger = logging.getLogger(__name__)
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = nc_client.notes_get_note(note_id=note_id)
|
||||
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
@@ -40,9 +44,9 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = nc_client.notes_update_note(
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
@@ -50,22 +54,27 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert updated_note["category"] == original_category # Verify category didn't change
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
time.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = nc_client.notes_get_note(note_id=note_id)
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
@@ -76,7 +85,7 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = nc_client.notes_update_note(
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
@@ -86,29 +95,165 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
time.sleep(1)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}")
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
nc_client.notes_update_note(
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
nc_client.notes_delete_note(note_id=non_existent_id)
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404.")
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
# --- Attachment tests moved to test_attachments.py ---
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
import pytest
|
||||
import logging
|
||||
import asyncio
|
||||
import uuid
|
||||
from httpx import HTTPStatusError
|
||||
from typing import Dict, Any
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
@@ -11,7 +11,10 @@ logger = logging.getLogger(__name__)
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
def test_category_change_cleans_up_old_attachments_directory(nc_client: NextcloudClient):
|
||||
|
||||
async def test_category_change_cleans_up_old_attachments_directory(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""
|
||||
Tests that when a note's category is changed, the old attachment directory is properly cleaned up.
|
||||
"""
|
||||
@@ -21,12 +24,12 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Category Cleanup Test {unique_suffix}"
|
||||
attachment_filename = f"cleanup_test_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
created_note = nc_client.notes_create_note(
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
@@ -35,32 +38,48 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})")
|
||||
upload_response = nc_client.add_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, content=attachment_content, category=initial_category, mime_type="text/plain"
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=initial_category,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info("Attachment added successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify attachment retrieval from initial category
|
||||
logger.info(f"Verifying attachment retrieval from initial category '{initial_category}'")
|
||||
retrieved_content1, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
assert retrieved_content1 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from initial category.")
|
||||
|
||||
# 4. Construct and check the WebDAV path for the initial category's attachment directory
|
||||
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
|
||||
logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}")
|
||||
# Here we would check if the directory exists, but the WebDAV client doesn't directly
|
||||
# Here we would check if the directory exists, but the WebDAV client doesn't directly
|
||||
# expose directory listing functionality, so we'll infer from attachment retrieval success
|
||||
|
||||
# 5. Update note category
|
||||
logger.info(f"Updating note {note_id} category from '{initial_category}' to '{new_category}'")
|
||||
current_note_data = nc_client.notes_get_note(note_id=note_id)
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
updated_note = nc_client.notes_update_note(
|
||||
note_id=note_id, etag=current_etag, category=new_category, title=note_title, content="Updated content"
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=current_etag,
|
||||
category=new_category,
|
||||
title=note_title,
|
||||
content="Updated content",
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
@@ -68,94 +87,177 @@ def test_category_change_cleans_up_old_attachments_directory(nc_client: Nextclou
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify attachment retrieval from new category
|
||||
logger.info(f"Verifying attachment retrieval from new category '{new_category}'")
|
||||
retrieved_content2, _ = nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
)
|
||||
assert retrieved_content2 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from new category.")
|
||||
|
||||
# 7. Try to retrieve from old category - this should fail
|
||||
logger.info(f"Trying to retrieve attachment from old category '{initial_category}' - should fail")
|
||||
logger.info(
|
||||
f"Trying to retrieve attachment from old category '{initial_category}' - should fail"
|
||||
)
|
||||
try:
|
||||
nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
# If we get here, it means the old directory still exists (a problem)
|
||||
logger.error("ISSUE DETECTED: Was able to retrieve attachment from old category path!")
|
||||
assert False, "Old category attachment directory still exists and accessible!"
|
||||
logger.error(
|
||||
"ISSUE DETECTED: Was able to retrieve attachment from old category path!"
|
||||
)
|
||||
assert False, (
|
||||
"Old category attachment directory still exists and accessible!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# This is the expected outcome - old directory should be gone
|
||||
logger.info(f"Correctly got error accessing old category path: {e.response.status_code}")
|
||||
assert e.response.status_code == 404, f"Expected 404, got {e.response.status_code}"
|
||||
logger.info("Verified old category attachment directory is not accessible (good!)")
|
||||
|
||||
logger.info(
|
||||
f"Correctly got error accessing old category path: {e.response.status_code}"
|
||||
)
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is not accessible (good!)"
|
||||
)
|
||||
|
||||
# 7.1 Directly check old attachment directory existence using WebDAV PROPFIND
|
||||
logger.info(f"Directly checking if old attachment directory exists in WebDAV")
|
||||
logger.info(
|
||||
"Directly checking if old attachment directory exists in WebDAV"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(f"Old attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(f"Verified old attachment directory does not exist (PROPFIND returned {status})")
|
||||
logger.info(
|
||||
f"Verified old attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# 404 is expected - directory should not exist
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info(f"Verified old attachment directory does not exist via PROPFIND (404 received)")
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
finally:
|
||||
# 8. Cleanup: Delete the note
|
||||
# 8. Cleanup: Delete the note
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up note ID: {note_id}")
|
||||
try:
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# 9. Verify both old and new attachment paths are gone
|
||||
logger.info("Verifying all attachment paths are gone")
|
||||
with pytest.raises(HTTPStatusError) as excinfo_new:
|
||||
nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=new_category)
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=new_category,
|
||||
)
|
||||
assert excinfo_new.value.response.status_code == 404
|
||||
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo_old:
|
||||
nc_client.get_note_attachment(note_id=note_id, filename=attachment_filename, category=initial_category)
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=initial_category,
|
||||
)
|
||||
assert excinfo_old.value.response.status_code == 404
|
||||
|
||||
|
||||
# 9.1 Directly verify directories don't exist using WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directories don't exist via PROPFIND")
|
||||
logger.info(
|
||||
"Directly verifying attachment directories don't exist via PROPFIND"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
|
||||
|
||||
# Check new category attachment directory
|
||||
new_attachment_dir_path = f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", new_attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(f"New category attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"New category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(f"Verified new category attachment directory does not exist (PROPFIND returned {status})")
|
||||
logger.info(
|
||||
f"Verified new category attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info("Verified new category attachment directory is gone via PROPFIND")
|
||||
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified new category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
# Check old category attachment directory
|
||||
old_attachment_dir_path = f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
propfind_resp = nc_client._client.request("PROPFIND", old_attachment_dir_path, headers=propfind_headers)
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(f"Old category attachment directory still exists! PROPFIND returned {status}")
|
||||
assert False, f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(f"Verified old category attachment directory does not exist (PROPFIND returned {status})")
|
||||
logger.info(
|
||||
f"Verified old category attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
logger.info("Verified old category attachment directory is gone via PROPFIND")
|
||||
|
||||
logger.info("Verified all attachment directories are properly cleaned up.")
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 420 # ID of the note with the image attachment
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# First get the current note
|
||||
try:
|
||||
note = client.notes_get_note(note_id=note_id)
|
||||
print(f"Retrieved note: {note['title']}")
|
||||
|
||||
# Update the note content to include a direct reference to the image
|
||||
updated_content = f"""# Note with Image Attachment
|
||||
|
||||
This note demonstrates attaching images to Nextcloud Notes.
|
||||
|
||||
An image will be attached to this note as a demonstration.
|
||||
|
||||
## Image Reference
|
||||
|
||||
The image is attached but not displayed inline in the Notes UI.
|
||||
Attachments in Nextcloud Notes exist as separate files in the .attachments.{note_id}
|
||||
directory but aren't automatically embedded in the note content.
|
||||
|
||||
You can view the image by going to the Files app and navigating to:
|
||||
/Notes/.attachments.{note_id}/sample_image.png
|
||||
|
||||
## Orphaned Attachments
|
||||
|
||||
When notes are deleted, their attachments remain in the system.
|
||||
This is the expected behavior of the official Nextcloud Notes app.
|
||||
"""
|
||||
|
||||
# Update the note with the new content
|
||||
updated_note = client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=note['etag'],
|
||||
content=updated_content
|
||||
)
|
||||
|
||||
print(f"Note updated successfully with image reference information.")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating note: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 487 # ID of the note with the issue
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
try:
|
||||
# Get the current note to get its etag
|
||||
note = client.notes_get_note(note_id=note_id)
|
||||
etag = note["etag"]
|
||||
|
||||
# Update the note content with correct image reference syntax
|
||||
updated_content = f"""# Note with Visible Image Demo
|
||||
|
||||
This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
|
||||
|
||||
We'll include the sample red square image we created earlier using both Markdown and HTML methods.
|
||||
|
||||
## Method 1: Markdown Image Syntax
|
||||

|
||||
|
||||
## Method 2: HTML Image Tag
|
||||
<img src=".attachments.{note_id}/sample_image.png" alt="Sample Red Square Image" width="300" />
|
||||
|
||||
## Image Path Details
|
||||
The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
|
||||
|
||||
## Note on Image Embedding
|
||||
In Nextcloud Notes, images must be referenced with a period at the beginning of the path. The correct format is:
|
||||
`.attachments.{note_id}/filename.png`
|
||||
|
||||
Without the leading period, the image won't display correctly.
|
||||
"""
|
||||
|
||||
# Update the note with the corrected image references
|
||||
updated_note = client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
content=updated_content
|
||||
)
|
||||
|
||||
print(f"Note updated with corrected image references.")
|
||||
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
import base64
|
||||
from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
|
||||
import logging
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_webdav_auth_with_attachment():
|
||||
"""
|
||||
Test function to verify WebDAV authentication by attempting to use add_note_attachment.
|
||||
"""
|
||||
client = NextcloudClient.from_env()
|
||||
print("Client authentication type:", type(client._client.auth).__name__)
|
||||
|
||||
username = os.environ["NEXTCLOUD_USERNAME"]
|
||||
webdav_base = client._get_webdav_base_path()
|
||||
notes_path = f"{webdav_base}/Notes"
|
||||
print(f"Target WebDAV Notes path for PROPFIND check: {notes_path}")
|
||||
|
||||
temp_note_id = None
|
||||
try:
|
||||
# 1. Create a temporary note to get a note_id
|
||||
print("\nCreating a temporary note...")
|
||||
temp_note_title = f"Temp Note for WebDAV Test - {int(time.time())}"
|
||||
created_note = client.notes_create_note(title=temp_note_title, content="Test content")
|
||||
temp_note_id = created_note.get("id")
|
||||
if not temp_note_id:
|
||||
print("Error: Failed to create temporary note.")
|
||||
return 1
|
||||
print(f"Temporary note created with ID: {temp_note_id}")
|
||||
|
||||
# 2. Attempt to add an attachment (this will trigger the internal PROPFIND)
|
||||
print(f"\nTest: Attempting add_note_attachment for note_id {temp_note_id} (uses client's BasicAuth)")
|
||||
dummy_content = b"This is a test attachment."
|
||||
dummy_filename = "test_attachment.txt"
|
||||
|
||||
# The add_note_attachment method itself contains the PROPFIND check
|
||||
# and will log details if it fails.
|
||||
response_data = client.add_note_attachment(
|
||||
note_id=temp_note_id,
|
||||
filename=dummy_filename,
|
||||
content=dummy_content,
|
||||
mime_type="text/plain"
|
||||
)
|
||||
print(f"add_note_attachment response: {response_data}")
|
||||
if response_data and response_data.get("status_code") in [201, 204]:
|
||||
print("Success! add_note_attachment (and its internal PROPFIND) worked.")
|
||||
else:
|
||||
print("Failure or unexpected response from add_note_attachment.")
|
||||
# The client.py logs should show details of the PROPFIND if it failed.
|
||||
|
||||
except HTTPStatusError as e:
|
||||
print(f"HTTPStatusError during add_note_attachment: {e.response.status_code} - {e.response.text}")
|
||||
if e.response.status_code == 401:
|
||||
print("Reproduced 401 Unauthorized during add_note_attachment's PROPFIND check!")
|
||||
else:
|
||||
print("An HTTP error other than 401 occurred.")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {str(e)}")
|
||||
finally:
|
||||
# 3. Clean up: Delete the temporary note
|
||||
if temp_note_id:
|
||||
print(f"\nCleaning up: Deleting temporary note ID {temp_note_id}...")
|
||||
try:
|
||||
client.notes_delete_note(note_id=temp_note_id)
|
||||
print(f"Successfully deleted temporary note ID {temp_note_id}.")
|
||||
except Exception as e_del:
|
||||
print(f"Error deleting temporary note ID {temp_note_id}: {str(e_del)}")
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(test_webdav_auth_with_attachment())
|
||||
@@ -25,6 +25,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argcomplete"
|
||||
version = "3.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asttokens"
|
||||
version = "3.0.0"
|
||||
@@ -34,34 +43,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
@@ -71,6 +52,54 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.8"
|
||||
@@ -92,6 +121,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "commitizen"
|
||||
version = "4.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argcomplete" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "colorama" },
|
||||
{ name = "decli" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "questionary" },
|
||||
{ name = "termcolor" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/15/c2fe85c0224886109b5061419acea2e20539be1b4bff619a16d7295fe0f2/commitizen-4.8.2.tar.gz", hash = "sha256:4fc73126c7300f715f11b85242550677722c57767b579100e869ccd45143e2c5", size = 53235, upload-time = "2025-05-22T03:16:39.915Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/40/2b81df1b3ec24c41004512feba0884895b84748775d21642690120539a30/commitizen-4.8.2-py3-none-any.whl", hash = "sha256:86cae0bd8e1da889389d828b30a5acb79b62f9290f9274b127ee9d8c189eb16c", size = 76074, upload-time = "2025-05-22T03:16:38.431Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.8.0"
|
||||
@@ -147,6 +197,15 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decli"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/a0/a4658f93ecb589f479037b164dc13c68d108b50bf6594e54c820749f97ac/decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f", size = 7424, upload-time = "2024-04-28T17:41:05.963Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/70/3ea48dc9e958d7d66c44c9944809181f1ca79aaef25703c023b5092d34ff/decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed", size = 7854, upload-time = "2024-04-28T17:41:04.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decorator"
|
||||
version = "5.2.1"
|
||||
@@ -275,6 +334,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -287,6 +358,54 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib-inline"
|
||||
version = "0.1.7"
|
||||
@@ -301,7 +420,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.7.1"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -314,9 +433,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ae/588691c45b38f4fbac07fa3d6d50cea44cc6b35d16ddfdf26e17a0467ab2/mcp-1.7.1.tar.gz", hash = "sha256:eb4f1f53bd717f75dda8a1416e00804b831a8f3c331e23447a03b78f04b43a6e", size = 230903, upload-time = "2025-05-02T17:01:56.403Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/79/fe0e20c3358997a80911af51bad927b5ea2f343ef95ab092b19c9cc48b59/mcp-1.7.1-py3-none-any.whl", hash = "sha256:f7e6108977db6d03418495426c7ace085ba2341b75197f8727f96f9cfd30057a", size = 100365, upload-time = "2025-05-02T17:01:54.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -334,18 +453,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.1.3"
|
||||
version = "0.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -355,25 +465,29 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "commitizen" },
|
||||
{ name = "ipython" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.7,<1.8" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.9,<1.10" },
|
||||
{ name = "pillow", specifier = ">=11.2.1,<12.0.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "black", specifier = ">=25.1.0" },
|
||||
{ name = "commitizen", specifier = ">=4.8.2" },
|
||||
{ name = "ipython", specifier = ">=9.2.0" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "ruff", specifier = ">=0.11.13" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -394,15 +508,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
@@ -474,15 +579,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -640,6 +736,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.1.1"
|
||||
@@ -671,6 +779,53 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/b8/d16eb579277f3de9e56e5ad25280fab52fc5774117fb70362e8c2e016559/questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587", size = 26775, upload-time = "2024-12-29T11:49:17.802Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747, upload-time = "2024-12-29T11:49:16.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
@@ -684,6 +839,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@@ -741,6 +921,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057, upload-time = "2024-10-06T19:50:04.115Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755, upload-time = "2024-10-06T19:50:02.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
@@ -780,6 +969,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885, upload-time = "2024-08-14T08:19:41.488Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955, upload-time = "2024-08-14T08:19:40.05Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "traitlets"
|
||||
version = "5.14.3"
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
def main():
|
||||
note_id = 420 # ID of the note we created earlier
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# First verify the note exists
|
||||
print(f"Retrieving note {note_id}...")
|
||||
try:
|
||||
note = client.notes_get_note(note_id=note_id)
|
||||
print(f"Note retrieved: {note['title']}")
|
||||
except Exception as e:
|
||||
print(f"Error retrieving note: {e}")
|
||||
return 1
|
||||
|
||||
# Now try to get the image attachment
|
||||
attachment_filename = "sample_image.png"
|
||||
print(f"Retrieving attachment '{attachment_filename}' from note {note_id}...")
|
||||
try:
|
||||
content, mime_type = client.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename
|
||||
)
|
||||
print(f"Attachment retrieved successfully!")
|
||||
print(f"MIME type: {mime_type}")
|
||||
print(f"Content size: {len(content)} bytes")
|
||||
|
||||
# Save the retrieved image to verify it's the same
|
||||
output_path = "retrieved_image.png"
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(content)
|
||||
print(f"Saved retrieved image to: {output_path}")
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error retrieving attachment: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user