Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0e4d96a8 | |||
| 1dca929983 | |||
| c91001d7e1 | |||
| 83748a27da | |||
| 3ddeeab67f | |||
| 2e078498b1 | |||
| 7291c930c4 | |||
| b8191c134a | |||
| 09061d9e4f | |||
| 2d3cb85fb2 | |||
| 3ad07d05dd | |||
| 50c1215676 | |||
| bf5879d408 | |||
| 442e82e994 | |||
| 9e96999f02 | |||
| e983693534 | |||
| b8a14a2229 | |||
| 508f83dfad | |||
| ce8d5f92b1 | |||
| ca32ff39b8 | |||
| 9da53e51f0 | |||
| 2cbac7c4be | |||
| d2394465d7 | |||
| c2615ac24d | |||
| 62e21f1f94 | |||
| 9bd95a8b17 | |||
| bfd2eed97b | |||
| 8a0b964add | |||
| 59bab51090 | |||
| 12fa550b60 | |||
| 85cdf75a5b | |||
| 0ee2b5b034 | |||
| 0c4d140bb9 | |||
| f515d74a4d | |||
| 79835b3439 | |||
| d518b76878 | |||
| 5179db40db | |||
| 9cbeecae64 | |||
| c5af81c94f | |||
| 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 | |||
| 251c9aaae6 | |||
| 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 |
@@ -18,14 +18,14 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
- name: Create bump and changelog
|
||||
uses: commitizen-tools/commitizen-action@0.24.0
|
||||
uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 # 0.24.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -2,7 +2,6 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
@@ -34,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'
|
||||
@@ -45,7 +44,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
+19
-15
@@ -6,24 +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@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # 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 dependencies
|
||||
run: |
|
||||
sudo apt update -y && sudo apt install -y pipx
|
||||
pipx install uv
|
||||
uv sync --locked
|
||||
env:
|
||||
DEBIAN_FRONTEND: "noninteractive"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -41,10 +49,6 @@ jobs:
|
||||
done
|
||||
echo "Service is ready (returned 401)."
|
||||
|
||||
- name: Install notes app
|
||||
run: |
|
||||
docker compose exec app php occ app:enable notes
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
repos:
|
||||
- hooks:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
|
||||
@@ -1,3 +1,74 @@
|
||||
## v0.6.0 (2025-07-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
|
||||
### Refactor
|
||||
|
||||
- **calendar**: optimize logging for production readiness
|
||||
|
||||
## v0.5.0 (2025-07-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
|
||||
## v0.4.1 (2025-07-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
|
||||
## 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
|
||||
|
||||
+3
-7
@@ -1,13 +1,9 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.7.8-python3.11-alpine@sha256:e7a2eb4196da4b1cc8c746c3fd7209b8c3682aeb679b87e63382c9e2000a9b29
|
||||
FROM ghcr.io/astral-sh/uv:0.8.3-python3.11-alpine@sha256:886c19178558b951bbb9cb242deb94e7e37f9cba5d0dc018cd210ccd6b5116db
|
||||
|
||||
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,24 +6,197 @@ 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_append_content`: Append content to an existing note with a clear separator.
|
||||
* `nc_notes_delete_note`: Delete a note by ID.
|
||||
* `nc_notes_search_notes`: Search notes by title or content.
|
||||
* `nc_get_note`: Get a specific note by ID.
|
||||
| App | Support Status | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
|
||||
|
||||
### 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 |
|
||||
|
||||
### Calendar Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_calendar_list_calendars` | List all available calendars for the user |
|
||||
| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) |
|
||||
| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) |
|
||||
| `nc_calendar_get_event` | Get detailed information about a specific event |
|
||||
| `nc_calendar_update_event` | Update any aspect of an existing event |
|
||||
| `nc_calendar_delete_event` | Delete a calendar event |
|
||||
| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults |
|
||||
| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days |
|
||||
| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection |
|
||||
| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria |
|
||||
| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties |
|
||||
|
||||
### 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 |
|
||||
|
||||
### WebDAV File System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
|
||||
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
|
||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||
| `nc_webdav_create_directory` | Create new directories |
|
||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||
|
||||
## 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 |
|
||||
|
||||
### WebDAV File System Access
|
||||
|
||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List files in root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# Browse a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
|
||||
# Read a text file
|
||||
content = await nc_webdav_read_file("Documents/readme.txt")
|
||||
|
||||
# Create a new directory
|
||||
await nc_webdav_create_directory("NewProject/docs")
|
||||
|
||||
# Write content to a file
|
||||
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
|
||||
|
||||
# Delete a file or directory
|
||||
await nc_webdav_delete_resource("old_file.txt")
|
||||
```
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
||||
|
||||
- List all available calendars
|
||||
- Create, read, update, and delete calendar events
|
||||
- Handle recurring events with RRULE support
|
||||
- Manage event reminders and notifications
|
||||
- Support all-day and timed events
|
||||
- Handle attendees and meeting invitations
|
||||
- Organize events with categories and priorities
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List available calendars
|
||||
calendars = await nc_calendar_list_calendars()
|
||||
|
||||
# Create a simple event
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="personal",
|
||||
title="Team Meeting",
|
||||
start_datetime="2025-07-28T14:00:00",
|
||||
end_datetime="2025-07-28T15:00:00",
|
||||
description="Weekly team sync",
|
||||
location="Conference Room A"
|
||||
)
|
||||
|
||||
# Create a recurring weekly meeting
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="work",
|
||||
title="Weekly Standup",
|
||||
start_datetime="2025-07-28T09:00:00",
|
||||
end_datetime="2025-07-28T09:30:00",
|
||||
recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY;BYDAY=MO"
|
||||
)
|
||||
|
||||
# Quick meeting creation
|
||||
await nc_calendar_create_meeting(
|
||||
title="Client Call",
|
||||
date="2025-07-28",
|
||||
time="15:00",
|
||||
duration_minutes=60,
|
||||
attendees="client@example.com,colleague@company.com"
|
||||
)
|
||||
|
||||
# Get upcoming events
|
||||
events = await nc_calendar_get_upcoming_events(days_ahead=7)
|
||||
|
||||
# Advanced search - find all meetings with 5+ attendees lasting 2+ hours
|
||||
long_meetings = await nc_calendar_list_events(
|
||||
calendar_name="", # Search all calendars
|
||||
search_all_calendars=True,
|
||||
start_date="2025-07-01",
|
||||
end_date="2025-07-31",
|
||||
min_attendees=5,
|
||||
min_duration_minutes=120,
|
||||
title_contains="meeting"
|
||||
)
|
||||
|
||||
# Find availability for a 1-hour meeting with specific attendees
|
||||
availability = await nc_calendar_find_availability(
|
||||
duration_minutes=60,
|
||||
attendees="sarah@company.com,mike@company.com",
|
||||
date_range_start="2025-07-28",
|
||||
date_range_end="2025-08-04",
|
||||
business_hours_only=True,
|
||||
exclude_weekends=True,
|
||||
preferred_times="09:00-12:00,14:00-17:00"
|
||||
)
|
||||
|
||||
# Bulk update all team meetings to new location
|
||||
bulk_result = await nc_calendar_bulk_operations(
|
||||
operation="update",
|
||||
title_contains="team meeting",
|
||||
start_date="2025-08-01",
|
||||
end_date="2025-08-31",
|
||||
new_location="Conference Room B",
|
||||
new_reminder_minutes=15
|
||||
)
|
||||
|
||||
# Create a new project calendar
|
||||
new_calendar = await nc_calendar_manage_calendar(
|
||||
action="create",
|
||||
calendar_name="project-alpha",
|
||||
display_name="Project Alpha Calendar",
|
||||
description="Calendar for Project Alpha team",
|
||||
color="#FF5722"
|
||||
)
|
||||
```
|
||||
|
||||
### Note Attachments
|
||||
|
||||
@@ -117,4 +290,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
|
||||
+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:c5c82ddc074b9236fda87127934a1da726ecac68e9de083fd0e0d11a7bedc077
|
||||
image: mariadb:lts@sha256:2bcbaec92bd9d4f6591bc8103d3a8e6d0512ee2235506e47a2e129d190444405
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:62b5498c91778f738f0efbf0a6fd5b434011235a3e7b5f2ed4a2c0c63bb1c786
|
||||
image: redis:alpine@sha256:25c0ae32c6c2301798579f5944af53729766a18eff5660bbef196fc2e6214a9c
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.5@sha256:3f71577339ef1db0d1900c8574853d11fa7100452bf24f0a06fae5d9ee019cb4
|
||||
image: nextcloud:31.0.7@sha256:31d564f5f9f43f2aed0633854a2abd39155f85aa156997f7252f5af908efa99b
|
||||
#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,566 +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_append_content(self, *, note_id: int, content: str):
|
||||
"""Append content to an existing note with a standard separator"""
|
||||
logger.info(f"Appending content to note {note_id}")
|
||||
|
||||
# Get current note
|
||||
current_note = self.notes_get_note(note_id=note_id)
|
||||
|
||||
# Use fixed separator for consistency
|
||||
separator = "\n---\n"
|
||||
|
||||
# Combine content
|
||||
existing_content = current_note.get("content", "")
|
||||
if existing_content:
|
||||
new_content = existing_content + separator + content
|
||||
else:
|
||||
new_content = content # No separator needed for empty notes
|
||||
|
||||
logger.info(f"Combining existing content ({len(existing_content)} chars) with new content ({len(content)} chars)")
|
||||
|
||||
# Update with combined content
|
||||
return self.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=current_note["etag"],
|
||||
content=new_content,
|
||||
title=None, # Keep existing title
|
||||
category=None # Keep existing category
|
||||
)
|
||||
|
||||
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.warning(
|
||||
"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.warning(
|
||||
"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.warning(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,85 @@
|
||||
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 .calendar import CalendarClient
|
||||
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)
|
||||
self.calendar = CalendarClient(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,977 @@
|
||||
"""CalDAV client for NextCloud calendar operations."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
from icalendar import Calendar, Event as ICalEvent, vRecur, Alarm
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarClient(BaseNextcloudClient):
|
||||
"""Client for NextCloud CalDAV calendar operations."""
|
||||
|
||||
def _get_caldav_base_path(self) -> str:
|
||||
"""Helper to get the base CalDAV path for calendars."""
|
||||
return f"/remote.php/dav/calendars/{self.username}"
|
||||
|
||||
def _get_principals_path(self) -> str:
|
||||
"""Helper to get the principals path for the user."""
|
||||
return f"/remote.php/dav/principals/users/{self.username}"
|
||||
|
||||
async def list_calendars(self) -> List[Dict[str, Any]]:
|
||||
"""List all available calendars for the user."""
|
||||
caldav_path = self._get_caldav_base_path()
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:resourcetype/>
|
||||
<c:calendar-description/>
|
||||
<cs:calendar-color/>
|
||||
<c:supported-calendar-component-set/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND", caldav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
calendars = []
|
||||
|
||||
for response_elem in root.findall(".//{DAV:}response"):
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
if not href_text.endswith("/"):
|
||||
continue # Skip non-calendar resources
|
||||
|
||||
# Extract calendar name from href
|
||||
calendar_name = href_text.rstrip("/").split("/")[-1]
|
||||
if not calendar_name or calendar_name == self.username:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Check if it's a calendar resource
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_calendar = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{urn:ietf:params:xml:ns:caldav}calendar")
|
||||
is not None
|
||||
)
|
||||
|
||||
if not is_calendar:
|
||||
continue
|
||||
|
||||
# Extract calendar properties
|
||||
displayname_elem = prop.find(".//{DAV:}displayname")
|
||||
displayname = (
|
||||
displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else calendar_name
|
||||
)
|
||||
|
||||
description_elem = prop.find(
|
||||
".//{urn:ietf:params:xml:ns:caldav}calendar-description"
|
||||
)
|
||||
description = (
|
||||
description_elem.text if description_elem is not None else ""
|
||||
)
|
||||
|
||||
color_elem = prop.find(
|
||||
".//{http://calendarserver.org/ns/}calendar-color"
|
||||
)
|
||||
color = color_elem.text if color_elem is not None else "#1976D2"
|
||||
|
||||
calendars.append(
|
||||
{
|
||||
"name": calendar_name,
|
||||
"display_name": displayname,
|
||||
"description": description,
|
||||
"color": color,
|
||||
"href": href_text,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(calendars)} calendars")
|
||||
return calendars
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
logger.warning(
|
||||
"Authentication failed for CalDAV - Calendar app may not be enabled for this user"
|
||||
)
|
||||
return []
|
||||
elif e.response.status_code == 404:
|
||||
logger.warning(
|
||||
"CalDAV endpoint not found - Calendar app may not be installed"
|
||||
)
|
||||
return []
|
||||
logger.error(f"HTTP error listing calendars: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing calendars: {e}")
|
||||
raise e
|
||||
|
||||
async def get_calendar_events(
|
||||
self,
|
||||
calendar_name: str,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar_path = f"{self._get_caldav_base_path()}/{calendar_name}/"
|
||||
|
||||
# Build time range filter if dates provided
|
||||
time_range_filter = ""
|
||||
if start_date or end_date:
|
||||
start_dt = start_date or "19700101T000000Z"
|
||||
end_dt = end_date or "20301231T235959Z"
|
||||
time_range_filter = f"""
|
||||
<c:time-range start="{start_dt}" end="{end_dt}"/>
|
||||
"""
|
||||
|
||||
report_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT">
|
||||
{time_range_filter}
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>"""
|
||||
|
||||
headers = {
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"REPORT", calendar_path, content=report_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response and extract events
|
||||
root = ET.fromstring(response.content)
|
||||
events = []
|
||||
|
||||
for response_elem in root.findall(".//{DAV:}response"):
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
calendar_data = prop.find(
|
||||
".//{urn:ietf:params:xml:ns:caldav}calendar-data"
|
||||
)
|
||||
etag_elem = prop.find(".//{DAV:}getetag")
|
||||
|
||||
if calendar_data is not None and calendar_data.text:
|
||||
event_data = self._parse_ical_event(calendar_data.text)
|
||||
if event_data:
|
||||
event_data["href"] = href.text
|
||||
event_data["etag"] = (
|
||||
etag_elem.text if etag_elem is not None else ""
|
||||
)
|
||||
events.append(event_data)
|
||||
|
||||
if len(events) >= limit:
|
||||
break
|
||||
|
||||
logger.debug(f"Found {len(events)} events")
|
||||
return events
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error getting calendar events: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting calendar events: {e}")
|
||||
raise e
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new calendar event with comprehensive features."""
|
||||
event_uid = str(uuid.uuid4())
|
||||
event_filename = f"{event_uid}.ics"
|
||||
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
|
||||
|
||||
# Create iCalendar event
|
||||
ical_content = self._create_ical_event(event_data, event_uid)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"If-None-Match": "*", # Ensure we're creating, not updating
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._client.put(
|
||||
event_path, content=ical_content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Created event {event_uid}")
|
||||
return {
|
||||
"uid": event_uid,
|
||||
"href": event_path,
|
||||
"etag": response.headers.get("etag", ""),
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error creating event: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating event: {e}")
|
||||
raise e
|
||||
|
||||
async def update_event(
|
||||
self,
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
event_data: Dict[str, Any],
|
||||
etag: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing calendar event."""
|
||||
event_filename = f"{event_uid}.ics"
|
||||
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
|
||||
|
||||
# Get existing event data to merge with updates
|
||||
existing_event_data = {}
|
||||
if not etag:
|
||||
try:
|
||||
existing_event_data, current_etag = await self.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
etag = current_etag
|
||||
except Exception:
|
||||
# Continue without etag if we can't get it
|
||||
pass
|
||||
|
||||
# Merge existing data with new data (new data takes precedence)
|
||||
merged_data = {**existing_event_data, **event_data}
|
||||
|
||||
# Create updated iCalendar event
|
||||
ical_content = self._create_ical_event(merged_data, event_uid)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
|
||||
try:
|
||||
response = await self._client.put(
|
||||
event_path, content=ical_content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Updated event {event_uid}")
|
||||
return {
|
||||
"uid": event_uid,
|
||||
"href": event_path,
|
||||
"etag": response.headers.get("etag", ""),
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error updating event: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating event: {e}")
|
||||
raise e
|
||||
|
||||
async def delete_event(self, calendar_name: str, event_uid: str) -> Dict[str, Any]:
|
||||
"""Delete a calendar event."""
|
||||
event_filename = f"{event_uid}.ics"
|
||||
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
|
||||
|
||||
try:
|
||||
response = await self._client.delete(event_path)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Deleted event {event_uid}")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Event {event_uid} not found")
|
||||
return {"status_code": 404}
|
||||
logger.error(f"HTTP error deleting event: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting event: {e}")
|
||||
raise e
|
||||
|
||||
async def get_event(
|
||||
self, calendar_name: str, event_uid: str
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""Get detailed information about a specific event."""
|
||||
event_filename = f"{event_uid}.ics"
|
||||
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
|
||||
|
||||
headers = {"Accept": "text/calendar"}
|
||||
|
||||
try:
|
||||
response = await self._client.get(event_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
etag = response.headers.get("etag", "")
|
||||
event_data = self._parse_ical_event(response.text)
|
||||
|
||||
if not event_data:
|
||||
raise ValueError(f"Failed to parse event data for {event_uid}")
|
||||
|
||||
event_data["href"] = event_path
|
||||
event_data["etag"] = etag
|
||||
|
||||
logger.debug(f"Retrieved event {event_uid}")
|
||||
return event_data, etag
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error getting event: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting event: {e}")
|
||||
raise e
|
||||
|
||||
def _create_ical_event(self, event_data: Dict[str, Any], event_uid: str) -> str:
|
||||
"""Create iCalendar content from event data."""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//NextCloud MCP Server//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
event = ICalEvent()
|
||||
event.add("uid", event_uid)
|
||||
event.add("summary", event_data.get("title", ""))
|
||||
event.add("description", event_data.get("description", ""))
|
||||
event.add("location", event_data.get("location", ""))
|
||||
|
||||
# Handle dates/times
|
||||
start_str = event_data.get("start_datetime", "")
|
||||
end_str = event_data.get("end_datetime", "")
|
||||
all_day = event_data.get("all_day", False)
|
||||
|
||||
if start_str: # Only parse if start_datetime is provided
|
||||
if all_day:
|
||||
start_date = datetime.fromisoformat(start_str.split("T")[0]).date()
|
||||
event.add("dtstart", start_date)
|
||||
if end_str:
|
||||
end_date = datetime.fromisoformat(end_str.split("T")[0]).date()
|
||||
event.add("dtend", end_date)
|
||||
else:
|
||||
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||
event.add("dtstart", start_dt)
|
||||
if end_str:
|
||||
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||
event.add("dtend", end_dt)
|
||||
|
||||
# Add categories
|
||||
categories = event_data.get("categories", "")
|
||||
if categories:
|
||||
event.add("categories", categories.split(","))
|
||||
|
||||
# Add priority and status
|
||||
priority = event_data.get("priority", 5)
|
||||
event.add("priority", priority)
|
||||
|
||||
status = event_data.get("status", "CONFIRMED")
|
||||
event.add("status", status)
|
||||
|
||||
# Add privacy classification
|
||||
privacy = event_data.get("privacy", "PUBLIC")
|
||||
event.add("class", privacy)
|
||||
|
||||
# Add URL
|
||||
url = event_data.get("url", "")
|
||||
if url:
|
||||
event.add("url", url)
|
||||
|
||||
# Handle recurrence
|
||||
recurring = event_data.get("recurring", False)
|
||||
if recurring:
|
||||
recurrence_rule = event_data.get("recurrence_rule", "")
|
||||
if recurrence_rule:
|
||||
event.add("rrule", vRecur.from_ical(recurrence_rule))
|
||||
|
||||
# Add alarms/reminders
|
||||
reminder_minutes = event_data.get("reminder_minutes", 0)
|
||||
if reminder_minutes > 0:
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Event reminder")
|
||||
alarm.add("trigger", timedelta(minutes=-reminder_minutes))
|
||||
event.add_component(alarm)
|
||||
|
||||
# Add attendees
|
||||
attendees = event_data.get("attendees", "")
|
||||
if attendees:
|
||||
for email in attendees.split(","):
|
||||
if email.strip():
|
||||
event.add("attendee", f"mailto:{email.strip()}")
|
||||
|
||||
# Add timestamps
|
||||
now = datetime.utcnow()
|
||||
event.add("created", now)
|
||||
event.add("dtstamp", now)
|
||||
event.add("last-modified", now)
|
||||
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract event data."""
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, date) and not isinstance(
|
||||
dtstart.dt, datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, date) and not isinstance(
|
||||
dtend.dt, datetime
|
||||
):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(
|
||||
str(a).replace("mailto:", "") for a in attendee
|
||||
)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar: {e}")
|
||||
return None
|
||||
|
||||
def _extract_categories(self, categories_obj) -> str:
|
||||
"""Extract categories from icalendar object to string."""
|
||||
if not categories_obj:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Handle icalendar vCategory objects
|
||||
if hasattr(categories_obj, "cats"):
|
||||
# vCategory object has a 'cats' attribute that's a list
|
||||
return ", ".join(str(cat) for cat in categories_obj.cats)
|
||||
elif hasattr(categories_obj, "__iter__") and not isinstance(
|
||||
categories_obj, str
|
||||
):
|
||||
# Handle lists or other iterables
|
||||
return ", ".join(str(cat) for cat in categories_obj)
|
||||
else:
|
||||
# Handle strings or other objects
|
||||
return str(categories_obj)
|
||||
except Exception:
|
||||
# Fallback to string conversion
|
||||
return str(categories_obj)
|
||||
|
||||
async def search_events_across_calendars(
|
||||
self,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search events across all calendars with advanced filtering."""
|
||||
try:
|
||||
calendars = await self.list_calendars()
|
||||
all_events = []
|
||||
|
||||
for calendar in calendars:
|
||||
try:
|
||||
events = await self.get_calendar_events(
|
||||
calendar["name"], start_date, end_date
|
||||
)
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = self._apply_event_filters(events, filters)
|
||||
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar.get(
|
||||
"display_name", calendar["name"]
|
||||
)
|
||||
|
||||
all_events.extend(events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return all_events
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching events across calendars: {e}")
|
||||
raise
|
||||
|
||||
def _apply_event_filters(
|
||||
self, events: List[Dict[str, Any]], filters: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Apply advanced filters to event list."""
|
||||
filtered_events = []
|
||||
|
||||
for event in events:
|
||||
# Skip if event doesn't match filters
|
||||
if not self._event_matches_filters(event, filters):
|
||||
continue
|
||||
filtered_events.append(event)
|
||||
|
||||
return filtered_events
|
||||
|
||||
def _event_matches_filters(
|
||||
self, event: Dict[str, Any], filters: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""Check if an event matches the provided filters."""
|
||||
try:
|
||||
# Filter by minimum attendees
|
||||
if "min_attendees" in filters:
|
||||
attendees = event.get("attendees", "")
|
||||
attendee_count = len(attendees.split(",")) if attendees else 0
|
||||
if attendee_count < filters["min_attendees"]:
|
||||
return False
|
||||
|
||||
# Filter by minimum duration
|
||||
if "min_duration_minutes" in filters:
|
||||
start_str = event.get("start_datetime", "")
|
||||
end_str = event.get("end_datetime", "")
|
||||
if start_str and end_str:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(
|
||||
start_str.replace("Z", "+00:00")
|
||||
)
|
||||
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||
duration_minutes = (end_dt - start_dt).total_seconds() / 60
|
||||
if duration_minutes < filters["min_duration_minutes"]:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filter by categories
|
||||
if "categories" in filters:
|
||||
event_categories = event.get("categories", "").lower()
|
||||
required_categories = [cat.lower() for cat in filters["categories"]]
|
||||
if not any(cat in event_categories for cat in required_categories):
|
||||
return False
|
||||
|
||||
# Filter by status
|
||||
if "status" in filters:
|
||||
if event.get("status", "").upper() != filters["status"].upper():
|
||||
return False
|
||||
|
||||
# Filter by title contains
|
||||
if "title_contains" in filters:
|
||||
title = event.get("title", "").lower()
|
||||
search_term = filters["title_contains"].lower()
|
||||
if search_term not in title:
|
||||
return False
|
||||
|
||||
# Filter by location contains
|
||||
if "location_contains" in filters:
|
||||
location = event.get("location", "").lower()
|
||||
search_term = filters["location_contains"].lower()
|
||||
if search_term not in location:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
# If filtering fails, include the event
|
||||
return True
|
||||
|
||||
async def find_availability(
|
||||
self,
|
||||
duration_minutes: int,
|
||||
attendees: Optional[List[str]] = None,
|
||||
date_range_start: str = "",
|
||||
date_range_end: str = "",
|
||||
constraints: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find available time slots for scheduling."""
|
||||
try:
|
||||
# Set default date range if not provided
|
||||
if not date_range_start:
|
||||
date_range_start = datetime.now().strftime("%Y-%m-%d")
|
||||
if not date_range_end:
|
||||
end_date = datetime.now() + timedelta(days=7)
|
||||
date_range_end = end_date.strftime("%Y-%m-%d")
|
||||
|
||||
# Get all events in the date range
|
||||
busy_events = await self.search_events_across_calendars(
|
||||
start_date=date_range_start, end_date=date_range_end
|
||||
)
|
||||
|
||||
# Filter events for relevant attendees if specified
|
||||
if attendees:
|
||||
relevant_events = []
|
||||
for event in busy_events:
|
||||
event_attendees = event.get("attendees", "").lower()
|
||||
if any(
|
||||
attendee.lower() in event_attendees for attendee in attendees
|
||||
):
|
||||
relevant_events.append(event)
|
||||
busy_events = relevant_events
|
||||
|
||||
# Apply constraints
|
||||
constraints = constraints or {}
|
||||
business_hours_only = constraints.get("business_hours_only", False)
|
||||
exclude_weekends = constraints.get("exclude_weekends", False)
|
||||
preferred_times = constraints.get("preferred_times", [])
|
||||
|
||||
# Generate time slots
|
||||
available_slots = self._generate_available_slots(
|
||||
busy_events,
|
||||
duration_minutes,
|
||||
date_range_start,
|
||||
date_range_end,
|
||||
business_hours_only,
|
||||
exclude_weekends,
|
||||
preferred_times,
|
||||
)
|
||||
|
||||
return available_slots
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding availability: {e}")
|
||||
raise
|
||||
|
||||
def _generate_available_slots(
|
||||
self,
|
||||
busy_events: List[Dict[str, Any]],
|
||||
duration_minutes: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
business_hours_only: bool,
|
||||
exclude_weekends: bool,
|
||||
preferred_times: List[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate available time slots."""
|
||||
available_slots = []
|
||||
|
||||
try:
|
||||
current_date = datetime.fromisoformat(start_date)
|
||||
end_date_dt = datetime.fromisoformat(end_date)
|
||||
|
||||
while current_date <= end_date_dt:
|
||||
# Skip weekends if requested
|
||||
if exclude_weekends and current_date.weekday() >= 5:
|
||||
current_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
# Generate slots for this day
|
||||
day_slots = self._generate_day_slots(
|
||||
current_date,
|
||||
busy_events,
|
||||
duration_minutes,
|
||||
business_hours_only,
|
||||
preferred_times,
|
||||
)
|
||||
available_slots.extend(day_slots)
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return available_slots[:10] # Limit to 10 slots
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating available slots: {e}")
|
||||
return []
|
||||
|
||||
def _generate_day_slots(
|
||||
self,
|
||||
date: datetime,
|
||||
busy_events: List[Dict[str, Any]],
|
||||
duration_minutes: int,
|
||||
business_hours_only: bool,
|
||||
preferred_times: List[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate available slots for a specific day."""
|
||||
slots = []
|
||||
|
||||
try:
|
||||
# Define working hours
|
||||
if business_hours_only:
|
||||
start_hour, end_hour = 9, 17
|
||||
else:
|
||||
start_hour, end_hour = 8, 20
|
||||
|
||||
# Get busy periods for this day
|
||||
day_busy_periods = []
|
||||
for event in busy_events:
|
||||
try:
|
||||
event_start = datetime.fromisoformat(
|
||||
event["start_datetime"].replace("Z", "+00:00")
|
||||
)
|
||||
event_end = datetime.fromisoformat(
|
||||
event["end_datetime"].replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
# Check if event is on this day
|
||||
if event_start.date() == date.date():
|
||||
day_busy_periods.append((event_start.time(), event_end.time()))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort busy periods
|
||||
day_busy_periods.sort()
|
||||
|
||||
# Generate potential slots
|
||||
current_time = date.replace(
|
||||
hour=start_hour, minute=0, second=0, microsecond=0
|
||||
)
|
||||
end_time = date.replace(hour=end_hour, minute=0, second=0, microsecond=0)
|
||||
slot_duration = timedelta(minutes=duration_minutes)
|
||||
|
||||
while current_time + slot_duration <= end_time:
|
||||
slot_end = current_time + slot_duration
|
||||
|
||||
# Check if slot conflicts with any busy period
|
||||
if not self._slot_conflicts(
|
||||
current_time.time(), slot_end.time(), day_busy_periods
|
||||
):
|
||||
# Check preferred times if specified
|
||||
if not preferred_times or self._slot_in_preferred_times(
|
||||
current_time.time(), preferred_times
|
||||
):
|
||||
slots.append(
|
||||
{
|
||||
"start_datetime": current_time.isoformat(),
|
||||
"end_datetime": slot_end.isoformat(),
|
||||
"duration_minutes": duration_minutes,
|
||||
"date": date.date().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
current_time += timedelta(minutes=30) # 30-minute increments
|
||||
|
||||
return slots
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating day slots: {e}")
|
||||
return []
|
||||
|
||||
def _slot_conflicts(self, slot_start, slot_end, busy_periods):
|
||||
"""Check if a time slot conflicts with busy periods."""
|
||||
for busy_start, busy_end in busy_periods:
|
||||
if slot_start < busy_end and slot_end > busy_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _slot_in_preferred_times(self, slot_start, preferred_times):
|
||||
"""Check if slot falls within preferred time ranges."""
|
||||
if not preferred_times:
|
||||
return True
|
||||
|
||||
for time_range in preferred_times:
|
||||
try:
|
||||
start_str, end_str = time_range.split("-")
|
||||
pref_start = datetime.strptime(start_str, "%H:%M").time()
|
||||
pref_end = datetime.strptime(end_str, "%H:%M").time()
|
||||
|
||||
if pref_start <= slot_start <= pref_end:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
async def bulk_update_events(
|
||||
self, filter_criteria: Dict[str, Any], update_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Bulk update events matching filter criteria."""
|
||||
try:
|
||||
# Find events matching criteria
|
||||
events = await self.search_events_across_calendars(
|
||||
start_date=filter_criteria.get("start_date", ""),
|
||||
end_date=filter_criteria.get("end_date", ""),
|
||||
filters=filter_criteria,
|
||||
)
|
||||
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Update the event
|
||||
await self.update_event(
|
||||
event["calendar_name"], event["uid"], update_data
|
||||
)
|
||||
updated_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "updated",
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"total_found": len(events),
|
||||
"updated_count": updated_count,
|
||||
"failed_count": failed_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk update: {e}")
|
||||
raise
|
||||
|
||||
async def create_calendar(
|
||||
self,
|
||||
calendar_name: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
color: str = "#1976D2",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new calendar."""
|
||||
try:
|
||||
# Calendar creation via CalDAV MKCALENDAR
|
||||
calendar_path = f"{self._get_caldav_base_path()}/{calendar_name}/"
|
||||
|
||||
# Create MKCALENDAR body
|
||||
mkcol_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcalendar xmlns="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>{display_name or calendar_name}</d:displayname>
|
||||
<cs:calendar-color>{color}</cs:calendar-color>
|
||||
<caldav:calendar-description xmlns:caldav="urn:ietf:params:xml:ns:caldav">{description}</caldav:calendar-description>
|
||||
<caldav:supported-calendar-component-set xmlns:caldav="urn:ietf:params:xml:ns:caldav">
|
||||
<caldav:comp name="VEVENT"/>
|
||||
</caldav:supported-calendar-component-set>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</mkcalendar>"""
|
||||
|
||||
headers = {"Content-Type": "application/xml", "Depth": "0"}
|
||||
|
||||
response = await self._client.request(
|
||||
"MKCALENDAR", calendar_path, content=mkcol_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Created calendar: {calendar_name}")
|
||||
return {
|
||||
"name": calendar_name,
|
||||
"display_name": display_name or calendar_name,
|
||||
"description": description,
|
||||
"color": color,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating calendar {calendar_name}: {e}")
|
||||
raise
|
||||
|
||||
async def delete_calendar(self, calendar_name: str) -> Dict[str, Any]:
|
||||
"""Delete a calendar."""
|
||||
try:
|
||||
calendar_path = f"{self._get_caldav_base_path()}/{calendar_name}/"
|
||||
|
||||
response = await self._client.delete(calendar_path)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Deleted calendar: {calendar_name}")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting calendar {calendar_name}: {e}")
|
||||
raise
|
||||
@@ -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,417 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Tuple, Dict, Any, Optional, List
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
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.debug(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.debug(
|
||||
f"Resource exists check status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{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.debug(f"Successfully deleted WebDAV resource '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' not found, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
else:
|
||||
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting WebDAV resource '{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.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.debug(f"Cleanup 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.debug(
|
||||
f"Cleaning up attachments for note {note_id} in category '{category}'"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed cleaning up attachments for note {note_id}: {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.debug(f"Uploading attachment '{filename}' for note {note_id}")
|
||||
|
||||
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"
|
||||
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")
|
||||
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()
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
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.error(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
|
||||
# Proceed with the PUT request
|
||||
response = await self._client.put(
|
||||
attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id}"
|
||||
)
|
||||
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.debug(f"Fetching attachment '{filename}' for note {note_id}")
|
||||
|
||||
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.debug(
|
||||
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
|
||||
else:
|
||||
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
|
||||
|
||||
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
|
||||
"""List files and directories in the specified path via WebDAV PROPFIND."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Listing directory: {path}")
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND", webdav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
items = []
|
||||
|
||||
# Skip the first response (the directory itself)
|
||||
responses = root.findall(".//{DAV:}response")[1:]
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory name from href
|
||||
href_text = href.text or ""
|
||||
name = href_text.rstrip("/").split("/")[-1]
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Determine if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Get other properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = (
|
||||
int(size_elem.text)
|
||||
if size_elem is not None and size_elem.text
|
||||
else 0
|
||||
)
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = (
|
||||
content_type_elem.text if content_type_elem is not None else None
|
||||
)
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": f"{path.rstrip('/')}/{name}" if path else name,
|
||||
"is_directory": is_directory,
|
||||
"size": size if not is_directory else None,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(items)} items in directory: {path}")
|
||||
return items
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
|
||||
async def read_file(self, path: str) -> Tuple[bytes, str]:
|
||||
"""Read a file's content via WebDAV GET."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Reading file: {path}")
|
||||
|
||||
try:
|
||||
response = await self._client.get(webdav_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
|
||||
return content, content_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error reading file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: bytes, content_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to a file via WebDAV PUT."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Writing file: {path}")
|
||||
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(path)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.put(
|
||||
webdav_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully wrote file '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error writing file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error writing file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def create_directory(
|
||||
self, path: str, recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a directory via WebDAV MKCOL."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Creating directory: {path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.request("MKCOL", webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully created directory '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# Method Not Allowed - directory already exists
|
||||
if e.response.status_code == 405:
|
||||
logger.debug(f"Directory '{path}' already exists")
|
||||
return {"status_code": 405, "message": "Directory already exists"}
|
||||
|
||||
# File Conflict - parent directory does not exist
|
||||
if e.response.status_code == 409 and recursive:
|
||||
# Extract parent directory path
|
||||
path_parts = path.strip("/").split("/")
|
||||
if len(path_parts) > 1:
|
||||
parent_dir = "/".join(path_parts[:-1])
|
||||
logger.debug(
|
||||
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
|
||||
)
|
||||
await self.create_directory(parent_dir, recursive)
|
||||
# Now try to create the original directory again
|
||||
return await self.create_directory(path, recursive)
|
||||
else:
|
||||
# This shouldn't happen for single-level directories under root
|
||||
logger.error(f"409 conflict for single-level directory '{path}'")
|
||||
raise e
|
||||
|
||||
logger.error(f"HTTP error creating directory '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating directory '{path}': {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
|
||||
+947
-31
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,15 +8,19 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.9,<1.10)",
|
||||
"mcp[cli] (>=1.10,<1.11)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)"
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
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"
|
||||
@@ -38,9 +42,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
|
||||
}
|
||||
|
||||
+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}")
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_calendar_name():
|
||||
"""Unique calendar name for testing."""
|
||||
return f"test_calendar_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
|
||||
"""Create a temporary calendar for testing and clean up afterward."""
|
||||
calendar_name = test_calendar_name
|
||||
|
||||
try:
|
||||
# Create a test calendar if possible
|
||||
# Note: Calendar creation might require admin permissions
|
||||
# For now, we'll use an existing calendar or create events in default calendar
|
||||
|
||||
# Try to find an existing calendar to use
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
if calendars:
|
||||
calendar_name = calendars[0]["name"]
|
||||
logger.info(f"Using existing calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
else:
|
||||
pytest.skip("No calendars available for testing")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up temporary calendar: {e}")
|
||||
pytest.skip(f"Calendar setup failed: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary event for testing and clean up afterward."""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create a test event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": f"Test Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test event created by integration tests",
|
||||
"location": "Test Location",
|
||||
"categories": "testing",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary event in calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result.get("uid")
|
||||
|
||||
if not event_uid:
|
||||
pytest.fail("Failed to create temporary event")
|
||||
|
||||
logger.info(f"Created temporary event with UID: {event_uid}")
|
||||
yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if event_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary event: {event_uid}")
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
logger.info(f"Successfully deleted temporary event: {event_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary event {event_uid}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary event {event_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_calendars(nc_client: NextcloudClient):
|
||||
"""Test listing available calendars."""
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
|
||||
assert isinstance(calendars, list)
|
||||
|
||||
if not calendars:
|
||||
pytest.skip("No calendars available - Calendar app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(calendars)} calendars")
|
||||
|
||||
# Check structure of calendars
|
||||
for calendar in calendars:
|
||||
assert "name" in calendar
|
||||
assert "display_name" in calendar
|
||||
assert "href" in calendar
|
||||
# Optional fields
|
||||
assert "description" in calendar
|
||||
assert "color" in calendar
|
||||
|
||||
logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}")
|
||||
|
||||
|
||||
async def test_create_and_delete_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and deleting a basic event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Integration Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Test event for integration testing",
|
||||
"location": "Test Room",
|
||||
"categories": "testing,integration",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
assert "uid" in result
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with UID: {event_uid}")
|
||||
|
||||
# Verify event was created by retrieving it
|
||||
retrieved_event, etag = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["uid"] == event_uid
|
||||
assert retrieved_event["title"] == "Integration Test Event"
|
||||
assert retrieved_event["location"] == "Test Room"
|
||||
|
||||
# Delete event
|
||||
delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_all_day_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an all-day event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "All Day Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%d"),
|
||||
"all_day": True,
|
||||
"description": "Test all-day event",
|
||||
"categories": "testing",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created all-day event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "All Day Test Event"
|
||||
assert retrieved_event.get("all_day") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"All-day event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_recurring_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating a recurring event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Weekly Recurring Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test recurring event",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||
"reminder_minutes": 30,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created recurring event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Weekly Recurring Test"
|
||||
assert retrieved_event.get("recurring") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recurring event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test listing events within a date range."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
|
||||
# Get events for the next week
|
||||
start_date = datetime.now().strftime("%Y%m%dT000000Z")
|
||||
end_date = (datetime.now() + timedelta(days=7)).strftime("%Y%m%dT235959Z")
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name, start_date=start_date, end_date=end_date, limit=50
|
||||
)
|
||||
|
||||
assert isinstance(events, list)
|
||||
logger.info(f"Found {len(events)} events in date range")
|
||||
|
||||
# Our temporary event should be in the list
|
||||
event_uids = [event.get("uid") for event in events]
|
||||
assert temporary_event["uid"] in event_uids
|
||||
|
||||
# Check event structure
|
||||
for event in events:
|
||||
assert "uid" in event
|
||||
assert "title" in event
|
||||
assert "start_datetime" in event
|
||||
|
||||
|
||||
async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test updating an existing event."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
event_uid = temporary_event["uid"]
|
||||
|
||||
# Update event data
|
||||
updated_data = {
|
||||
"title": "Updated Test Event Title",
|
||||
"description": "Updated description for test event",
|
||||
"location": "Updated Location",
|
||||
"priority": 1, # High priority
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.update_event(
|
||||
calendar_name, event_uid, updated_data
|
||||
)
|
||||
assert result["uid"] == event_uid
|
||||
|
||||
# Verify updates
|
||||
updated_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert updated_event["title"] == "Updated Test Event Title"
|
||||
assert updated_event["description"] == "Updated description for test event"
|
||||
assert updated_event["location"] == "Updated Location"
|
||||
assert updated_event["priority"] == 1
|
||||
|
||||
logger.info(f"Successfully updated event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event update test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with attendees."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Meeting with Attendees",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT16:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT17:00:00"),
|
||||
"description": "Test meeting with multiple attendees",
|
||||
"location": "Conference Room A",
|
||||
"attendees": "test1@example.com,test2@example.com",
|
||||
"reminder_minutes": 15,
|
||||
"status": "TENTATIVE",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with attendees, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Meeting with Attendees"
|
||||
assert "test1@example.com" in retrieved_event.get("attendees", "")
|
||||
assert retrieved_event["status"] == "TENTATIVE"
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with attendees test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_get_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test retrieving a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test deleting a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_event_with_url_and_categories(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with URL and multiple categories."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Event with URL and Categories",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT10:30:00"),
|
||||
"description": "Test event with additional metadata",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
"url": "https://zoom.us/j/123456789",
|
||||
"privacy": "PRIVATE",
|
||||
"priority": 2,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with metadata, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Event with URL and Categories"
|
||||
assert "work" in retrieved_event.get("categories", "")
|
||||
assert "important" in retrieved_event.get("categories", "")
|
||||
assert retrieved_event.get("url") == "https://zoom.us/j/123456789"
|
||||
assert retrieved_event.get("privacy") == "PRIVATE"
|
||||
assert retrieved_event.get("priority") == 2
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with metadata test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(nc_client: NextcloudClient):
|
||||
"""Test error handling for calendar operations."""
|
||||
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
@@ -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,15 +13,18 @@ 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"]
|
||||
@@ -29,7 +32,8 @@ def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: d
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
@@ -42,7 +46,7 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = nc_client.notes_update_note(
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
@@ -54,18 +58,23 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert updated_note["category"] == original_category # Verify category didn't change
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
time.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = nc_client.notes_get_note(note_id=note_id)
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
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,32 +95,40 @@ 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."
|
||||
)
|
||||
|
||||
def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
@@ -122,28 +139,28 @@ def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, t
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = nc_client.notes_append_content(
|
||||
note_id=note_id,
|
||||
content=append_text
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
|
||||
# Verify by reading the note again
|
||||
time.sleep(1) # Allow potential propagation delay
|
||||
read_note = nc_client.notes_get_note(note_id=note_id)
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
|
||||
def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
@@ -151,11 +168,11 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info(f"Creating empty note for append test")
|
||||
empty_note = nc_client.notes_create_note(
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
content="", # Empty content
|
||||
category=test_category
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
@@ -163,29 +180,31 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = nc_client.notes_append_content(
|
||||
note_id=note_id,
|
||||
content=append_text
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
time.sleep(1)
|
||||
read_note = nc_client.notes_get_note(note_id=note_id)
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
nc_client.notes_delete_note(note_id=note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, temporary_note: dict):
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
@@ -199,30 +218,31 @@ def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, tem
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = nc_client.notes_append_content(
|
||||
note_id=note_id,
|
||||
content=first_append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = nc_client.notes_append_content(
|
||||
note_id=note_id,
|
||||
content=second_append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
)
|
||||
|
||||
expected_content_after_second = expected_content_after_first + "\n---\n" + second_append
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
time.sleep(1)
|
||||
read_note = nc_client.notes_get_note(note_id=note_id)
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
|
||||
def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
@@ -230,11 +250,10 @@ def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
nc_client.notes_append_content(
|
||||
note_id=non_existent_id,
|
||||
content="This should fail"
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404.")
|
||||
|
||||
# --- Attachment tests moved to test_attachments.py ---
|
||||
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}")
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Integration tests for WebDAV operations."""
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
import uuid
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
|
||||
|
||||
async def test_create_and_delete_directory(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating and deleting directories."""
|
||||
test_dir = f"{test_base_path}/test_directory"
|
||||
|
||||
try:
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
dir_names = [item["name"] for item in parent_listing]
|
||||
assert "test_directory" in dir_names
|
||||
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test writing, reading, and deleting files."""
|
||||
test_file = f"{test_base_path}/test_file.txt"
|
||||
test_content = f"Test content {uuid.uuid4().hex}"
|
||||
|
||||
try:
|
||||
# Create base directory first
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write file
|
||||
write_result = await nc_client.webdav.write_file(
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
file_names = [item["name"] for item in listing]
|
||||
assert "test_file.txt" in file_names
|
||||
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_directory_empty_and_populated(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test listing empty and populated directories."""
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# List empty directory
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file1.txt", b"content1", content_type="text/plain"
|
||||
)
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file2.md",
|
||||
b"# Markdown content",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
# List populated directory
|
||||
populated_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert len(populated_listing) == 4 # 2 dirs + 2 files
|
||||
|
||||
# Check that we have both files and directories
|
||||
names = [item["name"] for item in populated_listing]
|
||||
assert "subdir1" in names
|
||||
assert "subdir2" in names
|
||||
assert "file1.txt" in names
|
||||
assert "file2.md" in names
|
||||
|
||||
# Check metadata is present
|
||||
for item in populated_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file1.txt")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file2.md")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
"""Test reading a file that doesn't exist."""
|
||||
nonexistent_file = f"nonexistent_{uuid.uuid4().hex}.txt"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
"""Test deleting a resource that doesn't exist."""
|
||||
nonexistent_resource = f"nonexistent_{uuid.uuid4().hex}"
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating nested directory structures."""
|
||||
nested_path = f"{test_base_path}/level1/level2/level3"
|
||||
|
||||
try:
|
||||
# Create nested directories (should create parent directories automatically)
|
||||
result = await nc_client.webdav.create_directory(nested_path, True)
|
||||
assert result["status_code"] == 201
|
||||
|
||||
# Verify the structure was created
|
||||
level1_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1"
|
||||
)
|
||||
assert len(level1_listing) == 1
|
||||
assert level1_listing[0]["name"] == "level2"
|
||||
assert level1_listing[0]["is_directory"] is True
|
||||
|
||||
level2_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1/level2"
|
||||
)
|
||||
assert len(level2_listing) == 1
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(nested_path)
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test overwriting an existing file."""
|
||||
test_file = f"{test_base_path}/overwrite_test.txt"
|
||||
original_content = "Original content"
|
||||
new_content = "New content after overwrite"
|
||||
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write original file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, original_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
|
||||
# Verify original content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == original_content
|
||||
|
||||
# Overwrite with new content
|
||||
overwrite_result = await nc_client.webdav.write_file(
|
||||
test_file, new_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert overwrite_result["status_code"] in [200, 204] # OK or No Content
|
||||
|
||||
# Verify new content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
"""Test listing the root directory."""
|
||||
root_listing = await nc_client.webdav.list_directory("")
|
||||
|
||||
# Root directory should exist and be listable
|
||||
assert isinstance(root_listing, list)
|
||||
# Should have at least some default folders/files
|
||||
assert len(root_listing) >= 0
|
||||
|
||||
# Check structure of items
|
||||
for item in root_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
@@ -44,31 +44,12 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
name = "attrs"
|
||||
version = "25.3.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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -298,6 +279,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "6.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -374,6 +368,33 @@ 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 = "jsonschema"
|
||||
version = "4.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -448,12 +469,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -461,9 +483,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/d90e42be23a7e6dd35c03e35c7c63fe1036f082d3bb88114b66bd0f2467e/mcp-1.10.0.tar.gz", hash = "sha256:91fb1623c3faf14577623d14755d3213db837c5da5dae85069e1b59124cbe0e9", size = 392961, upload-time = "2025-06-26T13:51:19.025Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/52/e1c43c4b5153465fd5d3b4b41bf2d4c7731475e9f668f38d68f848c25c9a/mcp-1.10.0-py3-none-any.whl", hash = "sha256:925c45482d75b1b6f11febddf9736d55edf7739c7ea39b583309f6651cbc9e5c", size = 150894, upload-time = "2025-06-26T13:51:17.342Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -481,48 +503,43 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[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.9,<1.10" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.10,<1.11" },
|
||||
{ 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]]
|
||||
@@ -543,15 +560,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
@@ -623,15 +631,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -789,6 +788,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"
|
||||
@@ -802,6 +813,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
@@ -867,6 +890,20 @@ 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 = "referencing"
|
||||
version = "0.36.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
@@ -880,6 +917,105 @@ 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 = "rpds-py"
|
||||
version = "0.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/da/323848a2b62abe6a0fec16ebe199dc6889c5d0a332458da8985b2980dffe/rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559", size = 364498, upload-time = "2025-05-21T12:43:54.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b4/4d3820f731c80fd0cd823b3e95b9963fec681ae45ba35b5281a42382c67d/rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1", size = 350083, upload-time = "2025-05-21T12:43:56.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b1/3a8ee1c9d480e8493619a437dec685d005f706b69253286f50f498cbdbcf/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c", size = 389023, upload-time = "2025-05-21T12:43:57.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/31/17293edcfc934dc62c3bf74a0cb449ecd549531f956b72287203e6880b87/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb", size = 403283, upload-time = "2025-05-21T12:43:59.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ca/e0f0bc1a75a8925024f343258c8ecbd8828f8997ea2ac71e02f67b6f5299/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40", size = 524634, upload-time = "2025-05-21T12:44:01.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/5d0be919037178fff33a6672ffc0afa04ea1cfcb61afd4119d1b5280ff0f/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79", size = 416233, upload-time = "2025-05-21T12:44:02.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7c/8abb70f9017a231c6c961a8941403ed6557664c0913e1bf413cbdc039e75/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325", size = 390375, upload-time = "2025-05-21T12:44:04.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ac/a87f339f0e066b9535074a9f403b9313fd3892d4a164d5d5f5875ac9f29f/rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295", size = 424537, upload-time = "2025-05-21T12:44:06.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8f/8d5c1567eaf8c8afe98a838dd24de5013ce6e8f53a01bd47fe8bb06b5533/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b", size = 566425, upload-time = "2025-05-21T12:44:08.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/33/03016a6be5663b389c8ab0bbbcca68d9e96af14faeff0a04affcb587e776/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98", size = 595197, upload-time = "2025-05-21T12:44:10.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8d/da9f4d3e208c82fda311bff0cf0a19579afceb77cf456e46c559a1c075ba/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd", size = 561244, upload-time = "2025-05-21T12:44:12.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b3/39d5dcf7c5f742ecd6dbc88f6f84ae54184b92f5f387a4053be2107b17f1/rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31", size = 222254, upload-time = "2025-05-21T12:44:14.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/19/2d6772c8eeb8302c5f834e6d0dfd83935a884e7c5ce16340c7eaf89ce925/rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500", size = 234741, upload-time = "2025-05-21T12:44:16.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/145ada26cfaf86018d0eb304fe55eafdd4f0b6b84530246bb4a7c4fb5c4b/rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5", size = 224830, upload-time = "2025-05-21T12:44:17.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ca/d435844829c384fd2c22754ff65889c5c556a675d2ed9eb0e148435c6690/rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129", size = 359668, upload-time = "2025-05-21T12:44:19.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/b056f21db3a09f89410d493d2f6614d87bb162499f98b649d1dbd2a81988/rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d", size = 345649, upload-time = "2025-05-21T12:44:20.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0f/e0d00dc991e3d40e03ca36383b44995126c36b3eafa0ccbbd19664709c88/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72", size = 384776, upload-time = "2025-05-21T12:44:22.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a2/59374837f105f2ca79bde3c3cd1065b2f8c01678900924949f6392eab66d/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34", size = 395131, upload-time = "2025-05-21T12:44:24.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/dc/48e8d84887627a0fe0bac53f0b4631e90976fd5d35fff8be66b8e4f3916b/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9", size = 520942, upload-time = "2025-05-21T12:44:25.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f5/ee056966aeae401913d37befeeab57a4a43a4f00099e0a20297f17b8f00c/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5", size = 411330, upload-time = "2025-05-21T12:44:27.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/74/b2cffb46a097cefe5d17f94ede7a174184b9d158a0aeb195f39f2c0361e8/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194", size = 387339, upload-time = "2025-05-21T12:44:29.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9a/0ff0b375dcb5161c2b7054e7d0b7575f1680127505945f5cabaac890bc07/rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6", size = 418077, upload-time = "2025-05-21T12:44:30.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/a1/fda629bf20d6b698ae84c7c840cfb0e9e4200f664fc96e1f456f00e4ad6e/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78", size = 562441, upload-time = "2025-05-21T12:44:32.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/15/ce4b5257f654132f326f4acd87268e1006cc071e2c59794c5bdf4bebbb51/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72", size = 590750, upload-time = "2025-05-21T12:44:34.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/ab/e04bf58a8d375aeedb5268edcc835c6a660ebf79d4384d8e0889439448b0/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66", size = 558891, upload-time = "2025-05-21T12:44:37.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/82/cb8c6028a6ef6cd2b7991e2e4ced01c854b6236ecf51e81b64b569c43d73/rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523", size = 218718, upload-time = "2025-05-21T12:44:38.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218, upload-time = "2025-05-21T12:44:40.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -889,6 +1025,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -1039,6 +1184,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.2"
|
||||
|
||||
Reference in New Issue
Block a user