Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48744e8a6c | |||
| 63b898c0e3 | |||
| e8f1340133 | |||
| fde68dac55 | |||
| 460e2e190c | |||
| 989b6de3c0 | |||
| aa0b6dc5dd | |||
| 7ae78d3a39 | |||
| 54326f9c64 | |||
| 6ba87e7e05 | |||
| 45bbf97033 | |||
| 14a0f166fe | |||
| 71f09a47ca | |||
| 61bb8cc048 | |||
| ad9b9f25a1 | |||
| f4dd68735c | |||
| c75f0c0a17 | |||
| a143123acc | |||
| 1dc2ddfdb7 | |||
| 92e18825bc | |||
| d398a8c8e6 | |||
| 39dfa13895 | |||
| cb7a609ec2 | |||
| b8d241b596 | |||
| 5395f8d3d6 | |||
| 198d7495f0 | |||
| c2f6c6ce0d | |||
| 5757f2582b | |||
| d5e6411c45 | |||
| f0c03ceede | |||
| 7818eb104e | |||
| b72514bb32 | |||
| f51d3a2101 | |||
| 5de4055f9f | |||
| 95da43ea0f | |||
| ae47c5f3e6 | |||
| 31ffeba69b | |||
| 963a504ae2 | |||
| ead298c132 | |||
| 2f805e54b7 | |||
| 6158a890af | |||
| 240ceb3808 | |||
| 1459fe9bc8 | |||
| 37164dbdbc | |||
| c3ff92a8c1 | |||
| 371d0c93a5 | |||
| 644c59bf78 | |||
| 056b6fc9d6 | |||
| 83917b3786 | |||
| 955ad78f13 | |||
| 3f04449a86 | |||
| 144a54c1ad | |||
| 90b4b2a038 | |||
| cdfab26c75 | |||
| a389f2940e | |||
| 5e829fc7e7 | |||
| 9c909b6e42 | |||
| 9b29eabfaa | |||
| 7549c988f4 | |||
| 0145be4bbd | |||
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e |
@@ -0,0 +1,29 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
pypi:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Environment and permissions trusted publishing.
|
||||
environment:
|
||||
# Create this environment in the GitHub repository under Settings -> Environments
|
||||
name: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
run: uv build
|
||||
- name: Publish
|
||||
run: uv publish
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -33,11 +33,11 @@ jobs:
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install firefox --with-deps
|
||||
uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --browser firefox
|
||||
uv run pytest -v --log-level=INFO
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ __pycache__/
|
||||
.env.*.local
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_shared_test_client.json
|
||||
.nextcloud_oauth_*.json
|
||||
|
||||
@@ -1,3 +1,47 @@
|
||||
## v0.17.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **caldav**: Add support for tasks
|
||||
|
||||
### Fix
|
||||
|
||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate from internal CalendarClient to caldav library
|
||||
|
||||
## v0.16.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **webdav**: Add search and list favorite response tools
|
||||
|
||||
### Perf
|
||||
|
||||
- **notes**: Improve notes search performance using async iterators
|
||||
|
||||
## v0.15.2 (2025-10-17)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Unify logging & remove factory deployment
|
||||
|
||||
## v0.15.1 (2025-10-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- Increase HTTP client timeout to 30s
|
||||
- Handle RequestError in mcp tools
|
||||
|
||||
## v0.15.0 (2025-10-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||
|
||||
## v0.14.3 (2025-10-17)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -19,6 +19,53 @@ uv run pytest --cov
|
||||
uv run pytest -m "not integration"
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
```bash
|
||||
# Run benchmark with default settings (10 workers, 30 seconds)
|
||||
uv run python -m tests.load.benchmark
|
||||
|
||||
# Quick test with custom concurrency and duration
|
||||
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
|
||||
|
||||
# Extended load test (50 workers for 5 minutes)
|
||||
uv run python -m tests.load.benchmark -c 50 -d 300
|
||||
|
||||
# Export results to JSON for analysis
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||
|
||||
# Test OAuth server on port 8001
|
||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||
|
||||
# Verbose mode with detailed logging
|
||||
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
|
||||
```
|
||||
|
||||
**Load Testing Features:**
|
||||
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
|
||||
- **Real-time progress** bar with live RPS and error counts
|
||||
- **Detailed metrics**:
|
||||
- Throughput (requests/second)
|
||||
- Latency percentiles (p50, p90, p95, p99)
|
||||
- Per-operation breakdown
|
||||
- Error rates and types
|
||||
- **Automatic cleanup** of test data
|
||||
- **JSON export** for CI/CD integration
|
||||
- **Server health checks** before starting
|
||||
|
||||
**Understanding Results:**
|
||||
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
|
||||
- **Latency**:
|
||||
- p50 (median): Should be <100ms for most operations
|
||||
- p95: Should be <500ms
|
||||
- p99: Should be <1000ms
|
||||
- **Error Rate**: Should be <1% under normal load
|
||||
|
||||
**Common Bottlenecks:**
|
||||
1. Nextcloud backend API response times (most common)
|
||||
2. Database connection limits
|
||||
3. HTTP client connection pooling
|
||||
4. Network I/O between containers
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format and lint code
|
||||
@@ -89,7 +136,17 @@ Each Nextcloud app has a corresponding server module that:
|
||||
### Supported Nextcloud Apps
|
||||
|
||||
- **Notes** - Full CRUD operations and search
|
||||
- **Calendar** - CalDAV integration with events, recurring events, attendees
|
||||
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
|
||||
- **Calendar Operations**: List, create, delete calendars
|
||||
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
|
||||
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
|
||||
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
- Priority levels (0-9, 1=highest, 9=lowest)
|
||||
- Due dates, start dates, completion tracking
|
||||
- Percent complete (0-100%)
|
||||
- Categories and filtering
|
||||
- Search across all calendars
|
||||
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
|
||||
- **Contacts** - CardDAV integration with address book operations
|
||||
- **Tables** - Row-level operations on Nextcloud Tables
|
||||
- **WebDAV** - Complete file system access
|
||||
@@ -102,9 +159,27 @@ Each Nextcloud app has a corresponding server module that:
|
||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||
|
||||
### MCP Response Patterns
|
||||
|
||||
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
|
||||
|
||||
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
|
||||
|
||||
**Pattern:**
|
||||
1. Client methods return `List[Dict]` (raw data)
|
||||
2. MCP tools convert to Pydantic models and wrap in response object
|
||||
3. Response models inherit from `BaseResponse`, include `results` field + metadata
|
||||
|
||||
**Reference implementations:**
|
||||
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
|
||||
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
|
||||
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
|
||||
|
||||
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **Integration tests** in `tests/integration/` and `tests/client/`, `tests/server/` - Test real Nextcloud API interactions
|
||||
- **Integration tests** in `tests/client/` and `tests/server/` - Test real Nextcloud API interactions
|
||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
@@ -126,8 +201,8 @@ Each Nextcloud app has a corresponding server module that:
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
- `temporary_contact` - Creates and cleans up test contacts
|
||||
- **Test specific functionality** after changes:
|
||||
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
|
||||
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.3-python3.11-alpine@sha256:c5c8e9241027c384aa5e0d0368a6fd013945a23b7a5f25c754ed55ea7ef64f92
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine@sha256:1a51c7710eaf839fa3365329ad993b48d17ddd9ab0f0672efaa9b09f407ebf44
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -7,22 +7,33 @@
|
||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
||||
|
||||
> [!NOTE]
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support.
|
||||
|
||||
## Features
|
||||
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|
||||
|--------|---------------------------------------------|--------------------------------------------------------|
|
||||
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
|
||||
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
|
||||
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
|
||||
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
|
||||
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
|
||||
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
|
||||
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
|
||||
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
|
||||
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
|
||||
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
|
||||
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
|
||||
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
|
||||
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
|
||||
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
|
||||
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
|
||||
|
||||
| App | Support | Features |
|
||||
|-----|---------|----------|
|
||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
||||
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
|
||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
||||
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
|
||||
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -30,14 +41,15 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) |
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
|
||||
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
|
||||
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
|
||||
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
|
||||
> - **Production use**: Wait for upstream patches to be merged into official releases
|
||||
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
|
||||
> - **Production use**: Wait for upstream patch to be merged into official releases
|
||||
>
|
||||
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
|
||||
|
||||
@@ -92,10 +104,10 @@ See [Configuration Guide](docs/configuration.md) for all options.
|
||||
3. Start the server
|
||||
|
||||
**OAuth Setup (experimental):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration
|
||||
4. Configure Bearer token validation
|
||||
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
|
||||
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration or create an OIDC client with id & secret
|
||||
4. Configure Bearer token validation in `user_oidc`
|
||||
5. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
|
||||
index ee3cd57..6429f94 100644
|
||||
--- a/lib/Util/DiscoveryGenerator.php
|
||||
+++ b/lib/Util/DiscoveryGenerator.php
|
||||
@@ -171,6 +171,11 @@ class DiscoveryGenerator
|
||||
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
|
||||
}
|
||||
|
||||
+ // Add PKCE support if enabled
|
||||
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
|
||||
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
|
||||
+ }
|
||||
+
|
||||
$this->logger->info('Request to Discovery Endpoint.');
|
||||
|
||||
$response = new JSONResponse($discoveryPayload);
|
||||
@@ -6,14 +6,18 @@ echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
php /var/www/html/occ app:enable --force tasks # Not currently supported on 32
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
# Disable rate limits on calendar creation for integration tests
|
||||
# Set to -1 to completely disable rate limiting
|
||||
# Reference: https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html#rate-limits
|
||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
|
||||
@@ -11,7 +11,6 @@ php /var/www/html/occ app:enable oidc
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
|
||||
@@ -39,6 +39,14 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
|
||||
@@ -296,8 +296,7 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
|
||||
The integration test suite includes comprehensive OAuth testing:
|
||||
|
||||
- **Automated tests** (Playwright): [`tests/integration/test_oauth_playwright.py`](../tests/integration/test_oauth_playwright.py)
|
||||
- **Interactive tests**: [`tests/integration/test_oauth_interactive.py`](../tests/integration/test_oauth_interactive.py)
|
||||
- **Automated tests** (Playwright): [`tests/client/test_oauth_playwright.py`](../tests/client/test_oauth_playwright.py)
|
||||
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
|
||||
|
||||
Run OAuth tests:
|
||||
@@ -306,10 +305,7 @@ Run OAuth tests:
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run automated tests
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Run interactive tests (manual login)
|
||||
uv run pytest tests/integration/test_oauth_interactive.py -v
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. PKCE Support Advertisement in Discovery
|
||||
### 2. PKCE Support (RFC 7636)
|
||||
|
||||
**Status**: 🟢 **PR Submitted** (Pending Review)
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
|
||||
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
|
||||
|
||||
**Why It Matters**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Some MCP clients may reject providers without proper PKCE advertisement
|
||||
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
|
||||
|
||||
**Current Behavior**:
|
||||
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
|
||||
- PKCE just isn't **advertised** in discovery metadata
|
||||
**Authorization Endpoint** (`/authorize`):
|
||||
- Accepts `code_challenge` and `code_challenge_method` parameters
|
||||
- Validates code_challenge format (43-128 characters, unreserved chars only)
|
||||
- Supports both `S256` (SHA-256) and `plain` challenge methods
|
||||
- Stores challenge and method in database for later verification
|
||||
|
||||
**Recommended Fix**: Update `oidc` app to include:
|
||||
**Token Endpoint** (`/token`):
|
||||
- Accepts `code_verifier` parameter
|
||||
- Verifies code_verifier against stored code_challenge using proper algorithm
|
||||
- Uses constant-time comparison to prevent timing attacks
|
||||
- Enforces code_verifier requirement when PKCE was used in authorization
|
||||
|
||||
**Discovery Document**:
|
||||
```json
|
||||
{
|
||||
"code_challenge_methods_supported": ["S256"]
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
|
||||
**Database**:
|
||||
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
|
||||
- Migration included for existing installations
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
|
||||
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
|
||||
- **Size**: +5 lines added, 0 deleted
|
||||
- **Status**: Open, awaiting review
|
||||
**Why It Mattered**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 7636 PKCE provides security for public clients (no client secret)
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Prevents authorization code interception attacks
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
|
||||
- **Changes**: Complete PKCE implementation (+194 lines)
|
||||
- Authorization flow with code_challenge validation
|
||||
- Token exchange with code_verifier verification
|
||||
- Database schema updates
|
||||
- Discovery document updates
|
||||
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||
|
||||
---
|
||||
|
||||
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
| PR/Issue | Component | Status | Priority | Notes |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
|
||||
|
||||
## What Works Without Patches
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow**:
|
||||
- OIDC discovery
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE
|
||||
- Token exchange
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
|
||||
- Tables API
|
||||
- Custom app APIs
|
||||
|
||||
🟡 **Standards Compliance** (PKCE advertisement):
|
||||
- Full RFC 8414 compliance
|
||||
- MCP client compatibility guarantee
|
||||
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||
- ✅ MCP client compatibility guarantee
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
@@ -171,7 +187,7 @@ The integration test suite validates OAuth functionality:
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run comprehensive OAuth tests
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Tests verify:
|
||||
# - OAuth flow completion
|
||||
@@ -221,6 +237,6 @@ Want to help get these patches merged?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-14
|
||||
**Last Updated**: 2025-10-20
|
||||
|
||||
**Next Review**: When PR #584 or issue #1221 has activity
|
||||
**Next Review**: When issue #1221 (Bearer token support) has activity
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# Testing Client Sessions Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document compares different approaches to managing MCP client sessions in integration tests, addressing the fundamental incompatibility between pytest-asyncio's fixture management and anyio's structured concurrency requirements.
|
||||
|
||||
## The Problem
|
||||
|
||||
When using pytest-asyncio with anyio-based libraries (like the MCP Python SDK), session-scoped async generator fixtures encounter a fundamental issue:
|
||||
|
||||
1. **pytest-asyncio** runs fixture teardown in a **new asyncio task** using `runner.run()`
|
||||
2. **anyio** requires that cancel scopes be entered and exited in the **same task**
|
||||
3. This causes `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`
|
||||
|
||||
This is a **known limitation** documented in the anyio project and is not a bug in either pytest-asyncio or anyio, but rather an inherent incompatibility between their design philosophies.
|
||||
|
||||
## Solution Comparison
|
||||
|
||||
### Solution 1: Native Async Context Managers with Surgical Exception Handling ✅ **IMPLEMENTED**
|
||||
|
||||
**Approach**: Use native `async with` statements for clean code structure, but add targeted exception handling at the pytest fixture level to handle the expected teardown errors.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
async def create_mcp_client_session(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Uses native async context managers for clean LIFO cleanup."""
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
|
||||
try:
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||
):
|
||||
yield session
|
||||
except RuntimeError as e:
|
||||
# Only catch the specific expected error during pytest teardown
|
||||
if "cancel scope" in str(e) and "different task" in str(e):
|
||||
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
|
||||
else:
|
||||
# Unexpected RuntimeError - re-raise to fail the test
|
||||
raise
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Clean, idiomatic code using native Python context managers
|
||||
- ✅ Exception handling is surgical - only catches the specific expected error
|
||||
- ✅ Unexpected errors still propagate and fail tests
|
||||
- ✅ Can use session-scoped fixtures for performance
|
||||
- ✅ Easy to understand and maintain
|
||||
- ✅ Minimal code changes from original implementation
|
||||
- ✅ No external dependencies required
|
||||
|
||||
**Cons**:
|
||||
- ⚠️ Still requires exception suppression (though targeted)
|
||||
- ⚠️ String-based exception matching is somewhat fragile
|
||||
- ⚠️ Must apply the pattern to each session-scoped fixture
|
||||
- ⚠️ Doesn't solve the root cause
|
||||
|
||||
**Verdict**: **Recommended** - Best balance of code clarity, maintainability, and pragmatism.
|
||||
|
||||
---
|
||||
|
||||
### Solution 2: Task-Isolated Fixtures
|
||||
|
||||
**Approach**: Run each fixture's client session in an isolated anyio task group, allowing independent cleanup without cross-fixture interference.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Fixture with task isolation for clean teardown."""
|
||||
import anyio
|
||||
|
||||
session_holder = {"session": None}
|
||||
|
||||
async def create_and_hold_session():
|
||||
"""Runs in isolated task - creates session and keeps it alive."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
session_holder["session"] = session
|
||||
|
||||
# Keep session alive until cancelled
|
||||
try:
|
||||
await anyio.sleep_forever()
|
||||
except anyio.get_cancelled_exc_class():
|
||||
pass # Expected cancellation
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(create_and_hold_session)
|
||||
|
||||
# Wait for session to be ready
|
||||
while session_holder["session"] is None:
|
||||
await anyio.sleep(0.1)
|
||||
|
||||
yield session_holder["session"]
|
||||
|
||||
# Task group cancellation ensures clean LIFO cleanup
|
||||
tg.cancel_scope.cancel()
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No exception suppression needed
|
||||
- ✅ Each fixture has its own isolated task scope
|
||||
- ✅ More theoretically correct approach
|
||||
- ✅ Can use session-scoped fixtures
|
||||
|
||||
**Cons**:
|
||||
- ❌ Significantly more complex code
|
||||
- ❌ Harder to understand for developers unfamiliar with anyio
|
||||
- ❌ Requires understanding of task groups and cancel scopes
|
||||
- ❌ More boilerplate per fixture
|
||||
- ❌ Still doesn't solve the fundamental pytest-asyncio incompatibility
|
||||
- ❌ Polling for session readiness is inelegant
|
||||
- ❌ Higher cognitive overhead for maintenance
|
||||
|
||||
**Verdict**: **Not Recommended** - Complexity outweighs benefits. Consider only if exception handling is completely unacceptable.
|
||||
|
||||
---
|
||||
|
||||
### Solution 3: Function-Scoped Fixtures with Nested Context Managers
|
||||
|
||||
**Approach**: Change fixtures to function scope and rely on Python's context manager nesting for guaranteed LIFO cleanup.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="function") # Changed from session
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Function-scoped fixture with natural LIFO cleanup."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
# For tests needing multiple clients:
|
||||
@pytest.fixture(scope="function")
|
||||
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
|
||||
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read1, write1, _):
|
||||
async with ClientSession(read1, write1) as session1:
|
||||
await session1.initialize()
|
||||
|
||||
async with streamablehttp_client("http://127.0.0.1:8001/mcp") as (read2, write2, _):
|
||||
async with ClientSession(read2, write2) as session2:
|
||||
await session2.initialize()
|
||||
yield session1, session2
|
||||
# Cleanup: session2 -> stream2 -> session1 -> stream1 (LIFO guaranteed)
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No exception handling needed
|
||||
- ✅ Simplest to understand
|
||||
- ✅ Natural LIFO cleanup through Python's context managers
|
||||
- ✅ Each test gets fresh clients (better isolation)
|
||||
- ✅ No workarounds or hacks required
|
||||
|
||||
**Cons**:
|
||||
- ❌ Significantly slower tests (new clients per test)
|
||||
- ❌ Cannot share client state across tests
|
||||
- ❌ More resource intensive
|
||||
- ❌ Higher overhead for test suite execution
|
||||
- ❌ May not be practical for expensive fixtures (e.g., OAuth tokens)
|
||||
- ❌ Nested context managers become unwieldy with many clients
|
||||
|
||||
**Verdict**: **Good Alternative** - Consider for specific fixtures where session scope isn't critical, or for new test files where performance isn't a concern.
|
||||
|
||||
---
|
||||
|
||||
### Solution 4: Use pytest-trio Instead of pytest-asyncio (Future)
|
||||
|
||||
**Approach**: Replace pytest-asyncio with pytest-trio, which was designed with structured concurrency in mind.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
# Remove: asyncio_mode = "auto"
|
||||
# Add: trio_mode = "auto"
|
||||
|
||||
# Fixtures work naturally with trio
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No workarounds needed
|
||||
- ✅ Designed for structured concurrency
|
||||
- ✅ Theoretically cleanest solution
|
||||
- ✅ Can use session-scoped fixtures naturally
|
||||
|
||||
**Cons**:
|
||||
- ❌ Requires switching from asyncio to trio backend
|
||||
- ❌ Major refactoring required
|
||||
- ❌ May break existing code that assumes asyncio
|
||||
- ❌ Dependency changes throughout project
|
||||
- ❌ Team needs to learn trio ecosystem
|
||||
- ❌ Less ecosystem support than asyncio
|
||||
|
||||
**Verdict**: **Not Practical** - Too disruptive for existing projects. Consider only for greenfield projects or major rewrites.
|
||||
|
||||
---
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Solution | Code Clarity | Maintenance | Performance | Safety | Effort |
|
||||
|----------|--------------|-------------|-------------|--------|--------|
|
||||
| **Solution 1** (Implemented) | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| Solution 2 (Task-Isolated) | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| Solution 3 (Function-Scoped) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| Solution 4 (pytest-trio) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### What Changed in Solution 1
|
||||
|
||||
1. **`create_mcp_client_session` function** (conftest.py:61-110):
|
||||
- Replaced manual `__aenter__`/`__aexit__` calls with native `async with` statements
|
||||
- Removed blanket exception suppression from cleanup logic
|
||||
- Added clear documentation about LIFO cleanup order
|
||||
- Simplified from ~60 lines to ~40 lines
|
||||
|
||||
2. **Session-scoped MCP client fixtures** (conftest.py:148-1269):
|
||||
- Added targeted exception handling wrapper
|
||||
- Only catches specific "cancel scope" + "different task" RuntimeError
|
||||
- All other exceptions propagate normally
|
||||
- Applied to: `nc_mcp_client`, `nc_mcp_oauth_client`, `alice_mcp_client`, `bob_mcp_client`, `charlie_mcp_client`, `diana_mcp_client`
|
||||
|
||||
3. **Documentation**:
|
||||
- Added comprehensive docstrings explaining the workaround
|
||||
- Referenced MCP SDK issue #577 for context
|
||||
- Documented why this is necessary and not a bug
|
||||
|
||||
### Benefits of This Implementation
|
||||
|
||||
1. **Clean Core Logic**: The `create_mcp_client_session` function is now clean, idiomatic Python with no workarounds
|
||||
2. **Isolated Workaround**: Exception handling is confined to pytest fixture level where the issue actually occurs
|
||||
3. **Surgical Exception Handling**: Only catches the specific expected error, not all RuntimeErrors
|
||||
4. **Performance**: Maintains session-scoped fixtures for fast test execution
|
||||
5. **Maintainability**: Easy to understand and modify
|
||||
6. **Safety**: Real errors still cause test failures
|
||||
|
||||
## Testing Results
|
||||
|
||||
All tests pass cleanly with the implementation:
|
||||
|
||||
```bash
|
||||
$ uv run pytest tests/server/test_mcp.py -v
|
||||
============================================= test session starts ==============================================
|
||||
tests/server/test_mcp.py::test_mcp_connectivity PASSED [ 16%]
|
||||
tests/server/test_mcp.py::test_mcp_notes_crud_workflow PASSED [ 33%]
|
||||
tests/server/test_mcp.py::test_mcp_notes_etag_conflict PASSED [ 50%]
|
||||
tests/server/test_mcp.py::test_mcp_webdav_workflow PASSED [ 66%]
|
||||
tests/server/test_mcp.py::test_mcp_resources_access PASSED [ 83%]
|
||||
tests/server/test_mcp.py::test_mcp_calendar_workflow PASSED [100%]
|
||||
============================================== 6 passed in 39.52s ==============================================
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For This Project: Solution 1 ✅
|
||||
|
||||
The implemented solution (Solution 1) is the best fit because:
|
||||
- Minimal disruption to existing tests
|
||||
- Clean, maintainable code
|
||||
- Good performance with session-scoped fixtures
|
||||
- Targeted exception handling that doesn't hide real errors
|
||||
|
||||
### For New Test Files: Consider Solution 3
|
||||
|
||||
For new test files where performance isn't critical, consider using function-scoped fixtures (Solution 3):
|
||||
- No workarounds needed
|
||||
- Perfect code clarity
|
||||
- Better test isolation
|
||||
|
||||
### For Greenfield Projects: Consider Solution 4
|
||||
|
||||
For new projects starting from scratch, consider pytest-trio instead of pytest-asyncio:
|
||||
- Native structured concurrency support
|
||||
- No workarounds needed
|
||||
- Better alignment with modern async Python patterns
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MCP Python SDK Issue #577](https://github.com/modelcontextprotocol/python-sdk/issues/577) - Original issue report
|
||||
- [Anyio Issue #345](https://github.com/agronholm/anyio/issues/345) - Discussion of fixture limitations
|
||||
- [Nextcloud MCP Note 378555](nextcloud://notes/378555) - Detailed investigation notes
|
||||
- pytest-asyncio documentation: https://pytest-asyncio.readthedocs.io/
|
||||
- anyio structured concurrency guide: https://anyio.readthedocs.io/en/stable/basics.html
|
||||
|
||||
## Appendix: Why Can't This Be Fixed Upstream?
|
||||
|
||||
The incompatibility cannot be "fixed" in either pytest-asyncio or anyio without breaking their core design:
|
||||
|
||||
1. **pytest-asyncio** needs to manage fixture lifecycle across different scopes, requiring separate task creation for cleanup
|
||||
2. **anyio** enforces structured concurrency guarantees by requiring same-task cancel scope entry/exit
|
||||
3. These requirements are fundamentally incompatible
|
||||
|
||||
The maintainers of both projects are aware of this issue, and it's considered an acceptable trade-off given their respective design goals. The recommended approach is to handle it at the application level, as we've done here.
|
||||
@@ -5,6 +5,7 @@ from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import uvicorn
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
@@ -14,7 +15,7 @@ from starlette.routing import Mount
|
||||
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
from nextcloud_mcp_server.server import (
|
||||
configure_calendar_tools,
|
||||
@@ -176,8 +177,6 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
|
||||
try:
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -266,8 +265,6 @@ async def setup_oauth_config():
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -352,9 +349,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Asynchronously get the OAuth configuration
|
||||
import asyncio
|
||||
|
||||
nextcloud_host, token_verifier, auth_settings = asyncio.run(
|
||||
setup_oauth_config()
|
||||
)
|
||||
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
@@ -422,10 +417,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@click.option(
|
||||
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||
)
|
||||
@click.option(
|
||||
"--workers", "-w", type=int, default=None, help="Number of worker processes"
|
||||
)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
@@ -483,8 +474,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
def run(
|
||||
host: str,
|
||||
port: int,
|
||||
workers: int,
|
||||
reload: bool,
|
||||
log_level: str,
|
||||
transport: str,
|
||||
enable_app: tuple[str, ...],
|
||||
@@ -591,21 +580,10 @@ def run(
|
||||
|
||||
enabled_apps = list(enable_app) if enable_app else None
|
||||
|
||||
if reload or workers:
|
||||
app = "nextcloud_mcp_server.app:get_app"
|
||||
factory = True
|
||||
else:
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
factory = False
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
|
||||
uvicorn.run(
|
||||
app=app,
|
||||
factory=factory,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
workers=workers,
|
||||
log_level=log_level,
|
||||
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -113,8 +114,11 @@ async def register_client(
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {client_info.get('client_secret_expires_at')} "
|
||||
f"Client expires at: {expires_at} "
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
)
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ from .groups import GroupsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
from .webdav import WebDAVClient
|
||||
from .users import UsersClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,7 +72,9 @@ class NextcloudClient:
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(self._client, username)
|
||||
self.calendar = CalendarClient(
|
||||
base_url, username, auth
|
||||
) # Uses AsyncDavClient internally
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
@@ -121,13 +123,14 @@ class NextcloudClient:
|
||||
|
||||
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)
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await 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."""
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
await self.calendar.close()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from httpx import Timeout
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -127,7 +129,10 @@ class CookbookClient(BaseNextcloudClient):
|
||||
"""
|
||||
logger.info(f"Importing recipe from URL: {url}")
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/import", json={"url": url}
|
||||
"POST",
|
||||
"/apps/cookbook/api/v1/import",
|
||||
json={"url": url},
|
||||
timeout=Timeout(300.0),
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
@@ -16,24 +16,22 @@ class NotesClient(BaseNextcloudClient):
|
||||
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."""
|
||||
notes = []
|
||||
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Get all notes, yielding them one at a time."""
|
||||
cursor = ""
|
||||
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/apps/notes/api/v1/notes",
|
||||
params={"chunkSize": 50, "chunkCursor": cursor},
|
||||
params={"chunkSize": 10, "chunkCursor": cursor},
|
||||
)
|
||||
notes.extend(response.json())
|
||||
for note in response.json():
|
||||
yield note
|
||||
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||
break
|
||||
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||
|
||||
return notes
|
||||
|
||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Get a specific note by ID."""
|
||||
response = await self._make_request(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional, Dict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.users import UserDetails
|
||||
|
||||
|
||||
@@ -570,3 +570,379 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
scope: str = "",
|
||||
where_conditions: Optional[str] = None,
|
||||
properties: Optional[List[str]] = None,
|
||||
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files using WebDAV SEARCH method (RFC 5323).
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
where_conditions: XML string for where clause conditions
|
||||
properties: List of property names to retrieve (defaults to basic set)
|
||||
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of file/directory dictionaries with requested properties
|
||||
"""
|
||||
# Default properties if not specified
|
||||
if properties is None:
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
]
|
||||
|
||||
# Build the SEARCH request XML
|
||||
search_body = self._build_search_xml(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# The SEARCH endpoint is at the dav root
|
||||
search_path = "/remote.php/dav/"
|
||||
|
||||
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
logger.debug(f"Searching files in scope: {scope}")
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"SEARCH", search_path, content=search_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
results = self._parse_search_response(response.content, scope)
|
||||
|
||||
logger.debug(f"Search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error during search: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during search: {e}")
|
||||
raise e
|
||||
|
||||
def _build_search_xml(
|
||||
self,
|
||||
scope: str,
|
||||
where_conditions: Optional[str],
|
||||
properties: List[str],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
limit: Optional[int],
|
||||
) -> str:
|
||||
"""Build the XML body for a SEARCH request."""
|
||||
# Construct the scope path
|
||||
username = self.username
|
||||
scope_path = f"/files/{username}"
|
||||
if scope:
|
||||
scope_path = f"{scope_path}/{scope.lstrip('/')}"
|
||||
|
||||
# Build property list
|
||||
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
|
||||
|
||||
# Build where clause
|
||||
where_xml = where_conditions if where_conditions else ""
|
||||
|
||||
# Build order by clause
|
||||
orderby_xml = ""
|
||||
if order_by:
|
||||
order_elements = []
|
||||
for prop, direction in order_by:
|
||||
prop_element = self._property_to_xml(prop)
|
||||
dir_element = (
|
||||
"<d:ascending/>"
|
||||
if direction.lower() == "ascending"
|
||||
else "<d:descending/>"
|
||||
)
|
||||
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
|
||||
orderby_xml = "\n".join(order_elements)
|
||||
else:
|
||||
orderby_xml = ""
|
||||
|
||||
# Build limit clause
|
||||
limit_xml = (
|
||||
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
|
||||
)
|
||||
|
||||
# Construct the full SEARCH XML
|
||||
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:basicsearch>
|
||||
<d:select>
|
||||
<d:prop>
|
||||
{prop_xml}
|
||||
</d:prop>
|
||||
</d:select>
|
||||
<d:from>
|
||||
<d:scope>
|
||||
<d:href>{scope_path}</d:href>
|
||||
<d:depth>infinity</d:depth>
|
||||
</d:scope>
|
||||
</d:from>
|
||||
<d:where>
|
||||
{where_xml}
|
||||
</d:where>
|
||||
<d:orderby>
|
||||
{orderby_xml}
|
||||
</d:orderby>
|
||||
{limit_xml}
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>"""
|
||||
|
||||
return search_xml
|
||||
|
||||
def _property_to_xml(self, prop: str) -> str:
|
||||
"""Convert a property name to its XML element."""
|
||||
# Handle properties with namespace prefixes
|
||||
if prop.startswith("{"):
|
||||
# Already a full namespace
|
||||
namespace_end = prop.index("}")
|
||||
namespace = prop[1:namespace_end]
|
||||
local_name = prop[namespace_end + 1 :]
|
||||
|
||||
# Map namespace URIs to prefixes
|
||||
ns_map = {
|
||||
"DAV:": "d",
|
||||
"http://owncloud.org/ns": "oc",
|
||||
"http://nextcloud.org/ns": "nc",
|
||||
}
|
||||
|
||||
prefix = ns_map.get(namespace, "d")
|
||||
return f"<{prefix}:{local_name}/>"
|
||||
else:
|
||||
# Guess namespace based on common properties
|
||||
if prop in [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"quota-available-bytes",
|
||||
"quota-used-bytes",
|
||||
]:
|
||||
return f"<d:{prop}/>"
|
||||
elif prop in [
|
||||
"fileid",
|
||||
"size",
|
||||
"permissions",
|
||||
"favorite",
|
||||
"tags",
|
||||
"owner-id",
|
||||
"owner-display-name",
|
||||
"share-types",
|
||||
"checksums",
|
||||
"comments-count",
|
||||
"comments-unread",
|
||||
]:
|
||||
return f"<oc:{prop}/>"
|
||||
else:
|
||||
# Assume nc namespace for newer properties
|
||||
return f"<nc:{prop}/>"
|
||||
|
||||
def _parse_search_response(
|
||||
self, xml_content: bytes, scope: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse the XML response from a SEARCH request."""
|
||||
root = ET.fromstring(xml_content)
|
||||
items = []
|
||||
|
||||
# Process each response element
|
||||
responses = root.findall(".//{DAV:}response")
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory path from href
|
||||
href_text = href.text or ""
|
||||
# Remove the /remote.php/dav/files/username/ prefix to get relative path
|
||||
path_parts = href_text.split("/files/")
|
||||
if len(path_parts) > 1:
|
||||
# Get the path after username
|
||||
path_after_user = "/".join(path_parts[1].split("/")[1:])
|
||||
relative_path = path_after_user.rstrip("/")
|
||||
else:
|
||||
relative_path = href_text.rstrip("/").split("/")[-1]
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Build item dictionary
|
||||
item = {"path": relative_path, "href": href_text}
|
||||
|
||||
# Extract all properties
|
||||
for child in prop:
|
||||
tag = child.tag
|
||||
value = child.text
|
||||
|
||||
# Remove namespace from tag
|
||||
if "}" in tag:
|
||||
tag = tag.split("}", 1)[1]
|
||||
|
||||
# Handle special properties
|
||||
if tag == "resourcetype":
|
||||
item["is_directory"] = child.find(".//{DAV:}collection") is not None
|
||||
elif tag == "getcontentlength":
|
||||
item["size"] = int(value) if value else 0
|
||||
elif tag == "displayname":
|
||||
item["name"] = value
|
||||
elif tag == "getcontenttype":
|
||||
item["content_type"] = value
|
||||
elif tag == "getlastmodified":
|
||||
item["last_modified"] = value
|
||||
elif tag == "getetag":
|
||||
item["etag"] = value.strip('"') if value else None
|
||||
elif tag == "fileid":
|
||||
item["file_id"] = int(value) if value else None
|
||||
elif tag == "favorite":
|
||||
item["is_favorite"] = value == "1"
|
||||
elif tag == "permissions":
|
||||
item["permissions"] = value
|
||||
elif tag == "size":
|
||||
# oc:size includes folder sizes
|
||||
item["total_size"] = int(value) if value else 0
|
||||
else:
|
||||
# Store other properties as-is
|
||||
item[tag] = value
|
||||
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
async def find_by_name(
|
||||
self, pattern: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by name pattern using LIKE matching.
|
||||
|
||||
Args:
|
||||
pattern: Name pattern to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files/directories
|
||||
|
||||
Examples:
|
||||
# Find all .txt files
|
||||
results = await find_by_name("%.txt")
|
||||
|
||||
# Find files starting with "report"
|
||||
results = await find_by_name("report%")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>{pattern}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def find_by_type(
|
||||
self, mime_type: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by MIME type.
|
||||
|
||||
Args:
|
||||
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files
|
||||
|
||||
Examples:
|
||||
# Find all images
|
||||
results = await find_by_type("image/%")
|
||||
|
||||
# Find all PDFs
|
||||
results = await find_by_type("application/pdf")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>{mime_type}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def list_favorites(
|
||||
self, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List all favorite files.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of favorite files/directories
|
||||
|
||||
Examples:
|
||||
# List all favorites
|
||||
results = await list_favorites()
|
||||
|
||||
# List favorites in a specific folder
|
||||
results = await list_favorites(scope="Documents")
|
||||
"""
|
||||
# Use REPORT method for favorites as it's more efficient
|
||||
# But we can also use SEARCH as fallback
|
||||
where_conditions = """
|
||||
<d:eq>
|
||||
<d:prop>
|
||||
<oc:favorite/>
|
||||
</d:prop>
|
||||
<d:literal>1</d:literal>
|
||||
</d:eq>
|
||||
"""
|
||||
|
||||
# Request favorite property
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"favorite",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -2,17 +2,18 @@ import logging.config
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "http",
|
||||
}
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"http": {
|
||||
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
}
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": {
|
||||
@@ -29,6 +30,21 @@ LOGGING_CONFIG = {
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
"uvicorn": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, AsyncIterable, Dict, List
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
"""Handles notes search logic and scoring."""
|
||||
|
||||
def search_notes(
|
||||
self, notes: List[Dict[str, Any]], query: str
|
||||
async def search_notes(
|
||||
self, notes: AsyncIterable[Dict[str, Any]], query: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
@@ -21,7 +21,7 @@ class NotesSearchController:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in notes:
|
||||
async for note in notes:
|
||||
title_tokens, content_tokens = self._process_note_content(note)
|
||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
|
||||
@@ -65,11 +65,14 @@ from .tables import (
|
||||
|
||||
# WebDAV models
|
||||
from .webdav import (
|
||||
CopyResourceResponse,
|
||||
CreateDirectoryResponse,
|
||||
DeleteResourceResponse,
|
||||
DirectoryListing,
|
||||
FileInfo,
|
||||
MoveResourceResponse,
|
||||
ReadFileResponse,
|
||||
SearchFilesResponse,
|
||||
WriteFileResponse,
|
||||
)
|
||||
|
||||
@@ -133,4 +136,7 @@ __all__ = [
|
||||
"WriteFileResponse",
|
||||
"CreateDirectoryResponse",
|
||||
"DeleteResourceResponse",
|
||||
"MoveResourceResponse",
|
||||
"CopyResourceResponse",
|
||||
"SearchFilesResponse",
|
||||
]
|
||||
|
||||
@@ -180,3 +180,71 @@ class ManageCalendarResponse(BaseResponse):
|
||||
None, description="List of calendars (for list action)"
|
||||
)
|
||||
message: str = Field(description="Success message")
|
||||
|
||||
|
||||
# ============= Todo/Task Models =============
|
||||
|
||||
|
||||
class Todo(BaseModel):
|
||||
"""Model for a CalDAV todo/task (VTODO)."""
|
||||
|
||||
uid: str = Field(description="Todo UID")
|
||||
summary: str = Field(description="Todo summary/title")
|
||||
description: str = Field(default="", description="Todo description")
|
||||
status: str = Field(
|
||||
default="NEEDS-ACTION",
|
||||
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
||||
)
|
||||
priority: int = Field(
|
||||
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
|
||||
)
|
||||
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
|
||||
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
|
||||
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
|
||||
completed: Optional[str] = Field(
|
||||
None, description="Completion timestamp (ISO format)"
|
||||
)
|
||||
categories: str = Field(default="", description="Comma-separated categories")
|
||||
href: str = Field(default="", description="CalDAV href")
|
||||
etag: str = Field(default="", description="ETag for versioning")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this todo"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this todo"
|
||||
)
|
||||
|
||||
|
||||
class ListTodosResponse(BaseResponse):
|
||||
"""Response model for listing todos."""
|
||||
|
||||
todos: List[Todo] = Field(description="List of todos/tasks")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
total_count: int = Field(description="Total number of todos found")
|
||||
|
||||
|
||||
class CreateTodoResponse(BaseResponse):
|
||||
"""Response model for todo creation."""
|
||||
|
||||
todo: Todo = Field(description="The created todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was created in"
|
||||
)
|
||||
|
||||
|
||||
class UpdateTodoResponse(BaseResponse):
|
||||
"""Response model for todo updates."""
|
||||
|
||||
todo: Todo = Field(description="The updated todo")
|
||||
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
|
||||
|
||||
|
||||
class DeleteTodoResponse(StatusResponse):
|
||||
"""Response model for todo deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was deleted from"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class FileInfo(BaseModel):
|
||||
None, description="Last modification time (ISO format)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
|
||||
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
|
||||
|
||||
@property
|
||||
def last_modified_datetime(self) -> Optional[datetime]:
|
||||
@@ -106,3 +108,14 @@ class CopyResourceResponse(StatusResponse):
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
|
||||
class SearchFilesResponse(BaseResponse):
|
||||
"""Response model for WebDAV search operations."""
|
||||
|
||||
results: List[FileInfo] = Field(description="Search results")
|
||||
total_found: int = Field(description="Total number of files found")
|
||||
scope: str = Field(description="The scope/path that was searched")
|
||||
filters_applied: Optional[dict] = Field(
|
||||
None, description="Filters that were applied to the search"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ from typing import Optional
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -796,3 +801,209 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
else:
|
||||
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
||||
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_list_todos(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
min_priority: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
summary_contains: Optional[str] = None,
|
||||
) -> ListTodosResponse:
|
||||
"""List todos/tasks in a calendar with optional filtering.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to list todos from
|
||||
ctx: MCP context
|
||||
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||
summary_contains: Filter todos where summary contains this text
|
||||
|
||||
Returns:
|
||||
List of todos matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if min_priority is not None:
|
||||
filters["min_priority"] = min_priority
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if summary_contains is not None:
|
||||
filters["summary_contains"] = summary_contains
|
||||
|
||||
todos_data = await client.calendar.list_todos(
|
||||
calendar_name, filters if filters else None
|
||||
)
|
||||
|
||||
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||
return ListTodosResponse(
|
||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_create_todo(
|
||||
calendar_name: str,
|
||||
summary: str,
|
||||
ctx: Context,
|
||||
description: str = "",
|
||||
status: str = "NEEDS-ACTION",
|
||||
priority: int = 0,
|
||||
due: str = "",
|
||||
dtstart: str = "",
|
||||
categories: str = "",
|
||||
):
|
||||
"""Create a new todo/task in a calendar.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to create the todo in
|
||||
summary: Todo title/summary
|
||||
ctx: MCP context
|
||||
description: Detailed description of the todo
|
||||
status: Todo status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
priority: Priority (0=undefined, 1=highest, 9=lowest)
|
||||
due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00")
|
||||
dtstart: Start date/time (ISO format)
|
||||
categories: Comma-separated categories (e.g., "work,urgent")
|
||||
|
||||
Returns:
|
||||
Dict with todo creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
todo_data = {
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"due": due,
|
||||
"dtstart": dtstart,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_update_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
ctx: Context,
|
||||
summary: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
percent_complete: Optional[int] = None,
|
||||
due: Optional[str] = None,
|
||||
dtstart: Optional[str] = None,
|
||||
completed: Optional[str] = None,
|
||||
categories: Optional[str] = None,
|
||||
):
|
||||
"""Update an existing todo/task.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar containing the todo
|
||||
todo_uid: UID of the todo to update
|
||||
ctx: MCP context
|
||||
summary: New summary/title
|
||||
description: New description
|
||||
status: New status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
priority: New priority (0-9)
|
||||
percent_complete: New completion percentage (0-100)
|
||||
due: New due date/time (ISO format)
|
||||
dtstart: New start date/time (ISO format)
|
||||
completed: Completion timestamp (ISO format)
|
||||
categories: New categories (comma-separated)
|
||||
|
||||
Returns:
|
||||
Dict with todo update result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
todo_data = {}
|
||||
if summary is not None:
|
||||
todo_data["summary"] = summary
|
||||
if description is not None:
|
||||
todo_data["description"] = description
|
||||
if status is not None:
|
||||
todo_data["status"] = status
|
||||
if priority is not None:
|
||||
todo_data["priority"] = priority
|
||||
if percent_complete is not None:
|
||||
todo_data["percent_complete"] = percent_complete
|
||||
if due is not None:
|
||||
todo_data["due"] = due
|
||||
if dtstart is not None:
|
||||
todo_data["dtstart"] = dtstart
|
||||
if completed is not None:
|
||||
todo_data["completed"] = completed
|
||||
if categories is not None:
|
||||
todo_data["categories"] = categories
|
||||
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_delete_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a todo/task from a calendar.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar containing the todo
|
||||
todo_uid: UID of the todo to delete
|
||||
ctx: MCP context
|
||||
|
||||
Returns:
|
||||
Dict with deletion status
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_search_todos(
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
min_priority: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
summary_contains: Optional[str] = None,
|
||||
):
|
||||
"""Search todos across all calendars with optional filtering.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||
summary_contains: Filter todos where summary contains this text
|
||||
|
||||
Returns:
|
||||
List of todos matching the filters from all calendars
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if min_priority is not None:
|
||||
filters["min_priority"] = min_priority
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if summary_contains is not None:
|
||||
filters["summary_contains"] = summary_contains
|
||||
|
||||
todos_data = await client.calendar.search_todos_across_calendars(
|
||||
filters if filters else None
|
||||
)
|
||||
|
||||
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||
return ListTodosResponse(todos=todos, total_count=len(todos))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
@@ -83,6 +83,18 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
recipe=recipe,
|
||||
recipe_id=recipe.id or "unknown",
|
||||
)
|
||||
except RequestError as e:
|
||||
# RequestError can have empty str() - get details from exception attributes
|
||||
error_detail = (
|
||||
str(e)
|
||||
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error importing recipe from {url}: {error_detail}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 400:
|
||||
raise McpError(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
@@ -61,6 +61,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error retrieving note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -92,6 +99,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return CreateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -146,6 +157,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return UpdateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -192,6 +209,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return AppendContentResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error appending to note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -238,6 +262,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return SearchNotesResponse(
|
||||
results=results, query=query, total_found=len(results)
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -265,6 +293,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -295,6 +329,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"mimeType": mime_type,
|
||||
"data": content,
|
||||
}
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
@@ -330,6 +371,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
message=f"Note {note_id} deleted successfully",
|
||||
deleted_id=note_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import json
|
||||
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
|
||||
def configure_sharing_tools(mcp: FastMCP):
|
||||
"""Configure sharing-related MCP tools.
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,13 +19,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
|
||||
Examples:
|
||||
# List root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# List a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.list_directory(path)
|
||||
@@ -39,15 +33,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
@@ -89,13 +74,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
|
||||
Examples:
|
||||
# Write a text file
|
||||
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
|
||||
|
||||
# Write binary data (base64 encoded)
|
||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
@@ -119,13 +97,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
|
||||
Examples:
|
||||
# Create a single directory
|
||||
await nc_webdav_create_directory("NewProject")
|
||||
|
||||
# Create nested directories (parent must exist)
|
||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.create_directory(path)
|
||||
@@ -139,13 +110,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
|
||||
Examples:
|
||||
# Delete a file
|
||||
await nc_webdav_delete_resource("old_document.txt")
|
||||
|
||||
# Delete a directory (will delete all contents)
|
||||
await nc_webdav_delete_resource("temp_folder")
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.delete_resource(path)
|
||||
@@ -163,19 +127,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
|
||||
Examples:
|
||||
# Rename a file
|
||||
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||
|
||||
# Move a file to another directory
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||
|
||||
# Move a directory
|
||||
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||
|
||||
# Move and overwrite if destination exists
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.move_resource(
|
||||
@@ -195,21 +146,198 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
|
||||
Examples:
|
||||
# Copy a file
|
||||
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||
|
||||
# Copy a file to another directory
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||
|
||||
# Copy a directory
|
||||
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||
|
||||
# Copy and overwrite if destination exists
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.copy_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_search_files(
|
||||
ctx: Context,
|
||||
scope: str = "",
|
||||
name_pattern: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
only_favorites: bool = False,
|
||||
limit: int | None = None,
|
||||
) -> SearchFilesResponse:
|
||||
"""Search for files in NextCloud using WebDAV SEARCH.
|
||||
|
||||
This is a high-level search tool that supports common search patterns.
|
||||
For more complex queries, use the specific search tools.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
name_pattern: File name pattern (supports % wildcard, e.g., "%.txt" for all text files)
|
||||
mime_type: MIME type to filter by (supports % wildcard, e.g., "image/%" for all images)
|
||||
only_favorites: If True, only return favorited files
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build where conditions based on filters
|
||||
conditions = []
|
||||
|
||||
if name_pattern:
|
||||
conditions.append(
|
||||
f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>{name_pattern}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
)
|
||||
|
||||
if mime_type:
|
||||
conditions.append(
|
||||
f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>{mime_type}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
)
|
||||
|
||||
if only_favorites:
|
||||
conditions.append(
|
||||
"""
|
||||
<d:eq>
|
||||
<d:prop>
|
||||
<oc:favorite/>
|
||||
</d:prop>
|
||||
<d:literal>1</d:literal>
|
||||
</d:eq>
|
||||
"""
|
||||
)
|
||||
|
||||
# Combine conditions with AND if multiple
|
||||
if len(conditions) > 1:
|
||||
where_conditions = f"""
|
||||
<d:and>
|
||||
{"".join(conditions)}
|
||||
</d:and>
|
||||
"""
|
||||
elif len(conditions) == 1:
|
||||
where_conditions = conditions[0]
|
||||
else:
|
||||
where_conditions = None
|
||||
|
||||
# Include extended properties
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"favorite",
|
||||
]
|
||||
|
||||
results = await client.webdav.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert to FileInfo models
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
|
||||
# Build filters applied dict
|
||||
filters = {}
|
||||
if name_pattern:
|
||||
filters["name_pattern"] = name_pattern
|
||||
if mime_type:
|
||||
filters["mime_type"] = mime_type
|
||||
if only_favorites:
|
||||
filters["only_favorites"] = True
|
||||
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
total_found=len(file_infos),
|
||||
scope=scope,
|
||||
filters_applied=filters if filters else None,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_find_by_name(
|
||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
"""Find files by name pattern in NextCloud.
|
||||
|
||||
Args:
|
||||
pattern: Name pattern to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
results = await client.webdav.find_by_name(
|
||||
pattern=pattern, scope=scope, limit=limit
|
||||
)
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
total_found=len(file_infos),
|
||||
scope=scope,
|
||||
filters_applied={"name_pattern": pattern},
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_find_by_type(
|
||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
"""Find files by MIME type in NextCloud.
|
||||
|
||||
Args:
|
||||
mime_type: MIME type to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
results = await client.webdav.find_by_type(
|
||||
mime_type=mime_type, scope=scope, limit=limit
|
||||
)
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
total_found=len(file_infos),
|
||||
scope=scope,
|
||||
filters_applied={"mime_type": mime_type},
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_favorites(
|
||||
ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
"""List all favorite files in NextCloud.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for all favorites)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
SearchFilesResponse with list of favorite files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
total_found=len(file_infos),
|
||||
scope=scope,
|
||||
filters_applied={"only_favorites": True},
|
||||
)
|
||||
|
||||
+50
-11
@@ -1,12 +1,14 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.14.3"
|
||||
description = ""
|
||||
version = "0.17.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
]
|
||||
readme = "README.md"
|
||||
license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.18,<1.19)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
@@ -15,15 +17,33 @@ dependencies = [
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Communications",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
|
||||
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
|
||||
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
anyio_mode = "auto"
|
||||
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||
log_cli = 1
|
||||
log_cli_level = "WARN"
|
||||
log_level = "WARN"
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
@@ -31,6 +51,9 @@ markers = [
|
||||
testpaths = [
|
||||
"tests",
|
||||
]
|
||||
# Timeout settings to prevent tests from hanging indefinitely
|
||||
timeout = 180 # 3 minutes default timeout per test (includes fixture setup)
|
||||
timeout_func_only = false # Timeout includes fixture setup/teardown
|
||||
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
@@ -40,9 +63,19 @@ version_provider = "uv"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["I"]
|
||||
|
||||
[tool.uv.sources]
|
||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "nextcloud_mcp_server"
|
||||
module-root = ""
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -50,11 +83,17 @@ dev = [
|
||||
"ipython>=9.2.0",
|
||||
"playwright>=1.49.1",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=6.1.1",
|
||||
"pytest-playwright-asyncio>=0.7.1",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
publish-url = "https://test.pypi.org/legacy/"
|
||||
explicit = true
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Shared fixtures for calendar integration tests.
|
||||
|
||||
Note: The temporary_calendar fixture is defined in tests/conftest.py and uses
|
||||
a shared session-scoped calendar to avoid Nextcloud rate limiting issues.
|
||||
This conftest.py exists for any calendar-specific fixtures that might be needed
|
||||
in the future.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
"""Integration tests for Calendar CalDAV operations.
|
||||
|
||||
Note: These tests use the shared temporary_calendar fixture from conftest.py
|
||||
which reuses a session-scoped calendar to avoid Nextcloud rate limiting issues.
|
||||
Each test cleans up its own events/todos but shares the same calendar.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -15,50 +20,13 @@ logger = logging.getLogger(__name__)
|
||||
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
|
||||
logger.info(f"Creating temporary calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Test Calendar {calendar_name}",
|
||||
description="Temporary calendar for integration testing",
|
||||
color="#FF5722",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create temporary calendar: {result}")
|
||||
|
||||
logger.info(f"Created temporary calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up temporary calendar: {e}")
|
||||
pytest.skip(f"Calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the temporary calendar
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary event for testing and clean up afterward."""
|
||||
"""Create a temporary event for testing and clean up afterward.
|
||||
|
||||
Uses the shared temporary_calendar fixture from conftest.py which reuses
|
||||
a session-scoped calendar to avoid Nextcloud rate limiting.
|
||||
"""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
@@ -351,11 +319,11 @@ async def test_get_nonexistent_event(
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
# caldav library raises generic Exception for missing events, not HTTPStatusError
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
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}")
|
||||
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
@@ -420,7 +388,11 @@ async def test_calendar_operations_error_handling(
|
||||
# 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)
|
||||
# caldav library returns empty list for non-existent calendars, doesn't raise
|
||||
# Testing that it doesn't crash and returns empty results
|
||||
events = await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
assert isinstance(events, list)
|
||||
# Empty list is expected for non-existent calendar
|
||||
assert len(events) == 0
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
|
||||
"""Test that custom iCal fields are preserved during round-trip update operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
# Create an event with standard fields
|
||||
@@ -32,7 +32,12 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Now manually inject a custom iCal property by creating a new version with raw iCal
|
||||
# Get the calendar object from the caldav library
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
|
||||
# Now manually inject custom iCal properties into the raw data
|
||||
# This simulates what would happen if the event was created by another CalDAV client
|
||||
# with extended properties
|
||||
custom_ical = f"""BEGIN:VCALENDAR
|
||||
@@ -57,22 +62,15 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Direct CalDAV PUT to inject the custom iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=custom_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
# Update the event's raw data and save
|
||||
event.data = custom_ical
|
||||
await event.save()
|
||||
|
||||
logger.info(f"Injected custom iCal properties into event {event_uid}")
|
||||
|
||||
# Retrieve the event to confirm custom fields are present in raw iCal
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_before = response.text
|
||||
# Reload the event to confirm custom fields are present
|
||||
await event.load()
|
||||
raw_ical_before = event.data
|
||||
|
||||
logger.info("Raw iCal before update:")
|
||||
logger.info(raw_ical_before)
|
||||
@@ -93,31 +91,24 @@ END:VCALENDAR"""
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
logger.info(f"Updated event {event_uid} through MCP client")
|
||||
|
||||
# Retrieve the event again to see if custom fields survived
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_after = response_after.text
|
||||
# Reload the event to see if custom fields survived
|
||||
await event.load()
|
||||
raw_ical_after = event.data
|
||||
|
||||
logger.info("Raw iCal after update:")
|
||||
logger.info(raw_ical_after)
|
||||
|
||||
# THIS IS THE TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
|
||||
try:
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
|
||||
)
|
||||
except AssertionError as e:
|
||||
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
|
||||
# Re-raise to show the test failure
|
||||
raise
|
||||
# THIS IS THE CRITICAL TEST - custom fields should be preserved
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
|
||||
logger.info("✓ Custom fields were preserved during update")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -299,7 +290,7 @@ END:VCARD"""
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
"""Demonstrates specific data loss scenarios in calendar events."""
|
||||
"""Test that extended iCal properties are preserved during round-trip update operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
event_data = {
|
||||
@@ -313,6 +304,11 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Get the calendar object and event
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
|
||||
# Inject additional iCal properties that are valid but not supported by our parser
|
||||
extended_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
@@ -342,20 +338,13 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Inject the extended iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=extended_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
# Update the event's raw data and save
|
||||
event.data = extended_ical
|
||||
await event.save()
|
||||
|
||||
# Verify extended properties are present
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
original_ical = response.text
|
||||
# Reload to verify extended properties are present
|
||||
await event.load()
|
||||
original_ical = event.data
|
||||
|
||||
# Confirm extended properties exist
|
||||
extended_properties = [
|
||||
@@ -392,11 +381,9 @@ END:VCALENDAR"""
|
||||
update_data = {"location": "Conference Room B"} # Simple location change
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
|
||||
# Check what survived the round-trip
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
updated_ical = response_after.text
|
||||
# Reload the event to check what survived the round-trip
|
||||
await event.load()
|
||||
updated_ical = event.data
|
||||
|
||||
logger.info("Checking which properties survived the update...")
|
||||
|
||||
@@ -423,13 +410,16 @@ END:VCALENDAR"""
|
||||
lost.append(prop)
|
||||
|
||||
logger.info(f"Properties that SURVIVED: {survived}")
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
if lost:
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
|
||||
# This test should fail - we expect data loss
|
||||
# Assert that all extended properties were preserved
|
||||
assert len(lost) == 0, (
|
||||
f"Round-trip update lost {len(lost)} extended properties: {lost}"
|
||||
)
|
||||
|
||||
logger.info("✓ All extended properties preserved during update")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Integration tests for Calendar VTODO (task) operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
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 temporary_todo(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary todo for testing and clean up afterward."""
|
||||
todo_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create a test todo
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
todo_data = {
|
||||
"summary": f"Test Task {uuid.uuid4().hex[:8]}",
|
||||
"description": "Test todo created by integration tests",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 5,
|
||||
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
|
||||
"categories": "testing",
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary todo in calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result.get("uid")
|
||||
|
||||
if not todo_uid:
|
||||
pytest.fail("Failed to create temporary todo")
|
||||
|
||||
logger.info(f"Created temporary todo with UID: {todo_uid}")
|
||||
yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if todo_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary todo: {todo_uid}")
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
logger.info(f"Successfully deleted temporary todo: {todo_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary todo {todo_uid}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary todo {todo_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
# ============= Basic CRUD Tests =============
|
||||
|
||||
|
||||
async def test_create_and_delete_todo(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and deleting a basic todo."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create todo
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
todo_data = {
|
||||
"summary": "Integration Test Task",
|
||||
"description": "Test task for integration testing",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 3,
|
||||
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
|
||||
"categories": "testing,integration",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
assert "uid" in result
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with UID: {todo_uid}")
|
||||
|
||||
# Verify todo was created by listing todos
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
todo_uids = [todo.get("uid") for todo in todos]
|
||||
assert todo_uid in todo_uids
|
||||
|
||||
# Find our todo in the list
|
||||
our_todo = next((t for t in todos if t.get("uid") == todo_uid), None)
|
||||
assert our_todo is not None
|
||||
assert our_todo["summary"] == "Integration Test Task"
|
||||
assert our_todo["status"] == "NEEDS-ACTION"
|
||||
assert our_todo["priority"] == 3
|
||||
|
||||
# Delete todo
|
||||
delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted todo: {todo_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Test listing todos in a calendar."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create multiple todos
|
||||
todo_uids = []
|
||||
for i in range(3):
|
||||
todo_data = {
|
||||
"summary": f"Test Task {i + 1}",
|
||||
"description": f"Task number {i + 1}",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": i + 1,
|
||||
}
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uids.append(result["uid"])
|
||||
|
||||
try:
|
||||
# List todos
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
|
||||
assert isinstance(todos, list)
|
||||
assert len(todos) >= 3 # At least our 3 todos
|
||||
|
||||
# Check structure
|
||||
for todo in todos:
|
||||
assert "uid" in todo
|
||||
assert "summary" in todo
|
||||
assert "status" in todo
|
||||
assert "priority" in todo
|
||||
|
||||
# Verify our todos are in the list
|
||||
listed_uids = [todo["uid"] for todo in todos]
|
||||
for uid in todo_uids:
|
||||
assert uid in listed_uids
|
||||
|
||||
logger.info(f"Found {len(todos)} todos in calendar")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
for uid in todo_uids:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict):
|
||||
"""Test updating an existing todo."""
|
||||
calendar_name = temporary_todo["calendar_name"]
|
||||
todo_uid = temporary_todo["uid"]
|
||||
|
||||
# Update todo data
|
||||
updated_data = {
|
||||
"summary": "Updated Test Task Title",
|
||||
"description": "Updated description for test task",
|
||||
"status": "IN-PROCESS",
|
||||
"priority": 1, # High priority
|
||||
"percent_complete": 50,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.update_todo(
|
||||
calendar_name, todo_uid, updated_data
|
||||
)
|
||||
assert result["uid"] == todo_uid
|
||||
|
||||
# Verify updates by listing todos
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
updated_todo = next((t for t in todos if t["uid"] == todo_uid), None)
|
||||
|
||||
assert updated_todo is not None
|
||||
assert updated_todo["summary"] == "Updated Test Task Title"
|
||||
assert updated_todo["description"] == "Updated description for test task"
|
||||
assert updated_todo["status"] == "IN-PROCESS"
|
||||
assert updated_todo["priority"] == 1
|
||||
assert updated_todo["percent_complete"] == 50
|
||||
|
||||
logger.info(f"Successfully updated todo: {todo_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Todo update test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Test creating a todo with start, due, and completed dates."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
now = datetime.now()
|
||||
start_date = now + timedelta(days=1)
|
||||
due_date = now + timedelta(days=7)
|
||||
|
||||
todo_data = {
|
||||
"summary": "Task with Dates",
|
||||
"description": "Test task with various date fields",
|
||||
"status": "NEEDS-ACTION",
|
||||
"dtstart": start_date.strftime("%Y-%m-%dT09:00:00"),
|
||||
"due": due_date.strftime("%Y-%m-%dT17:00:00"),
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with dates, UID: {todo_uid}")
|
||||
|
||||
# Verify dates
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
|
||||
|
||||
assert created_todo is not None
|
||||
assert created_todo["summary"] == "Task with Dates"
|
||||
assert "dtstart" in created_todo
|
||||
assert "due" in created_todo
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Date handling test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ============= Advanced Feature Tests =============
|
||||
|
||||
|
||||
async def test_todo_status_transitions(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test transitioning through different todo statuses."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
todo_data = {
|
||||
"summary": "Status Transition Test",
|
||||
"description": "Testing status changes",
|
||||
"status": "NEEDS-ACTION",
|
||||
}
|
||||
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Transition: NEEDS-ACTION → IN-PROCESS
|
||||
await nc_client.calendar.update_todo(
|
||||
calendar_name,
|
||||
todo_uid,
|
||||
{"status": "IN-PROCESS", "percent_complete": 25},
|
||||
)
|
||||
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
todo = next((t for t in todos if t["uid"] == todo_uid), None)
|
||||
assert todo["status"] == "IN-PROCESS"
|
||||
assert todo["percent_complete"] == 25
|
||||
|
||||
# Transition: IN-PROCESS → COMPLETED
|
||||
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
await nc_client.calendar.update_todo(
|
||||
calendar_name,
|
||||
todo_uid,
|
||||
{
|
||||
"status": "COMPLETED",
|
||||
"percent_complete": 100,
|
||||
"completed": completed_time,
|
||||
},
|
||||
)
|
||||
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
todo = next((t for t in todos if t["uid"] == todo_uid), None)
|
||||
assert todo["status"] == "COMPLETED"
|
||||
assert todo["percent_complete"] == 100
|
||||
assert "completed" in todo
|
||||
|
||||
logger.info(f"Successfully transitioned todo through statuses: {todo_uid}")
|
||||
|
||||
finally:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
|
||||
async def test_todo_priority_levels(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test different priority levels (0=undefined, 1=highest, 9=lowest)."""
|
||||
calendar_name = temporary_calendar
|
||||
priorities = [0, 1, 5, 9]
|
||||
priority_labels = {0: "Undefined", 1: "Highest", 5: "Medium", 9: "Lowest"}
|
||||
todo_uids = []
|
||||
|
||||
try:
|
||||
# Create todos with different priorities
|
||||
for priority in priorities:
|
||||
todo_data = {
|
||||
"summary": f"Priority {priority} Task ({priority_labels[priority]})",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": priority,
|
||||
}
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uids.append((result["uid"], priority))
|
||||
|
||||
# Verify all priorities
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
|
||||
for uid, expected_priority in todo_uids:
|
||||
todo = next((t for t in todos if t["uid"] == uid), None)
|
||||
assert todo is not None
|
||||
assert todo["priority"] == expected_priority
|
||||
|
||||
logger.info(f"Successfully tested priority levels: {priorities}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
for uid, _ in todo_uids:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_todo_with_categories(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating a todo with multiple categories."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
todo_data = {
|
||||
"summary": "Task with Categories",
|
||||
"description": "Testing category support",
|
||||
"status": "NEEDS-ACTION",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with categories, UID: {todo_uid}")
|
||||
|
||||
# Verify categories
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
|
||||
|
||||
assert created_todo is not None
|
||||
assert "categories" in created_todo
|
||||
categories_str = created_todo["categories"]
|
||||
assert "work" in categories_str
|
||||
assert "meeting" in categories_str
|
||||
assert "important" in categories_str
|
||||
assert "quarterly" in categories_str
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Categories test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_search_todos_across_calendars(
|
||||
nc_client: NextcloudClient, temporary_calendar: str, shared_calendar_2: str
|
||||
):
|
||||
"""Test searching for todos across multiple calendars.
|
||||
|
||||
Uses two shared test calendars to avoid rate limiting.
|
||||
"""
|
||||
# Use existing shared calendars to avoid rate limits
|
||||
cal1_name = temporary_calendar # First shared test calendar
|
||||
cal2_name = shared_calendar_2 # Second shared test calendar
|
||||
|
||||
try:
|
||||
# Create todos in both calendars
|
||||
todo1_data = {"summary": "Task in Calendar 1", "status": "NEEDS-ACTION"}
|
||||
todo2_data = {"summary": "Task in Calendar 2", "status": "IN-PROCESS"}
|
||||
|
||||
result1 = await nc_client.calendar.create_todo(cal1_name, todo1_data)
|
||||
result2 = await nc_client.calendar.create_todo(cal2_name, todo2_data)
|
||||
|
||||
# Search across all calendars
|
||||
all_todos = await nc_client.calendar.search_todos_across_calendars()
|
||||
|
||||
assert isinstance(all_todos, list)
|
||||
|
||||
# Find our todos
|
||||
todo1 = next((t for t in all_todos if t["uid"] == result1["uid"]), None)
|
||||
todo2 = next((t for t in all_todos if t["uid"] == result2["uid"]), None)
|
||||
|
||||
assert todo1 is not None
|
||||
assert todo2 is not None
|
||||
assert "calendar_name" in todo1
|
||||
assert "calendar_name" in todo2
|
||||
assert todo1["calendar_name"] == cal1_name
|
||||
assert todo2["calendar_name"] == cal2_name
|
||||
|
||||
logger.info(f"Found {len(all_todos)} todos across all calendars")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete only the todos we created (calendars are reused/built-in)
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(cal1_name, result1["uid"])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(cal2_name, result2["uid"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============= Edge Case Tests =============
|
||||
|
||||
|
||||
async def test_get_nonexistent_todo(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test attempting to retrieve a non-existent todo."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
# List todos to ensure it doesn't exist
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
matching_todos = [t for t in todos if t.get("uid") == fake_uid]
|
||||
assert len(matching_todos) == 0
|
||||
|
||||
logger.info(f"Verified nonexistent todo UID: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_todo(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test deleting a non-existent todo."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
result = await nc_client.calendar.delete_todo(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent todo: {fake_uid}")
|
||||
|
||||
|
||||
async def test_list_todos_with_filters(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test listing todos with various filters."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create todos with different statuses and priorities
|
||||
test_todos = [
|
||||
{
|
||||
"summary": "High Priority Task",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 1,
|
||||
"categories": "urgent",
|
||||
},
|
||||
{
|
||||
"summary": "In Progress Task",
|
||||
"status": "IN-PROCESS",
|
||||
"priority": 5,
|
||||
"categories": "work",
|
||||
},
|
||||
{
|
||||
"summary": "Low Priority Task",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 9,
|
||||
"categories": "someday",
|
||||
},
|
||||
]
|
||||
|
||||
created_uids = []
|
||||
|
||||
try:
|
||||
# Create test todos
|
||||
for todo_data in test_todos:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
created_uids.append(result["uid"])
|
||||
|
||||
# Test basic list without filters
|
||||
all_todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
assert len(all_todos) >= 3
|
||||
|
||||
# Verify all our todos are in the list
|
||||
our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids]
|
||||
assert len(our_todo_uids) == 3
|
||||
|
||||
logger.info(f"Successfully created and listed {len(created_uids)} test todos")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
for uid in created_uids:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, uid)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -153,29 +153,17 @@ async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
logger.info(f"Delete correctly failed with {e.response.status_code}")
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(
|
||||
nc_client: NextcloudClient, test_recipe_server: str
|
||||
):
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses a local test server to provide reliable, controlled test data.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
# Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it
|
||||
# The test_recipe_server runs on the host, but Nextcloud runs in Docker
|
||||
# On Linux, 172.17.0.1 is the default Docker bridge gateway
|
||||
# On Mac/Windows, try host.docker.internal first
|
||||
import platform
|
||||
|
||||
if platform.system() == "Linux":
|
||||
docker_host = "172.17.0.1"
|
||||
else:
|
||||
docker_host = "host.docker.internal"
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
docker_accessible_url = test_recipe_server.replace("localhost", docker_host)
|
||||
test_url = f"{docker_accessible_url}/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from local test URL (Docker-accessible): {test_url}")
|
||||
logger.info(f"Importing recipe from nginx container: {test_url}")
|
||||
|
||||
try:
|
||||
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
|
||||
@@ -213,7 +201,7 @@ async def test_cookbook_import_recipe_from_url(
|
||||
elif e.response.status_code == 400:
|
||||
# URL couldn't be imported
|
||||
logger.error(
|
||||
f"Failed to import recipe from local test URL: {test_url}. "
|
||||
f"Failed to import recipe from nginx container: {test_url}. "
|
||||
f"Status: {e.response.status_code}, Response: {e.response.text}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
|
||||
|
||||
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can list notes."""
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
|
||||
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client successfully listed {len(notes)} notes")
|
||||
@@ -95,7 +95,7 @@ async def test_invalid_token_fails():
|
||||
# Attempt to use a protected endpoint - should fail with 401
|
||||
# Note: capabilities endpoint is public and doesn't require auth
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await invalid_client.notes.get_all_notes()
|
||||
_ = [note async for note in invalid_client.notes.get_all_notes()]
|
||||
|
||||
assert exc_info.value.response.status_code == 401
|
||||
|
||||
|
||||
@@ -27,6 +27,6 @@ async def test_oauth_client_with_playwright_flow(nc_oauth_client):
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
|
||||
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_delete_share(nc_client):
|
||||
"""Test creating and deleting a file share."""
|
||||
# Create a test user to share with
|
||||
@@ -68,7 +68,7 @@ async def test_create_and_delete_share(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_update_share_permissions(nc_client):
|
||||
"""Test updating share permissions."""
|
||||
# Create a test user to share with
|
||||
@@ -120,7 +120,7 @@ async def test_update_share_permissions(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_list_shares(nc_client):
|
||||
"""Test listing all shares."""
|
||||
# Create a test user to share with
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Integration tests for WebDAV search operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
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_search_setup(nc_client: NextcloudClient):
|
||||
"""Create test files and directories for search testing."""
|
||||
test_dir = f"mcp_search_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
|
||||
# Create various test files
|
||||
test_files = [
|
||||
# Text files
|
||||
(f"{test_dir}/document1.txt", b"Sample document content", "text/plain"),
|
||||
(f"{test_dir}/document2.txt", b"Another document", "text/plain"),
|
||||
(f"{test_dir}/report.txt", b"Report content", "text/plain"),
|
||||
# Markdown files
|
||||
(f"{test_dir}/readme.md", b"# README\nMarkdown content", "text/markdown"),
|
||||
(f"{test_dir}/notes.md", b"# Notes\nSome notes here", "text/markdown"),
|
||||
# PDF (simulated as binary)
|
||||
(
|
||||
f"{test_dir}/presentation.pdf",
|
||||
b"%PDF-1.4 fake pdf content",
|
||||
"application/pdf",
|
||||
),
|
||||
# Subdirectory with files
|
||||
(f"{test_dir}/subdir/nested.txt", b"Nested file content", "text/plain"),
|
||||
]
|
||||
|
||||
# Create subdirectory
|
||||
await nc_client.webdav.create_directory(f"{test_dir}/subdir")
|
||||
|
||||
# Write all test files
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
|
||||
|
||||
|
||||
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by exact name."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one readme.md file"
|
||||
|
||||
# Check that we found the right file
|
||||
readme_files = [r for r in results if r.get("name") == "readme.md"]
|
||||
assert len(readme_files) >= 1, "Should find readme.md"
|
||||
|
||||
logger.info(f"Found {len(results)} files matching 'readme.md'")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_extension(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by extension using wildcard."""
|
||||
# Find all .txt files
|
||||
results = await nc_client.webdav.find_by_name("%.txt", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 3, "Should find at least 3 .txt files"
|
||||
|
||||
# Verify all results are .txt files
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
|
||||
logger.info(f"Found {len(results)} .txt files")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_prefix(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by name prefix using wildcard."""
|
||||
# Find all files starting with "document"
|
||||
results = await nc_client.webdav.find_by_name("document%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 2, "Should find at least 2 files starting with 'document'"
|
||||
|
||||
# Verify all results start with "document"
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files starting with 'document'")
|
||||
|
||||
|
||||
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by MIME type (text files)."""
|
||||
# Find all text files
|
||||
results = await nc_client.webdav.find_by_type("text/%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 5, "Should find at least 5 text files"
|
||||
|
||||
# Verify all results are text files
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type.startswith("text/"), (
|
||||
f"Expected text/* type, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} text files")
|
||||
|
||||
|
||||
async def test_find_by_type_specific(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by specific MIME type."""
|
||||
# Find PDF files
|
||||
results = await nc_client.webdav.find_by_type(
|
||||
"application/pdf", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find at least 1 PDF file"
|
||||
|
||||
# Verify result is PDF
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type == "application/pdf", (
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} PDF files")
|
||||
|
||||
|
||||
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search with result limit."""
|
||||
# Search for .txt files with limit of 2
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"%.txt", scope=test_search_setup, limit=2
|
||||
)
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
|
||||
assert len(results) > 0, "Should return at least 1 result"
|
||||
|
||||
logger.info(f"Found {len(results)} files with limit=2")
|
||||
|
||||
|
||||
async def test_search_files_combined_filters(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test search with multiple filters combined."""
|
||||
# This test uses the search_files method directly to test combined conditions
|
||||
# Search for .txt files that match a specific pattern
|
||||
where_conditions = """
|
||||
<d:and>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>%.txt</d:literal>
|
||||
</d:like>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>document%</d:literal>
|
||||
</d:like>
|
||||
</d:and>
|
||||
"""
|
||||
|
||||
results = await nc_client.webdav.search_files(
|
||||
scope=test_search_setup, where_conditions=where_conditions
|
||||
)
|
||||
|
||||
# Should find document1.txt and document2.txt
|
||||
assert len(results) >= 2, "Should find at least 2 files matching both conditions"
|
||||
|
||||
# Verify results match both conditions
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files matching combined filters")
|
||||
|
||||
|
||||
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search in empty scope (user root)."""
|
||||
# Search entire user root for a unique filename
|
||||
unique_name = "readme.md"
|
||||
results = await nc_client.webdav.find_by_name(unique_name, scope="")
|
||||
|
||||
# Should find at least the one we created
|
||||
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
|
||||
|
||||
logger.info(f"Found {len(results)} files in root scope")
|
||||
|
||||
|
||||
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search within a subdirectory."""
|
||||
# Search in the subdir for the nested file
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nested.txt", scope=f"{test_search_setup}/subdir"
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find nested.txt in subdirectory"
|
||||
|
||||
# Verify the file path
|
||||
nested_file = results[0]
|
||||
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
|
||||
|
||||
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
|
||||
|
||||
|
||||
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search that returns no results."""
|
||||
# Search for a non-existent pattern
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nonexistent_file_xyz123.txt", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) == 0, "Should return empty results for non-existent file"
|
||||
|
||||
logger.info("Search correctly returned no results for non-existent file")
|
||||
|
||||
|
||||
async def test_search_properties_returned(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test that search returns expected properties."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one file"
|
||||
|
||||
result = results[0]
|
||||
|
||||
# Check for expected properties
|
||||
assert "name" in result, "Should include name property"
|
||||
assert "path" in result, "Should include path property"
|
||||
assert "is_directory" in result, "Should include is_directory property"
|
||||
assert result["is_directory"] is False, "readme.md should not be a directory"
|
||||
|
||||
# Optional properties that may be present
|
||||
optional_props = ["size", "content_type", "last_modified", "etag"]
|
||||
logger.info(f"Result properties: {list(result.keys())}")
|
||||
|
||||
# At least some optional properties should be present
|
||||
present_optional = [prop for prop in optional_props if prop in result]
|
||||
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
|
||||
|
||||
logger.info(f"Search returned properties: {list(result.keys())}")
|
||||
+223
-126
@@ -15,6 +15,12 @@ from nextcloud_mcp_server.client import NextcloudClient
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
"""Configure anyio to use asyncio backend for all tests."""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def wait_for_nextcloud(
|
||||
host: str, max_attempts: int = 30, delay: float = 2.0
|
||||
) -> bool:
|
||||
@@ -66,10 +72,14 @@ async def create_mcp_client_session(
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
|
||||
Uses native async context managers to ensure correct LIFO cleanup order,
|
||||
eliminating the need for exception suppression. Python's context manager protocol
|
||||
guarantees that cleanup happens in reverse order of entry.
|
||||
|
||||
Consolidates the common pattern used by all MCP client fixtures:
|
||||
- Creates streamable HTTP client with optional OAuth token
|
||||
- Initializes MCP ClientSession
|
||||
- Handles cleanup with proper exception handling
|
||||
- Ensures proper cleanup without suppressing errors
|
||||
|
||||
Args:
|
||||
url: MCP server URL (e.g., "http://127.0.0.1:8000/mcp")
|
||||
@@ -78,51 +88,36 @@ async def create_mcp_client_session(
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
|
||||
Note:
|
||||
This implementation uses native async context managers instead of manually
|
||||
calling __aenter__/__aexit__. This ensures that anyio's structured concurrency
|
||||
requirements are met, as Python guarantees LIFO cleanup order for nested
|
||||
context managers. See: https://github.com/modelcontextprotocol/python-sdk/issues/577
|
||||
"""
|
||||
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
||||
|
||||
# Prepare headers with OAuth token if provided
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
streamable_context = streamablehttp_client(url, headers=headers)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
# Use native async with - Python ensures LIFO cleanup
|
||||
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
|
||||
async with streamablehttp_client(url, headers=headers) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
yield session
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing {client_name} session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing {client_name} session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing {client_name} streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing {client_name} streamable HTTP client: {e}")
|
||||
# Cleanup happens automatically in LIFO order - no exception suppression needed
|
||||
logger.debug(f"{client_name} client session cleaned up successfully")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Uses environment variables for configuration.
|
||||
@@ -157,9 +152,11 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
|
||||
Uses anyio pytest plugin for proper async fixture handling.
|
||||
"""
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||
@@ -169,6 +166,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client(
|
||||
anyio_backend,
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
@@ -176,6 +174,7 @@ async def nc_mcp_oauth_client(
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
|
||||
Uses headless browser automation suitable for CI/CD.
|
||||
Uses anyio pytest plugin for proper async fixture handling.
|
||||
"""
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
@@ -502,8 +501,164 @@ async def temporary_board_with_card(
|
||||
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_test_calendar_name():
|
||||
"""Unique calendar name for the entire test session."""
|
||||
return f"test_calendar_shared_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_test_calendar_name_2():
|
||||
"""Second unique calendar name for cross-calendar tests."""
|
||||
return f"test_calendar_shared_2_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def shared_calendar(nc_client: NextcloudClient, shared_test_calendar_name: str):
|
||||
"""Create a shared calendar for all tests in the session. Reuses the calendar to avoid rate limiting."""
|
||||
calendar_name = shared_test_calendar_name
|
||||
|
||||
try:
|
||||
# Create a test calendar
|
||||
logger.info(f"Creating shared test calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Shared Test Calendar {calendar_name}",
|
||||
description="Shared calendar for integration testing (reused across tests)",
|
||||
color="#FF5722",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create shared test calendar: {result}")
|
||||
|
||||
logger.info(f"Created shared test calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up shared test calendar: {e}")
|
||||
pytest.skip(f"Shared calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the shared calendar at end of session
|
||||
try:
|
||||
logger.info(f"Cleaning up shared test calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(f"Successfully deleted shared test calendar: {calendar_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting shared test calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def shared_calendar_2(
|
||||
nc_client: NextcloudClient,
|
||||
shared_test_calendar_name_2: str,
|
||||
shared_calendar: str, # Explicit dependency to ensure proper initialization order
|
||||
):
|
||||
"""Create a second shared calendar for cross-calendar tests.
|
||||
|
||||
Note: Depends on shared_calendar to ensure proper fixture initialization order
|
||||
and avoid race conditions when running multiple tests together.
|
||||
"""
|
||||
calendar_name = shared_test_calendar_name_2
|
||||
|
||||
try:
|
||||
# Wait for first calendar to fully initialize to avoid Nextcloud rate limiting
|
||||
# When creating multiple calendars rapidly, Nextcloud may not register them all
|
||||
import asyncio
|
||||
|
||||
logger.info("Waiting before creating second calendar to avoid rate limiting...")
|
||||
await asyncio.sleep(3) # Increased from 2 to 3 seconds
|
||||
|
||||
# Create a test calendar
|
||||
logger.info(f"Creating second shared test calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Shared Test Calendar 2 {calendar_name}",
|
||||
description="Second shared calendar for cross-calendar testing",
|
||||
color="#4CAF50",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create second shared test calendar: {result}")
|
||||
|
||||
logger.info(f"Created second shared test calendar: {calendar_name}")
|
||||
|
||||
# Verify calendar was created by listing calendars
|
||||
# Add small delay to allow calendar to propagate in the system
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(1.0) # Allow time for calendar to propagate
|
||||
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
calendar_names = [cal["name"] for cal in calendars]
|
||||
if calendar_name not in calendar_names:
|
||||
logger.warning(
|
||||
f"Calendar {calendar_name} not found immediately after creation. Available: {calendar_names}"
|
||||
)
|
||||
# Try one more time after a longer delay
|
||||
await asyncio.sleep(3) # Additional wait for calendar synchronization
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
calendar_names = [cal["name"] for cal in calendars]
|
||||
if calendar_name not in calendar_names:
|
||||
logger.error(
|
||||
f"Calendar {calendar_name} still not found after retries. Available: {calendar_names}"
|
||||
)
|
||||
pytest.fail(
|
||||
f"Failed to create second shared calendar: {calendar_name} not found in listing"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Successfully verified second shared test calendar: {calendar_name}"
|
||||
)
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up second shared test calendar: {e}")
|
||||
pytest.skip(f"Second shared calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the second shared calendar at end of session
|
||||
try:
|
||||
logger.info(f"Cleaning up second shared test calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(
|
||||
f"Successfully deleted second shared test calendar: {calendar_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error deleting second shared test calendar {calendar_name}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_calendar(shared_calendar: str, nc_client: NextcloudClient):
|
||||
"""Provide the shared calendar and clean up todos after each test.
|
||||
|
||||
This fixture reuses a session-scoped calendar to avoid Nextcloud rate limiting
|
||||
on calendar creation. Each test gets the same calendar but todos are cleaned up
|
||||
between tests.
|
||||
"""
|
||||
calendar_name = shared_calendar
|
||||
|
||||
yield calendar_name
|
||||
|
||||
# Cleanup: Delete all todos from this calendar
|
||||
try:
|
||||
logger.info(f"Cleaning up todos from shared calendar: {calendar_name}")
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
for todo in todos:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo["uid"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Error deleting todo {todo['uid']}: {e}")
|
||||
logger.info(f"Cleaned up {len(todos)} todos from shared calendar")
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up todos from calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client(
|
||||
anyio_backend,
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
@@ -549,9 +704,14 @@ def oauth_callback_server():
|
||||
- server_url: The callback URL for the server (e.g., "http://localhost:8081")
|
||||
|
||||
The server automatically shuts down when the fixture is torn down.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
# Skip OAuth tests in GitHub Actions - Playwright browser automation
|
||||
# has issues with localhost callback server in CI environment
|
||||
# if os.getenv("GITHUB_ACTIONS"):
|
||||
# pytest.skip(
|
||||
# "OAuth tests with browser automation not supported in GitHub Actions CI"
|
||||
# )
|
||||
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -626,7 +786,7 @@ def oauth_callback_server():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def shared_oauth_client_credentials(oauth_callback_server):
|
||||
async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||
"""
|
||||
Fixture to obtain shared OAuth client credentials that will be reused for all users.
|
||||
|
||||
@@ -669,7 +829,7 @@ async def shared_oauth_client_credentials(oauth_callback_server):
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=".nextcloud_oauth_shared_test_client.json",
|
||||
client_name="Nextcloud MCP Server - Shared Test Client",
|
||||
client_name="Pytest - Shared Test Client",
|
||||
redirect_uris=[callback_url],
|
||||
)
|
||||
|
||||
@@ -687,7 +847,7 @@ async def shared_oauth_client_credentials(oauth_callback_server):
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def playwright_oauth_token(
|
||||
browser, shared_oauth_client_credentials, oauth_callback_server
|
||||
anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token using Playwright headless browser automation.
|
||||
@@ -756,7 +916,7 @@ async def playwright_oauth_token(
|
||||
try:
|
||||
# Navigate to authorization URL
|
||||
logger.debug(f"Navigating to: {auth_url}")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
|
||||
# Check if we need to login first
|
||||
current_url = page.url
|
||||
@@ -779,7 +939,7 @@ async def playwright_oauth_token(
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
# Wait for navigation after login
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.info(f"After login, current URL: {current_url}")
|
||||
|
||||
@@ -850,7 +1010,7 @@ async def playwright_oauth_token(
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def test_users_setup(nc_client: NextcloudClient):
|
||||
async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||
"""
|
||||
Create test users for multi-user OAuth testing.
|
||||
|
||||
@@ -1097,7 +1257,11 @@ async def _get_oauth_token_for_user(
|
||||
# Parallel token retrieval fixture - fetches all OAuth tokens concurrently
|
||||
@pytest.fixture(scope="session")
|
||||
async def all_oauth_tokens(
|
||||
browser, shared_oauth_client_credentials, test_users_setup, oauth_callback_server
|
||||
anyio_backend,
|
||||
browser,
|
||||
shared_oauth_client_credentials,
|
||||
test_users_setup,
|
||||
oauth_callback_server,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Fetch OAuth tokens for all test users in parallel for speed.
|
||||
@@ -1157,31 +1321,32 @@ async def all_oauth_tokens(
|
||||
|
||||
# Session-scoped OAuth token fixtures - now use the parallel fixture
|
||||
@pytest.fixture(scope="session")
|
||||
async def alice_oauth_token(all_oauth_tokens) -> str:
|
||||
async def alice_oauth_token(anyio_backend, all_oauth_tokens) -> str:
|
||||
"""OAuth token for alice (cached for session). Uses shared OAuth client."""
|
||||
return all_oauth_tokens["alice"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def bob_oauth_token(all_oauth_tokens) -> str:
|
||||
async def bob_oauth_token(anyio_backend, all_oauth_tokens) -> str:
|
||||
"""OAuth token for bob (cached for session). Uses shared OAuth client."""
|
||||
return all_oauth_tokens["bob"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def charlie_oauth_token(all_oauth_tokens) -> str:
|
||||
async def charlie_oauth_token(anyio_backend, all_oauth_tokens) -> str:
|
||||
"""OAuth token for charlie (cached for session). Uses shared OAuth client."""
|
||||
return all_oauth_tokens["charlie"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def diana_oauth_token(all_oauth_tokens) -> str:
|
||||
async def diana_oauth_token(anyio_backend, all_oauth_tokens) -> str:
|
||||
"""OAuth token for diana (cached for session). Uses shared OAuth client."""
|
||||
return all_oauth_tokens["diana"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def alice_mcp_client(
|
||||
anyio_backend,
|
||||
alice_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as alice (owner role)."""
|
||||
@@ -1194,16 +1359,21 @@ async def alice_mcp_client(
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def bob_mcp_client(bob_oauth_token: str) -> AsyncGenerator[ClientSession, Any]:
|
||||
async def bob_mcp_client(
|
||||
anyio_backend, bob_oauth_token: str
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as bob (viewer role)."""
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp", token=bob_oauth_token, client_name="Bob MCP"
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
token=bob_oauth_token,
|
||||
client_name="Bob MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def charlie_mcp_client(
|
||||
anyio_backend,
|
||||
charlie_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
||||
@@ -1217,6 +1387,7 @@ async def charlie_mcp_client(
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def diana_mcp_client(
|
||||
anyio_backend,
|
||||
diana_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as diana (no-access role)."""
|
||||
@@ -1319,77 +1490,3 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
|
||||
logger.debug(f"Added user {user_config['userid']} to group {groupid}")
|
||||
|
||||
yield (user_config, groupid)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_recipe_server():
|
||||
"""
|
||||
Fixture to create a local HTTP server serving test recipe HTML pages.
|
||||
|
||||
This serves static HTML files with schema.org Recipe JSON-LD data for testing
|
||||
recipe import functionality without relying on external websites.
|
||||
|
||||
Yields the server URL (e.g., "http://localhost:8082")
|
||||
"""
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
httpd = None
|
||||
server_thread = None
|
||||
|
||||
# Get the path to the fixtures directory
|
||||
fixtures_dir = Path(__file__).parent / "fixtures"
|
||||
|
||||
class RecipeServerHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
# Suppress default HTTP logging
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
# Map URL paths to fixture files
|
||||
if self.path == "/black-pepper-tofu":
|
||||
file_path = fixtures_dir / "test_recipe.html"
|
||||
else:
|
||||
# 404 for unknown paths
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if file_path.exists():
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
try:
|
||||
# Start the HTTP server on all interfaces (0.0.0.0) so Docker can reach it
|
||||
httpd = HTTPServer(("0.0.0.0", 8082), RecipeServerHandler)
|
||||
server_thread = threading.Thread(target=httpd.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
logger.info(
|
||||
"Test recipe server started on http://0.0.0.0:8082 (accessible from Docker)"
|
||||
)
|
||||
|
||||
# Yield the server URL (use localhost for test code, will be replaced with Docker-accessible IP)
|
||||
yield "http://localhost:8082"
|
||||
|
||||
finally:
|
||||
# Clean up the server
|
||||
if httpd:
|
||||
logger.info("Shutting down test recipe server...")
|
||||
shutdown_thread = threading.Thread(target=httpd.shutdown)
|
||||
shutdown_thread.start()
|
||||
shutdown_thread.join(timeout=2)
|
||||
httpd.server_close()
|
||||
logger.info("Test recipe server shut down successfully")
|
||||
if server_thread:
|
||||
server_thread.join(timeout=1)
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type text/html;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri.html =404;
|
||||
}
|
||||
|
||||
# Serve test_recipe.html at /black-pepper-tofu
|
||||
location = /black-pepper-tofu {
|
||||
root /usr/share/nginx/html;
|
||||
try_files /test_recipe.html =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
# OAuth Multi-User Load Testing Framework
|
||||
|
||||
Comprehensive multi-user benchmarking system for testing OAuth-authenticated Nextcloud MCP server with realistic collaborative workflows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Ensure docker-compose is running
|
||||
docker-compose up -d
|
||||
|
||||
# 2. Run a benchmark with 2 users for 30 seconds
|
||||
uv run python -m tests.load.oauth_benchmark --users 2 --duration 30
|
||||
|
||||
# 3. Clean up test users (IMPORTANT - always run after benchmark)
|
||||
uv run python -m tests.load.cleanup_loadtest_users
|
||||
|
||||
# Optional: Verify cleanup
|
||||
uv run python -m tests.load.cleanup_loadtest_users --dry-run
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
This framework extends the basic load testing infrastructure to support:
|
||||
- **Multiple OAuth-authenticated users** running concurrently
|
||||
- **Coordinated workflows** spanning multiple users (sharing, collaboration, permissions)
|
||||
- **Per-user metrics** tracking individual user performance
|
||||
- **Workflow-specific metrics** measuring cross-user operation latencies
|
||||
- **Realistic scenarios** mimicking actual user collaboration patterns
|
||||
- **Concurrent user creation** - all users created and authenticated in parallel for fast setup
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
tests/load/
|
||||
├── oauth_pool.py # OAuth user pool management
|
||||
├── oauth_workloads.py # Multi-user workflow definitions
|
||||
├── oauth_metrics.py # Enhanced metrics collection
|
||||
├── oauth_benchmark.py # Main CLI entry point
|
||||
└── README_OAUTH.md # This file
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
**OAuthUserPool** (`oauth_pool.py`)
|
||||
- Manages N OAuth-authenticated users
|
||||
- Handles token acquisition and storage
|
||||
- Creates and manages MCP sessions per user
|
||||
- Tracks per-user operation statistics
|
||||
|
||||
**UserSessionWrapper** (`oauth_pool.py`)
|
||||
- Wraps MCP ClientSession for a specific user
|
||||
- Automatic operation tracking
|
||||
- Convenient tool/resource access methods
|
||||
|
||||
**Workflow** (`oauth_workloads.py`)
|
||||
- Base class for multi-user coordinated workflows
|
||||
- Step-by-step execution with timing
|
||||
- Comprehensive error handling and reporting
|
||||
|
||||
**OAuthBenchmarkMetrics** (`oauth_metrics.py`)
|
||||
- Per-user operation counts and latencies
|
||||
- Workflow completion rates and timings
|
||||
- Baseline operation statistics
|
||||
- Detailed reporting and JSON export
|
||||
|
||||
## Available Workflows
|
||||
|
||||
### 1. NoteShareWorkflow
|
||||
**Scenario**: Alice creates a note and shares it with Bob, who then reads it.
|
||||
|
||||
**Steps**:
|
||||
1. User A creates a note
|
||||
2. User A shares note with User B (read-only permissions)
|
||||
3. User B lists their shared notes (measures propagation delay)
|
||||
4. User B reads the shared note
|
||||
|
||||
**Metrics**: Creation latency, share propagation time, read latency
|
||||
|
||||
### 2. CollaborativeEditWorkflow
|
||||
**Scenario**: Multiple users concurrently edit the same note.
|
||||
|
||||
**Steps**:
|
||||
1. Owner creates a note
|
||||
2. All users read the note simultaneously
|
||||
3. All users append content concurrently
|
||||
4. Owner verifies final state
|
||||
|
||||
**Metrics**: Concurrent read latency, concurrent write conflicts, final state consistency
|
||||
|
||||
### 3. FileShareAndDownloadWorkflow
|
||||
**Scenario**: Alice uploads a file, shares it with Bob, who then downloads it.
|
||||
|
||||
**Steps**:
|
||||
1. User A creates a file via WebDAV
|
||||
2. User A shares file with User B (read-only)
|
||||
3. User B lists their shares
|
||||
4. User B downloads the file
|
||||
|
||||
**Metrics**: Upload latency, share creation, download latency
|
||||
|
||||
### 4. MixedOAuthWorkload
|
||||
**Distribution**:
|
||||
- 50% Baseline operations (individual user CRUD)
|
||||
- 30% Note sharing workflows
|
||||
- 15% Collaborative editing workflows
|
||||
- 5% File sharing workflows
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# 4 users, 60-second test with mixed workload
|
||||
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
|
||||
|
||||
# 10 users, 5-minute test
|
||||
uv run python -m tests.load.oauth_benchmark -u 10 -d 300
|
||||
|
||||
# Export results to JSON
|
||||
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Sharing-focused workload
|
||||
uv run python -m tests.load.oauth_benchmark --workload sharing -u 8 -d 180
|
||||
|
||||
# Collaborative editing workload
|
||||
uv run python -m tests.load.oauth_benchmark --workload collaboration -u 6 -d 120
|
||||
|
||||
# Baseline operations only (no workflows)
|
||||
uv run python -m tests.load.oauth_benchmark --workload baseline -u 10 -d 60
|
||||
|
||||
# Verbose logging for debugging
|
||||
uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
|
||||
```
|
||||
|
||||
### CLI Options
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
|
||||
| `--duration` | `-d` | 30.0 | Test duration in seconds |
|
||||
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
|
||||
| `--url` | | `http://127.0.0.1:8001/mcp` | MCP OAuth server URL |
|
||||
| `--output` | `-o` | None | JSON output file path |
|
||||
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
|
||||
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
|
||||
| `--cleanup/--no-cleanup` | | `cleanup` | Delete created users after benchmark |
|
||||
| `--browser` | | `chromium` | Playwright browser: firefox, chromium, webkit |
|
||||
| `--headed` | | False | Run browser in headed mode (visible window) |
|
||||
| `--verbose` | `-v` | False | Enable verbose logging |
|
||||
|
||||
## Test User Creation
|
||||
|
||||
The framework **dynamically creates test users** on-demand with OAuth authentication:
|
||||
|
||||
- **Naming**: Users are created with the pattern `{prefix}_user_{n}` (default: `loadtest_user_1`, `loadtest_user_2`, etc.)
|
||||
- **Customization**: Use `--user-prefix` to change the prefix (e.g., `--user-prefix mytest` → `mytest_user_1`)
|
||||
- **Scalability**: No limit on user count - create as many concurrent users as your system can handle
|
||||
- **Credentials**: Each user gets a randomly generated secure password
|
||||
- **OAuth Tokens**: All users authenticate via automated OAuth flow using Playwright
|
||||
- **Cleanup**: Users are automatically deleted after the benchmark (disable with `--no-cleanup`)
|
||||
|
||||
**Example**: Running `--users 5` creates:
|
||||
- `loadtest_user_1` (Display: Load Test User 1, Email: loadtest_user_1@benchmark.local)
|
||||
- `loadtest_user_2` (Display: Load Test User 2, Email: loadtest_user_2@benchmark.local)
|
||||
- `loadtest_user_3` (Display: Load Test User 3, Email: loadtest_user_3@benchmark.local)
|
||||
- `loadtest_user_4` (Display: Load Test User 4, Email: loadtest_user_4@benchmark.local)
|
||||
- `loadtest_user_5` (Display: Load Test User 5, Email: loadtest_user_5@benchmark.local)
|
||||
|
||||
## Metrics Output
|
||||
|
||||
### Console Report
|
||||
|
||||
```
|
||||
================================================================================
|
||||
OAUTH MULTI-USER BENCHMARK RESULTS
|
||||
================================================================================
|
||||
|
||||
Duration: 120.45s
|
||||
Total Users: 5
|
||||
Total Workflows Executed: 312
|
||||
Total Baseline Operations: 678
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
WORKFLOW STATISTICS
|
||||
--------------------------------------------------------------------------------
|
||||
Workflow Total Success Rate P50 P95
|
||||
--------------------------------------------------------------------------------
|
||||
note_share 112 109 97.3% 0.2341s 0.4782s
|
||||
collaborative_edit 65 61 93.8% 0.5123s 0.9234s
|
||||
file_share 29 29 100.0% 0.3456s 0.6123s
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
PER-USER STATISTICS
|
||||
--------------------------------------------------------------------------------
|
||||
User Total Ops Success Errors Rate P50
|
||||
--------------------------------------------------------------------------------
|
||||
loadtest_user_1 289 283 6 97.9% 0.2456s
|
||||
loadtest_user_2 245 241 4 98.4% 0.2123s
|
||||
loadtest_user_3 231 226 5 97.8% 0.2345s
|
||||
loadtest_user_4 198 195 3 98.5% 0.2234s
|
||||
loadtest_user_5 187 184 3 98.4% 0.2189s
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
BASELINE OPERATIONS
|
||||
--------------------------------------------------------------------------------
|
||||
Total Operations: 678
|
||||
Success Rate: 98.2%
|
||||
Latency: min=0.0234s, p50=0.1234s, p95=0.3456s, max=0.8123s
|
||||
================================================================================
|
||||
```
|
||||
|
||||
### JSON Export
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"duration": 120.45,
|
||||
"total_workflows": 312,
|
||||
"total_baseline_ops": 678,
|
||||
"total_users": 5
|
||||
},
|
||||
"workflows": {
|
||||
"note_share": {
|
||||
"total_executions": 112,
|
||||
"successful_executions": 109,
|
||||
"failed_executions": 3,
|
||||
"success_rate": 97.3,
|
||||
"latency": {
|
||||
"min": 0.1234,
|
||||
"max": 0.8765,
|
||||
"mean": 0.2891,
|
||||
"median": 0.2341,
|
||||
"p90": 0.4123,
|
||||
"p95": 0.4782,
|
||||
"p99": 0.7234
|
||||
},
|
||||
"step_latencies": {
|
||||
"create_note": {...},
|
||||
"share_note": {...},
|
||||
"list_shared_with_me": {...},
|
||||
"read_shared_note": {...}
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadtest_user_1": {
|
||||
"total_operations": 289,
|
||||
"successful_operations": 283,
|
||||
"failed_operations": 6,
|
||||
"success_rate": 97.9,
|
||||
"latency": {...},
|
||||
"operations_breakdown": {...},
|
||||
"errors_breakdown": {...}
|
||||
},
|
||||
"loadtest_user_2": {...},
|
||||
"loadtest_user_3": {...},
|
||||
"loadtest_user_4": {...},
|
||||
"loadtest_user_5": {...}
|
||||
},
|
||||
"baseline": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Components
|
||||
|
||||
**Framework:**
|
||||
- OAuth user pool management with dynamic user creation
|
||||
- User session wrappers with automatic tracking
|
||||
- Workflow base classes and framework
|
||||
- 3 example workflows (note share, collaborative edit, file share)
|
||||
- Enhanced metrics with per-user and workflow tracking
|
||||
- CLI interface with multiple workload options
|
||||
- Comprehensive reporting (console + JSON)
|
||||
|
||||
**OAuth Integration:**
|
||||
- ✅ Playwright browser automation for OAuth login
|
||||
- ✅ OAuth callback server for auth code capture
|
||||
- ✅ Token exchange with OIDC provider
|
||||
- ✅ OAuth token injection into MCP sessions via Authorization headers
|
||||
- ✅ Cancel scope error handling for reliable cleanup
|
||||
- ✅ Dynamic user creation and deletion via Nextcloud Users API
|
||||
|
||||
**Implementation Details:**
|
||||
The benchmark now successfully:
|
||||
1. Creates Nextcloud users dynamically with unique passwords
|
||||
2. Acquires OAuth tokens via automated Playwright browser flows
|
||||
3. Creates MCP client sessions with proper `Authorization: Bearer {token}` headers
|
||||
4. Executes coordinated multi-user workflows
|
||||
5. Tracks per-user and per-workflow metrics
|
||||
6. Provides standalone cleanup utility for test users
|
||||
|
||||
**Key Fix (oauth_pool.py:163-164)**:
|
||||
```python
|
||||
# Pass OAuth token as Authorization header
|
||||
headers = {"Authorization": f"Bearer {profile.token}"}
|
||||
streamable_context = streamablehttp_client(mcp_url, headers=headers)
|
||||
```
|
||||
|
||||
## Creating Custom Workflows
|
||||
|
||||
### Example: Permission Escalation Workflow
|
||||
|
||||
```python
|
||||
class PermissionEscalationWorkflow(Workflow):
|
||||
"""Test sharing permission changes."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("permission_escalation")
|
||||
|
||||
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
|
||||
self.start_time = time.time()
|
||||
|
||||
if len(users) < 2:
|
||||
return self._finish(False, error="Requires 2+ users")
|
||||
|
||||
owner, collaborator = users[0], users[1]
|
||||
|
||||
# Step 1: Owner creates note
|
||||
create_result = await self._execute_step(
|
||||
"create_note",
|
||||
owner,
|
||||
lambda: owner.call_tool("nc_notes_create_note", {...})
|
||||
)
|
||||
|
||||
# Step 2: Share read-only
|
||||
await self._execute_step(
|
||||
"share_readonly",
|
||||
owner,
|
||||
lambda: owner.call_tool("nc_share_create", {
|
||||
"permissions": 1 # Read-only
|
||||
})
|
||||
)
|
||||
|
||||
# Step 3: Upgrade to edit permissions
|
||||
await self._execute_step(
|
||||
"upgrade_permissions",
|
||||
owner,
|
||||
lambda: owner.call_tool("nc_share_update", {
|
||||
"permissions": 15 # Read+update+create+delete
|
||||
})
|
||||
)
|
||||
|
||||
# Step 4: Collaborator edits
|
||||
await self._execute_step(
|
||||
"collaborator_edit",
|
||||
collaborator,
|
||||
lambda: collaborator.call_tool("nc_notes_update_note", {...})
|
||||
)
|
||||
|
||||
return self._finish(success=True)
|
||||
```
|
||||
|
||||
### Registering Custom Workflows
|
||||
|
||||
```python
|
||||
# In oauth_workloads.py
|
||||
class MixedOAuthWorkload:
|
||||
def __init__(self, users: list[UserSessionWrapper]):
|
||||
self.users = users
|
||||
self.workflows = {
|
||||
"note_share": NoteShareWorkflow(),
|
||||
"collaborative_edit": CollaborativeEditWorkflow(),
|
||||
"file_share": FileShareAndDownloadWorkflow(),
|
||||
"permission_escalation": PermissionEscalationWorkflow(), # Add your workflow
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Expectations
|
||||
|
||||
### Baseline Performance (basic auth, from existing benchmarks)
|
||||
- **Throughput**: 50-200 RPS for mixed workload
|
||||
- **Latency**: p50 <100ms, p95 <500ms, p99 <1000ms
|
||||
|
||||
### OAuth Multi-User Expectations
|
||||
- **Lower throughput**: ~30-60% of baseline due to:
|
||||
- OAuth token validation overhead
|
||||
- Cross-user synchronization delays
|
||||
- Workflow coordination overhead
|
||||
- **Higher p99 latency**: Due to workflow step dependencies
|
||||
- **Focus**: End-to-end workflow completion time more important than raw RPS
|
||||
|
||||
### Common Bottlenecks
|
||||
1. **OAuth token validation**: Per-request overhead
|
||||
2. **Share propagation**: Time for shares to become visible to recipients
|
||||
3. **Concurrent edit conflicts**: ETags and conflict resolution
|
||||
4. **Permission checks**: Cross-user access validation
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start Small**: Begin with 2-3 users to validate workflows
|
||||
2. **Monitor Errors**: Watch for permission errors and conflicts
|
||||
3. **Adjust Delays**: Tune sleep delays between operations based on server response
|
||||
4. **Profile Workflows**: Use step latencies to identify bottlenecks
|
||||
5. **Export Results**: Always export to JSON for historical comparison
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Concurrent User Creation
|
||||
|
||||
The benchmark creates and authenticates users **concurrently** for maximum performance:
|
||||
|
||||
**Step 5: User Creation & OAuth Authentication**
|
||||
- All N users are created in parallel using `asyncio.gather()`
|
||||
- Each user runs through the full OAuth flow simultaneously
|
||||
- Multiple Playwright browser contexts operate independently
|
||||
|
||||
**Step 6: MCP Session Creation**
|
||||
- All user sessions are created concurrently
|
||||
- OAuth tokens passed as Authorization headers to each session
|
||||
|
||||
**Performance Impact:**
|
||||
- **Sequential** (old): ~10-12s per user → 40-48s for 4 users
|
||||
- **Concurrent** (new): ~12-15s total for 4 users (3-4x speedup!)
|
||||
|
||||
Example output showing concurrent execution:
|
||||
```
|
||||
Step 5/6: Creating 4 users and acquiring OAuth tokens...
|
||||
(Running concurrently for faster setup)
|
||||
|
||||
[1/4] Creating user 'loadtest_user_1'...
|
||||
[2/4] Creating user 'loadtest_user_2'...
|
||||
[3/4] Creating user 'loadtest_user_3'...
|
||||
[4/4] Creating user 'loadtest_user_4'...
|
||||
✓ User 'loadtest_user_4' authenticated
|
||||
✓ User 'loadtest_user_2' authenticated
|
||||
✓ User 'loadtest_user_1' authenticated
|
||||
✓ User 'loadtest_user_3' authenticated
|
||||
|
||||
✓ Successfully created and authenticated 4 users
|
||||
```
|
||||
|
||||
**Implementation** (oauth_benchmark.py:402-437):
|
||||
```python
|
||||
# Create tasks for all users
|
||||
tasks = [
|
||||
create_user_task(i, browser, callback_server.auth_states)
|
||||
for i in range(num_users)
|
||||
]
|
||||
# Run all concurrently
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
**Important**: Due to asyncio scoping issues with the MCP client library, automatic cleanup in the benchmark's finally block may not execute reliably. Always use the cleanup utility after running benchmarks.
|
||||
|
||||
### Cleanup Utility (Recommended)
|
||||
|
||||
Use the cleanup utility to remove test users:
|
||||
|
||||
```bash
|
||||
# Dry run - see what would be deleted
|
||||
uv run python -m tests.load.cleanup_loadtest_users --dry-run
|
||||
|
||||
# Delete all loadtest users
|
||||
uv run python -m tests.load.cleanup_loadtest_users
|
||||
|
||||
# Delete users with custom prefix
|
||||
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
|
||||
```
|
||||
|
||||
### Disable Automatic Cleanup
|
||||
|
||||
To keep test users after the benchmark for inspection:
|
||||
|
||||
```bash
|
||||
uv run python -m tests.load.oauth_benchmark --users 2 --no-cleanup
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Leftover Test Users
|
||||
**Symptom**: Test users remain in Nextcloud after benchmark crashes
|
||||
|
||||
**Solution**: Run the cleanup utility:
|
||||
```bash
|
||||
uv run python -m tests.load.cleanup_loadtest_users
|
||||
```
|
||||
|
||||
### "User X not in pool" Error
|
||||
- Ensure user count doesn't exceed configured limits
|
||||
- Check that user creation succeeded in previous steps
|
||||
|
||||
### CancelledError During Benchmark
|
||||
**Symptom**: Error message like `'CancelledError' object has no attribute 'username'` appears in logs
|
||||
|
||||
**Cause**: Async task cancellation during benchmark shutdown or errors can cause race conditions in error handling
|
||||
|
||||
**Solution**: This has been mitigated with defensive error handling. The worker now:
|
||||
- Catches `asyncio.CancelledError` specifically before general exceptions
|
||||
- Logs cancellation gracefully without attempting to access potentially invalid state
|
||||
- Re-raises the exception to allow proper cleanup chain
|
||||
|
||||
If you still see this error, it's likely harmless and occurs during shutdown. The benchmark results should still be valid.
|
||||
|
||||
### High Error Rates
|
||||
- Increase delay between operations (`await asyncio.sleep()` in worker)
|
||||
- Check OAuth token validity
|
||||
- Verify MCP OAuth server is running and accessible (port 8001)
|
||||
- Rebuild mcp-oauth container after code changes: `docker-compose up --build -d mcp-oauth`
|
||||
|
||||
### Workflows Failing
|
||||
- Check step-by-step latencies to identify failing steps
|
||||
- Verify users have correct permissions
|
||||
- Review server logs for errors
|
||||
|
||||
### MCP Session Creation Fails (401 Unauthorized)
|
||||
**Solution**: This issue has been fixed! OAuth tokens are now properly passed as Authorization headers when creating MCP sessions.
|
||||
|
||||
If you still see 401 errors:
|
||||
- Rebuild the mcp-oauth container: `docker-compose up --build -d mcp-oauth`
|
||||
- Verify OAuth tokens are being acquired successfully in verbose mode
|
||||
- Check that the token hasn't expired (use shorter test durations during troubleshooting)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [x] Dynamic user creation (beyond 4 default users) - **COMPLETED**
|
||||
- [x] OAuth token injection for MCP sessions - **COMPLETED**
|
||||
- [x] Cancel scope error handling - **COMPLETED**
|
||||
- [x] Concurrent user creation and authentication - **COMPLETED** (3-4x speedup!)
|
||||
- [ ] Workflow templates for common patterns
|
||||
- [ ] Real-time dashboard for live monitoring
|
||||
- [ ] Historical comparison and regression detection
|
||||
- [ ] Load ramping (gradual user increase)
|
||||
- [ ] Geographic distribution simulation (latency injection)
|
||||
- [ ] Improve cleanup reliability in finally block
|
||||
@@ -0,0 +1 @@
|
||||
"""Load testing utilities for Nextcloud MCP Server."""
|
||||
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Load testing benchmark for Nextcloud MCP Server.
|
||||
|
||||
Usage:
|
||||
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
|
||||
uv run python -m tests.load.benchmark -c 50 -d 300 --output results.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import click
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from tests.load.workloads import MixedWorkload, OperationResult, WorkloadOperations
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BenchmarkMetrics:
|
||||
"""Collect and analyze benchmark metrics."""
|
||||
|
||||
def __init__(self):
|
||||
self.results: list[OperationResult] = []
|
||||
self.start_time: float | None = None
|
||||
self.end_time: float | None = None
|
||||
self._operation_counts: Counter = Counter()
|
||||
self._operation_errors: Counter = Counter()
|
||||
|
||||
def add_result(self, result: OperationResult):
|
||||
"""Add a single operation result."""
|
||||
self.results.append(result)
|
||||
self._operation_counts[result.operation] += 1
|
||||
if not result.success:
|
||||
self._operation_errors[result.operation] += 1
|
||||
|
||||
def start(self):
|
||||
"""Mark the start of the benchmark."""
|
||||
self.start_time = time.time()
|
||||
|
||||
def stop(self):
|
||||
"""Mark the end of the benchmark."""
|
||||
self.end_time = time.time()
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Total benchmark duration in seconds."""
|
||||
if self.start_time is None or self.end_time is None:
|
||||
return 0.0
|
||||
return self.end_time - self.start_time
|
||||
|
||||
@property
|
||||
def total_requests(self) -> int:
|
||||
"""Total number of requests made."""
|
||||
return len(self.results)
|
||||
|
||||
@property
|
||||
def successful_requests(self) -> int:
|
||||
"""Number of successful requests."""
|
||||
return sum(1 for r in self.results if r.success)
|
||||
|
||||
@property
|
||||
def failed_requests(self) -> int:
|
||||
"""Number of failed requests."""
|
||||
return sum(1 for r in self.results if not r.success)
|
||||
|
||||
@property
|
||||
def error_rate(self) -> float:
|
||||
"""Error rate as a percentage."""
|
||||
if self.total_requests == 0:
|
||||
return 0.0
|
||||
return (self.failed_requests / self.total_requests) * 100
|
||||
|
||||
@property
|
||||
def requests_per_second(self) -> float:
|
||||
"""Average requests per second."""
|
||||
if self.duration == 0:
|
||||
return 0.0
|
||||
return self.total_requests / self.duration
|
||||
|
||||
def latency_stats(self) -> dict[str, float]:
|
||||
"""Calculate latency statistics."""
|
||||
if not self.results:
|
||||
return {
|
||||
"min": 0.0,
|
||||
"max": 0.0,
|
||||
"mean": 0.0,
|
||||
"median": 0.0,
|
||||
"p90": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
}
|
||||
|
||||
durations = [r.duration for r in self.results]
|
||||
sorted_durations = sorted(durations)
|
||||
|
||||
def percentile(data: list[float], p: float) -> float:
|
||||
k = (len(data) - 1) * p
|
||||
f = int(k)
|
||||
c = f + 1
|
||||
if c >= len(data):
|
||||
return data[-1]
|
||||
return data[f] + (k - f) * (data[c] - data[f])
|
||||
|
||||
return {
|
||||
"min": min(durations),
|
||||
"max": max(durations),
|
||||
"mean": statistics.mean(durations),
|
||||
"median": statistics.median(durations),
|
||||
"p90": percentile(sorted_durations, 0.90),
|
||||
"p95": percentile(sorted_durations, 0.95),
|
||||
"p99": percentile(sorted_durations, 0.99),
|
||||
}
|
||||
|
||||
def operation_breakdown(self) -> dict[str, dict[str, Any]]:
|
||||
"""Get per-operation statistics."""
|
||||
breakdown = {}
|
||||
for op_name in self._operation_counts:
|
||||
op_results = [r for r in self.results if r.operation == op_name]
|
||||
op_durations = [r.duration for r in op_results if r.success]
|
||||
|
||||
if op_durations:
|
||||
sorted_durations = sorted(op_durations)
|
||||
p50 = statistics.median(sorted_durations)
|
||||
p95_idx = int(len(sorted_durations) * 0.95)
|
||||
p95 = sorted_durations[min(p95_idx, len(sorted_durations) - 1)]
|
||||
else:
|
||||
p50 = p95 = 0.0
|
||||
|
||||
breakdown[op_name] = {
|
||||
"count": self._operation_counts[op_name],
|
||||
"errors": self._operation_errors[op_name],
|
||||
"success_rate": (
|
||||
(self._operation_counts[op_name] - self._operation_errors[op_name])
|
||||
/ self._operation_counts[op_name]
|
||||
* 100
|
||||
),
|
||||
"p50_latency": p50,
|
||||
"p95_latency": p95,
|
||||
}
|
||||
|
||||
return breakdown
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert metrics to dictionary for JSON export."""
|
||||
return {
|
||||
"summary": {
|
||||
"duration": self.duration,
|
||||
"total_requests": self.total_requests,
|
||||
"successful_requests": self.successful_requests,
|
||||
"failed_requests": self.failed_requests,
|
||||
"error_rate": self.error_rate,
|
||||
"requests_per_second": self.requests_per_second,
|
||||
},
|
||||
"latency": self.latency_stats(),
|
||||
"operations": self.operation_breakdown(),
|
||||
}
|
||||
|
||||
def print_report(self):
|
||||
"""Print human-readable benchmark report."""
|
||||
print("\n" + "=" * 80)
|
||||
print("BENCHMARK RESULTS")
|
||||
print("=" * 80)
|
||||
|
||||
print(f"\nDuration: {self.duration:.2f}s")
|
||||
print(f"Total Requests: {self.total_requests}")
|
||||
print(f"Successful: {self.successful_requests}")
|
||||
print(f"Failed: {self.failed_requests}")
|
||||
print(f"Error Rate: {self.error_rate:.2f}%")
|
||||
print(f"Requests/Second: {self.requests_per_second:.2f}")
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("LATENCY (seconds)")
|
||||
print("-" * 80)
|
||||
latency = self.latency_stats()
|
||||
print(f"Min: {latency['min']:.4f}s")
|
||||
print(f"Mean: {latency['mean']:.4f}s")
|
||||
print(f"Median: {latency['median']:.4f}s")
|
||||
print(f"P90: {latency['p90']:.4f}s")
|
||||
print(f"P95: {latency['p95']:.4f}s")
|
||||
print(f"P99: {latency['p99']:.4f}s")
|
||||
print(f"Max: {latency['max']:.4f}s")
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("OPERATION BREAKDOWN")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"{'Operation':<25} {'Count':>8} {'Errors':>8} {'Success':>9} {'P50':>10} {'P95':>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
breakdown = self.operation_breakdown()
|
||||
for op_name, stats in sorted(breakdown.items()):
|
||||
print(
|
||||
f"{op_name:<25} {stats['count']:>8} {stats['errors']:>8} "
|
||||
f"{stats['success_rate']:>8.1f}% {stats['p50_latency']:>9.4f}s {stats['p95_latency']:>9.4f}s"
|
||||
)
|
||||
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_mcp_session(url: str):
|
||||
"""Create an MCP client session with proper cleanup."""
|
||||
logger.info(f"Creating MCP client session for {url}")
|
||||
streamable_context = streamablehttp_client(url)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("MCP client session initialized")
|
||||
yield session
|
||||
finally:
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing streamable context: {e}")
|
||||
|
||||
|
||||
async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
|
||||
"""Wait for MCP server to be ready."""
|
||||
logger.info(f"Waiting for MCP server at {url}...")
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
async with create_mcp_session(url) as session:
|
||||
# Try to get capabilities
|
||||
await session.read_resource("nc://capabilities")
|
||||
logger.info("MCP server is ready")
|
||||
return True
|
||||
except Exception as e:
|
||||
if attempt < max_attempts:
|
||||
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
|
||||
await anyio.sleep(2)
|
||||
else:
|
||||
logger.error(f"MCP server not ready after {max_attempts} attempts")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def benchmark_worker(
|
||||
worker_id: int,
|
||||
url: str,
|
||||
duration: float,
|
||||
metrics: BenchmarkMetrics,
|
||||
stop_event: anyio.Event,
|
||||
):
|
||||
"""Single worker that runs operations for the specified duration."""
|
||||
logger.info(f"Worker {worker_id} starting...")
|
||||
|
||||
try:
|
||||
async with create_mcp_session(url) as session:
|
||||
ops = WorkloadOperations(session)
|
||||
workload = MixedWorkload(ops)
|
||||
|
||||
# Warmup
|
||||
await workload.warmup(count=5)
|
||||
|
||||
# Run operations until duration expires or stop event is set
|
||||
start_time = time.time()
|
||||
operation_count = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
if time.time() - start_time >= duration:
|
||||
break
|
||||
|
||||
result = await workload.run_operation()
|
||||
metrics.add_result(result)
|
||||
operation_count += 1
|
||||
|
||||
# Small delay to prevent overwhelming the server
|
||||
await anyio.sleep(0.01)
|
||||
|
||||
# Cleanup
|
||||
await ops.cleanup()
|
||||
|
||||
logger.info(f"Worker {worker_id} completed {operation_count} operations")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def run_benchmark(
|
||||
url: str,
|
||||
concurrency: int,
|
||||
duration: float,
|
||||
warmup: float = 5.0,
|
||||
) -> BenchmarkMetrics:
|
||||
"""Run the benchmark with specified parameters."""
|
||||
metrics = BenchmarkMetrics()
|
||||
stop_event = anyio.Event()
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
def signal_handler(sig, frame):
|
||||
logger.warning("Received interrupt signal, stopping benchmark...")
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print(
|
||||
f"\nStarting benchmark with {concurrency} concurrent workers for {duration}s..."
|
||||
)
|
||||
print(f"Target: {url}")
|
||||
print(f"Warmup period: {warmup}s\n")
|
||||
|
||||
# Warmup period
|
||||
if warmup > 0:
|
||||
print("Warming up...")
|
||||
await anyio.sleep(warmup)
|
||||
|
||||
# Start metrics collection
|
||||
metrics.start()
|
||||
|
||||
# Create and run workers using anyio task groups
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start all workers
|
||||
for i in range(concurrency):
|
||||
tg.start_soon(benchmark_worker, i, url, duration, metrics, stop_event)
|
||||
|
||||
# Show progress
|
||||
tg.start_soon(show_progress, duration, metrics, stop_event)
|
||||
|
||||
# Stop metrics (tasks already completed when task group exits)
|
||||
metrics.stop()
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
async def show_progress(
|
||||
duration: float,
|
||||
metrics: BenchmarkMetrics,
|
||||
stop_event: anyio.Event,
|
||||
):
|
||||
"""Show real-time progress during benchmark."""
|
||||
start_time = time.time()
|
||||
|
||||
while not stop_event.is_set():
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= duration:
|
||||
break
|
||||
|
||||
# Calculate progress
|
||||
progress = min(elapsed / duration * 100, 100)
|
||||
rps = metrics.total_requests / max(elapsed, 0.1)
|
||||
|
||||
# Print progress bar
|
||||
bar_length = 40
|
||||
filled = int(bar_length * progress / 100)
|
||||
bar = "█" * filled + "░" * (bar_length - filled)
|
||||
|
||||
print(
|
||||
f"\r[{bar}] {progress:5.1f}% | "
|
||||
f"Requests: {metrics.total_requests:6d} | "
|
||||
f"RPS: {rps:6.1f} | "
|
||||
f"Errors: {metrics.failed_requests:4d}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
print() # New line after progress
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--concurrency",
|
||||
"-c",
|
||||
type=int,
|
||||
default=10,
|
||||
show_default=True,
|
||||
help="Number of concurrent workers",
|
||||
)
|
||||
@click.option(
|
||||
"--duration",
|
||||
"-d",
|
||||
type=float,
|
||||
default=30.0,
|
||||
show_default=True,
|
||||
help="Test duration in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--warmup",
|
||||
"-w",
|
||||
type=float,
|
||||
default=5.0,
|
||||
show_default=True,
|
||||
help="Warmup duration before collecting metrics (seconds)",
|
||||
)
|
||||
@click.option(
|
||||
"--url",
|
||||
"-u",
|
||||
default="http://127.0.0.1:8000/mcp",
|
||||
show_default=True,
|
||||
help="MCP server URL",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(),
|
||||
help="Output file for JSON results (optional)",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-for-server/--no-wait",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Wait for MCP server to be ready before starting",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
help="Enable verbose logging",
|
||||
)
|
||||
def main(
|
||||
concurrency: int,
|
||||
duration: float,
|
||||
warmup: float,
|
||||
url: str,
|
||||
output: str | None,
|
||||
wait_for_server: bool,
|
||||
verbose: bool,
|
||||
):
|
||||
"""
|
||||
Load testing benchmark for Nextcloud MCP Server.
|
||||
|
||||
Runs a mixed workload of realistic MCP operations against the server
|
||||
and reports detailed performance metrics.
|
||||
|
||||
Examples:
|
||||
|
||||
# Quick 30-second test with 10 workers
|
||||
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
|
||||
|
||||
# Extended test with 50 workers for 5 minutes
|
||||
uv run python -m tests.load.benchmark -c 50 -d 300
|
||||
|
||||
# Export results to JSON
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||
|
||||
# Test OAuth server on port 8001
|
||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||
"""
|
||||
if verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.getLogger("tests.load").setLevel(logging.DEBUG)
|
||||
|
||||
async def run():
|
||||
# Wait for server if requested
|
||||
if wait_for_server:
|
||||
if not await wait_for_mcp_server(url):
|
||||
print("ERROR: MCP server is not ready", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Run benchmark
|
||||
metrics = await run_benchmark(url, concurrency, duration, warmup)
|
||||
|
||||
# Print report
|
||||
metrics.print_report()
|
||||
|
||||
# Export to JSON if requested
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
json.dump(metrics.to_dict(), f, indent=2)
|
||||
print(f"Results exported to: {output}")
|
||||
|
||||
try:
|
||||
anyio.run(run)
|
||||
except KeyboardInterrupt:
|
||||
print("\nBenchmark interrupted by user")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
if verbose:
|
||||
raise
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cleanup utility for loadtest users.
|
||||
|
||||
Searches for and deletes all users with 'loadtest' prefix in their username.
|
||||
Useful for cleaning up after failed benchmark runs.
|
||||
|
||||
Usage:
|
||||
uv run python -m tests.load.cleanup_loadtest_users
|
||||
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
|
||||
uv run python -m tests.load.cleanup_loadtest_users --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import anyio
|
||||
import click
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
async def cleanup_users(prefix: str = "loadtest", dry_run: bool = False):
|
||||
"""
|
||||
Search for and delete users with the specified prefix.
|
||||
|
||||
Args:
|
||||
prefix: Username prefix to search for
|
||||
dry_run: If True, only list users without deleting them
|
||||
"""
|
||||
print(f"Searching for users with prefix '{prefix}'...")
|
||||
|
||||
try:
|
||||
client = NextcloudClient.from_env()
|
||||
users = await client.users.search_users(search=prefix)
|
||||
|
||||
if not users:
|
||||
print(f"✓ No users found with prefix '{prefix}'")
|
||||
return
|
||||
|
||||
print(f"Found {len(users)} user(s): {', '.join(users)}\n")
|
||||
|
||||
if dry_run:
|
||||
print("DRY RUN - No users will be deleted")
|
||||
for user in users:
|
||||
print(f" Would delete: {user}")
|
||||
print("\nTo actually delete these users, run without --dry-run flag")
|
||||
return
|
||||
|
||||
# Delete users
|
||||
deleted = []
|
||||
failed = []
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
print(f" Deleting {user}...")
|
||||
await client.users.delete_user(userid=user)
|
||||
deleted.append(user)
|
||||
print(f" ✓ Deleted {user}")
|
||||
except Exception as e:
|
||||
failed.append((user, str(e)))
|
||||
print(f" ✗ Failed to delete {user}: {e}")
|
||||
|
||||
# Summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Cleanup Summary")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Successfully deleted: {len(deleted)}")
|
||||
print(f"Failed to delete: {len(failed)}")
|
||||
|
||||
if failed:
|
||||
print("\nFailed deletions:")
|
||||
for user, error in failed:
|
||||
print(f" - {user}: {error}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✓ All users cleaned up successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--prefix",
|
||||
default="loadtest",
|
||||
show_default=True,
|
||||
help="Username prefix to search for",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="List users without deleting them",
|
||||
)
|
||||
def main(prefix: str, dry_run: bool):
|
||||
"""
|
||||
Cleanup loadtest users from Nextcloud.
|
||||
|
||||
Searches for all users with the specified prefix and deletes them.
|
||||
Useful for cleaning up after failed benchmark runs.
|
||||
|
||||
Examples:
|
||||
|
||||
# Dry run to see what would be deleted
|
||||
uv run python -m tests.load.cleanup_loadtest_users --dry-run
|
||||
|
||||
# Delete all loadtest users
|
||||
uv run python -m tests.load.cleanup_loadtest_users
|
||||
|
||||
# Delete users with custom prefix
|
||||
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
|
||||
"""
|
||||
anyio.run(cleanup_users, prefix, dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OAuth Multi-User Load Testing for Nextcloud MCP Server.
|
||||
|
||||
Simulates realistic multi-user scenarios with coordinated workflows
|
||||
like note sharing, collaborative editing, and file operations.
|
||||
|
||||
Usage:
|
||||
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
|
||||
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import httpx
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
|
||||
from tests.load.oauth_pool import (
|
||||
OAuthUserPool,
|
||||
UserSessionWrapper,
|
||||
generate_secure_password,
|
||||
)
|
||||
from tests.load.oauth_workloads import MixedOAuthWorkload, WorkflowResult
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthCallbackServer:
|
||||
"""
|
||||
Temporary HTTP server to capture OAuth authorization codes.
|
||||
|
||||
Runs in a background thread, captures auth codes via state parameter
|
||||
correlation, and stores them in a shared dictionary.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 8081):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.auth_states: dict[str, str] = {}
|
||||
self.server: HTTPServer | None = None
|
||||
self.thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
"""Start the callback server in a background thread."""
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
auth_states = self.auth_states
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/callback":
|
||||
params = parse_qs(parsed.query)
|
||||
code = params.get("code", [None])[0]
|
||||
state = params.get("state", [None])[0]
|
||||
|
||||
if code and state:
|
||||
self.auth_states[state] = code
|
||||
logger.info(f"Captured auth code for state {state[:16]}...")
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<html><body><h1>Authorization successful!</h1>"
|
||||
b"<p>You can close this window.</p></body></html>"
|
||||
)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Suppress default logging
|
||||
pass
|
||||
|
||||
self.server = HTTPServer((self.host, self.port), CallbackHandler)
|
||||
|
||||
def run():
|
||||
logger.info(f"OAuth callback server listening on {self.host}:{self.port}")
|
||||
self.server.serve_forever()
|
||||
|
||||
self.thread = threading.Thread(target=run, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info("OAuth callback server started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the callback server."""
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
logger.info("OAuth callback server stopped")
|
||||
|
||||
def get_auth_code(self, state: str) -> str | None:
|
||||
"""Get auth code for a given state parameter."""
|
||||
return self.auth_states.get(state)
|
||||
|
||||
|
||||
async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
|
||||
"""
|
||||
Discover OIDC endpoints from Nextcloud's .well-known configuration.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Nextcloud host URL (e.g., http://localhost:8080)
|
||||
|
||||
Returns:
|
||||
Dict with authorization_endpoint, token_endpoint, and registration_endpoint
|
||||
"""
|
||||
logger.info("Discovering OIDC endpoints...")
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
|
||||
endpoints = {
|
||||
"authorization_endpoint": config["authorization_endpoint"],
|
||||
"token_endpoint": config["token_endpoint"],
|
||||
"registration_endpoint": config["registration_endpoint"],
|
||||
}
|
||||
logger.info(f"Discovered endpoints: {endpoints}")
|
||||
return endpoints
|
||||
|
||||
|
||||
async def setup_oauth_client(
|
||||
nextcloud_host: str, callback_url: str, registration_endpoint: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Setup OAuth client using load_or_register_client.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Nextcloud host URL
|
||||
callback_url: OAuth callback URL
|
||||
registration_endpoint: OAuth registration endpoint URL
|
||||
|
||||
Returns:
|
||||
Dict with client_id and client_secret
|
||||
"""
|
||||
logger.info("Setting up OAuth client...")
|
||||
|
||||
# Use the client registration utility
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=".nextcloud_oauth_benchmark_client.json",
|
||||
client_name="OAuth Benchmark Test Client",
|
||||
redirect_uris=[callback_url],
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client setup complete (client_id: {client_info.client_id})")
|
||||
return {
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
}
|
||||
|
||||
|
||||
async def create_and_authenticate_user(
|
||||
user_pool: OAuthUserPool,
|
||||
browser: Any,
|
||||
auth_states: dict[str, str],
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create Nextcloud user and acquire OAuth token via Playwright.
|
||||
|
||||
Args:
|
||||
user_pool: OAuthUserPool instance
|
||||
browser: Playwright browser instance
|
||||
auth_states: Shared auth_states dict for callback server
|
||||
username: Username to create
|
||||
password: Password for the user
|
||||
display_name: Optional display name
|
||||
|
||||
Returns:
|
||||
OAuth access token for the user
|
||||
"""
|
||||
logger.info(f"Creating and authenticating user: {username}")
|
||||
|
||||
# Create Nextcloud user
|
||||
await user_pool.create_nextcloud_user(
|
||||
username=username,
|
||||
password=password,
|
||||
display_name=display_name or username,
|
||||
)
|
||||
|
||||
# Generate unique state for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Acquire OAuth token via Playwright
|
||||
token = await user_pool.acquire_token_playwright(
|
||||
browser=browser,
|
||||
username=username,
|
||||
password=password,
|
||||
state=state,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully authenticated user: {username}")
|
||||
return token
|
||||
|
||||
|
||||
async def oauth_benchmark_worker(
|
||||
user_wrapper: UserSessionWrapper,
|
||||
workload: MixedOAuthWorkload,
|
||||
duration: float,
|
||||
metrics: OAuthBenchmarkMetrics,
|
||||
stop_event: anyio.Event,
|
||||
):
|
||||
"""
|
||||
Single worker executing operations for one user.
|
||||
|
||||
Args:
|
||||
user_wrapper: UserSessionWrapper for this worker
|
||||
workload: MixedOAuthWorkload instance
|
||||
duration: Test duration in seconds
|
||||
metrics: Metrics collector
|
||||
stop_event: Event to signal stop
|
||||
"""
|
||||
logger.info(f"Worker for {user_wrapper.username} starting...")
|
||||
|
||||
start_time = time.time()
|
||||
operation_count = 0
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if time.time() - start_time >= duration:
|
||||
break
|
||||
|
||||
# Run an operation (might be baseline or workflow)
|
||||
result = await workload.run_operation()
|
||||
|
||||
# Record metrics
|
||||
if isinstance(result, WorkflowResult):
|
||||
metrics.add_workflow_result(result)
|
||||
else:
|
||||
# Baseline operation
|
||||
metrics.add_baseline_operation(result)
|
||||
|
||||
operation_count += 1
|
||||
|
||||
# Small delay to prevent overwhelming the server
|
||||
await anyio.sleep(0.05)
|
||||
|
||||
logger.info(
|
||||
f"Worker for {user_wrapper.username} completed {operation_count} operations"
|
||||
)
|
||||
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Handle task cancellation gracefully (e.g., during benchmark shutdown)
|
||||
logger.info(
|
||||
f"Worker for {user_wrapper.username} was cancelled "
|
||||
f"(completed {operation_count} operations)"
|
||||
)
|
||||
raise # Re-raise to allow proper cleanup
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {user_wrapper.username} error: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def show_progress(
|
||||
duration: float,
|
||||
metrics: OAuthBenchmarkMetrics,
|
||||
stop_event: anyio.Event,
|
||||
):
|
||||
"""Show real-time progress during benchmark."""
|
||||
start_time = time.time()
|
||||
|
||||
while not stop_event.is_set():
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= duration:
|
||||
break
|
||||
|
||||
# Calculate progress
|
||||
progress = min(elapsed / duration * 100, 100)
|
||||
total_ops = len(metrics.baseline_operations) + len(metrics.workflows)
|
||||
workflows = len(metrics.workflows)
|
||||
|
||||
# Print progress bar
|
||||
bar_length = 40
|
||||
filled = int(bar_length * progress / 100)
|
||||
bar = "█" * filled + "░" * (bar_length - filled)
|
||||
|
||||
print(
|
||||
f"\r[{bar}] {progress:5.1f}% | "
|
||||
f"Total Ops: {total_ops:6d} | "
|
||||
f"Workflows: {workflows:4d}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
print() # New line after progress
|
||||
|
||||
|
||||
async def run_oauth_benchmark(
|
||||
num_users: int,
|
||||
duration: float,
|
||||
mcp_url: str,
|
||||
warmup: float = 5.0,
|
||||
user_prefix: str = "loadtest",
|
||||
cleanup: bool = True,
|
||||
browser_type: str = "firefox",
|
||||
headed: bool = False,
|
||||
) -> OAuthBenchmarkMetrics:
|
||||
"""
|
||||
Run the OAuth multi-user benchmark with dynamic user creation.
|
||||
|
||||
Args:
|
||||
num_users: Number of concurrent users to create
|
||||
duration: Test duration in seconds
|
||||
mcp_url: MCP server URL
|
||||
warmup: Warmup period in seconds
|
||||
user_prefix: Prefix for generated usernames
|
||||
cleanup: Whether to delete users after benchmark
|
||||
browser_type: Playwright browser type (firefox, chromium, webkit)
|
||||
headed: Whether to run browser in headed mode
|
||||
|
||||
Returns:
|
||||
OAuthBenchmarkMetrics with results
|
||||
"""
|
||||
metrics = OAuthBenchmarkMetrics()
|
||||
stop_event = anyio.Event()
|
||||
created_users: list[str] = []
|
||||
callback_server: OAuthCallbackServer | None = None
|
||||
user_pool: OAuthUserPool | None = None
|
||||
admin_client: NextcloudClient | None = None
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
def signal_handler(sig, frame):
|
||||
logger.warning("Received interrupt signal, stopping benchmark...")
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print("OAUTH MULTI-USER BENCHMARK")
|
||||
print(f"{'=' * 80}")
|
||||
print(f"Users: {num_users} | Duration: {duration}s | Warmup: {warmup}s")
|
||||
print(f"Target: {mcp_url}")
|
||||
print(f"User Prefix: {user_prefix} | Cleanup: {cleanup}")
|
||||
print(f"Browser: {browser_type} | Headed: {headed}")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
try:
|
||||
# Get environment variables
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
callback_url = "http://127.0.0.1:8081/callback"
|
||||
|
||||
# Step 1: Start OAuth callback server
|
||||
print("Step 1/6: Starting OAuth callback server...")
|
||||
callback_server = OAuthCallbackServer(host="127.0.0.1", port=8081)
|
||||
callback_server.start()
|
||||
print("✓ Callback server listening on http://127.0.0.1:8081\n")
|
||||
|
||||
# Step 2: Discover OIDC endpoints
|
||||
print("Step 2/6: Discovering OIDC endpoints...")
|
||||
endpoints = await discover_oidc_endpoints(nextcloud_host)
|
||||
print(f"✓ Authorization endpoint: {endpoints['authorization_endpoint']}")
|
||||
print(f"✓ Token endpoint: {endpoints['token_endpoint']}")
|
||||
print(f"✓ Registration endpoint: {endpoints['registration_endpoint']}\n")
|
||||
|
||||
# Step 3: Setup OAuth client
|
||||
print("Step 3/6: Setting up OAuth client...")
|
||||
oauth_credentials = await setup_oauth_client(
|
||||
nextcloud_host, callback_url, endpoints["registration_endpoint"]
|
||||
)
|
||||
print(f"✓ OAuth client registered (ID: {oauth_credentials['client_id']})\n")
|
||||
|
||||
# Step 4: Create admin client and user pool
|
||||
print("Step 4/6: Initializing admin client and user pool...")
|
||||
admin_client = NextcloudClient.from_env()
|
||||
user_pool = OAuthUserPool(
|
||||
admin_client=admin_client,
|
||||
client_id=oauth_credentials["client_id"],
|
||||
client_secret=oauth_credentials["client_secret"],
|
||||
callback_url=callback_url,
|
||||
token_endpoint=endpoints["token_endpoint"],
|
||||
authorization_endpoint=endpoints["authorization_endpoint"],
|
||||
)
|
||||
|
||||
async with user_pool:
|
||||
print("✓ User pool initialized\n")
|
||||
|
||||
# Step 5: Create users and acquire OAuth tokens (concurrently)
|
||||
print(f"Step 5/6: Creating {num_users} users and acquiring OAuth tokens...")
|
||||
print("(Running concurrently for faster setup)\n")
|
||||
|
||||
async def create_user_task(
|
||||
i: int, browser, auth_states: dict
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create and authenticate a single user. Returns (username, password, token) or None on failure."""
|
||||
username = f"{user_prefix}_user_{i + 1}"
|
||||
password = generate_secure_password(16)
|
||||
|
||||
print(f" [{i + 1}/{num_users}] Creating user '{username}'...")
|
||||
|
||||
try:
|
||||
token = await create_and_authenticate_user(
|
||||
user_pool=user_pool,
|
||||
browser=browser,
|
||||
auth_states=auth_states,
|
||||
username=username,
|
||||
password=password,
|
||||
display_name=f"Load Test User {i + 1}",
|
||||
)
|
||||
|
||||
print(f" ✓ User '{username}' authenticated\n")
|
||||
return (username, password, token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create/authenticate user {username}: {e}")
|
||||
return None
|
||||
|
||||
async with async_playwright() as p:
|
||||
# Launch browser
|
||||
browser_launcher = getattr(p, browser_type)
|
||||
browser = await browser_launcher.launch(headless=not headed)
|
||||
|
||||
try:
|
||||
# Create all users concurrently using anyio task groups
|
||||
results = []
|
||||
|
||||
async def run_and_collect(i: int):
|
||||
"""Wrapper to collect results from tasks."""
|
||||
try:
|
||||
result = await create_user_task(
|
||||
i, browser, callback_server.auth_states
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"User creation task failed: {e}")
|
||||
results.append(e)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for i in range(num_users):
|
||||
tg.start_soon(run_and_collect, i)
|
||||
|
||||
# Process results
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"User creation task failed: {result}")
|
||||
continue
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
username, password, token = result
|
||||
await user_pool.add_user(
|
||||
username=username, password=password, token=token
|
||||
)
|
||||
created_users.append(username)
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
if not created_users:
|
||||
raise RuntimeError("Failed to create any users")
|
||||
|
||||
print(
|
||||
f"✓ Successfully created and authenticated {len(created_users)} users\n"
|
||||
)
|
||||
|
||||
# Step 6: Create MCP sessions for each user (concurrently)
|
||||
print("Step 6/6: Creating MCP sessions for users...")
|
||||
user_wrappers = []
|
||||
async with user_pool:
|
||||
|
||||
async def create_session_task(username: str) -> UserSessionWrapper | None:
|
||||
"""Create MCP session for a user. Returns wrapper or None on failure."""
|
||||
try:
|
||||
session = await user_pool.create_user_session(username, mcp_url)
|
||||
wrapper = UserSessionWrapper(username, session, user_pool)
|
||||
print(f" ✓ Session created for '{username}'")
|
||||
return wrapper
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create session for {username}: {e}")
|
||||
return None
|
||||
|
||||
# Create all sessions concurrently using anyio task groups
|
||||
session_results = []
|
||||
|
||||
async def run_and_collect_session(username: str):
|
||||
"""Wrapper to collect session results from tasks."""
|
||||
try:
|
||||
result = await create_session_task(username)
|
||||
session_results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Session creation task failed: {e}")
|
||||
session_results.append(e)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for username in created_users:
|
||||
tg.start_soon(run_and_collect_session, username)
|
||||
|
||||
# Process results
|
||||
for result in session_results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Session creation task failed: {result}")
|
||||
continue
|
||||
if result is not None:
|
||||
user_wrappers.append(result)
|
||||
|
||||
if not user_wrappers:
|
||||
raise RuntimeError("Failed to create any user sessions")
|
||||
|
||||
print(f"✓ Created {len(user_wrappers)} MCP sessions\n")
|
||||
|
||||
# Warmup period
|
||||
if warmup > 0:
|
||||
print(f"Warmup period: {warmup}s...")
|
||||
await anyio.sleep(warmup)
|
||||
print()
|
||||
|
||||
# Start benchmark
|
||||
print(f"{'=' * 80}")
|
||||
print("STARTING BENCHMARK")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
metrics.start()
|
||||
|
||||
# Create workload and workers using anyio task groups
|
||||
workload = MixedOAuthWorkload(user_wrappers)
|
||||
|
||||
# Run workers with progress display
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start all workers
|
||||
for wrapper in user_wrappers:
|
||||
tg.start_soon(
|
||||
oauth_benchmark_worker,
|
||||
wrapper,
|
||||
workload,
|
||||
duration,
|
||||
metrics,
|
||||
stop_event,
|
||||
)
|
||||
|
||||
# Show progress
|
||||
tg.start_soon(show_progress, duration, metrics, stop_event)
|
||||
|
||||
# Tasks already completed when task group exits
|
||||
metrics.stop()
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print("BENCHMARK COMPLETE")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
# Cleanup user sessions
|
||||
print("Closing user sessions...")
|
||||
await user_pool.close_all_sessions()
|
||||
print("✓ All sessions closed\n")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Benchmark error: {e}", exc_info=True)
|
||||
# Don't re-raise here - we want cleanup to run
|
||||
|
||||
finally:
|
||||
# Cleanup callback server
|
||||
if callback_server:
|
||||
try:
|
||||
callback_server.stop()
|
||||
logger.info("OAuth callback server stopped")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping callback server: {e}")
|
||||
|
||||
# Cleanup test users
|
||||
if cleanup and created_users:
|
||||
print(f"\nCleaning up {len(created_users)} test users...")
|
||||
# Create a new admin client for cleanup (don't rely on the existing one)
|
||||
try:
|
||||
cleanup_client = NextcloudClient.from_env()
|
||||
for username in created_users:
|
||||
try:
|
||||
await cleanup_client.users.delete_user(userid=username)
|
||||
print(f" ✓ Deleted user '{username}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete user {username}: {e}")
|
||||
print("✓ Cleanup complete\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during user cleanup: {e}")
|
||||
print(
|
||||
"⚠️ Failed to cleanup users. Please run cleanup script manually.\n"
|
||||
)
|
||||
elif created_users:
|
||||
print(
|
||||
f"\n⚠️ {len(created_users)} test users were NOT deleted (cleanup=False)"
|
||||
)
|
||||
print(f"Users: {', '.join(created_users)}\n")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--users",
|
||||
"-u",
|
||||
type=int,
|
||||
default=2,
|
||||
show_default=True,
|
||||
help="Number of concurrent users to create dynamically",
|
||||
)
|
||||
@click.option(
|
||||
"--duration",
|
||||
"-d",
|
||||
type=float,
|
||||
default=30.0,
|
||||
show_default=True,
|
||||
help="Test duration in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--warmup",
|
||||
"-w",
|
||||
type=float,
|
||||
default=5.0,
|
||||
show_default=True,
|
||||
help="Warmup duration before collecting metrics (seconds)",
|
||||
)
|
||||
@click.option(
|
||||
"--url",
|
||||
default="http://127.0.0.1:8001/mcp",
|
||||
show_default=True,
|
||||
help="MCP OAuth server URL",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(),
|
||||
help="Output file for JSON results (optional)",
|
||||
)
|
||||
@click.option(
|
||||
"--workload",
|
||||
type=click.Choice(["mixed", "sharing", "collaboration", "baseline"]),
|
||||
default="mixed",
|
||||
show_default=True,
|
||||
help="Workload type to execute",
|
||||
)
|
||||
@click.option(
|
||||
"--user-prefix",
|
||||
default="loadtest",
|
||||
show_default=True,
|
||||
help="Prefix for dynamically created usernames",
|
||||
)
|
||||
@click.option(
|
||||
"--cleanup/--no-cleanup",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Delete created users after benchmark",
|
||||
)
|
||||
@click.option(
|
||||
"--browser",
|
||||
type=click.Choice(["firefox", "chromium", "webkit"]),
|
||||
default="firefox",
|
||||
show_default=True,
|
||||
help="Playwright browser type for OAuth automation",
|
||||
)
|
||||
@click.option(
|
||||
"--headed",
|
||||
is_flag=True,
|
||||
help="Run browser in headed mode (visible window, useful for debugging)",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
help="Enable verbose logging",
|
||||
)
|
||||
def main(
|
||||
users: int,
|
||||
duration: float,
|
||||
warmup: float,
|
||||
url: str,
|
||||
output: str | None,
|
||||
workload: str,
|
||||
user_prefix: str,
|
||||
cleanup: bool,
|
||||
browser: str,
|
||||
headed: bool,
|
||||
verbose: bool,
|
||||
):
|
||||
"""
|
||||
OAuth Multi-User Load Testing for Nextcloud MCP Server.
|
||||
|
||||
Dynamically creates N users, authenticates them via OAuth using Playwright
|
||||
browser automation, and simulates realistic multi-user scenarios with
|
||||
coordinated workflows like note sharing, collaborative editing, and file operations.
|
||||
|
||||
Examples:
|
||||
|
||||
# 2 users, 30-second test (default settings)
|
||||
uv run python -m tests.load.oauth_benchmark
|
||||
|
||||
# 4 users, 60-second test with mixed workload
|
||||
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
|
||||
|
||||
# 10 users, 5-minute sharing-focused test
|
||||
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
|
||||
|
||||
# Export results to JSON
|
||||
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
|
||||
|
||||
# Custom user prefix and keep users after benchmark
|
||||
uv run python -m tests.load.oauth_benchmark -u 3 --user-prefix mytest --no-cleanup
|
||||
|
||||
# Debug with visible browser (headed mode)
|
||||
uv run python -m tests.load.oauth_benchmark -u 2 -d 10 --headed --verbose
|
||||
|
||||
Requirements:
|
||||
- docker-compose up (mcp-oauth container running on port 8001)
|
||||
- NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars set
|
||||
- Playwright browser installed: uv run playwright install firefox
|
||||
"""
|
||||
if verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.getLogger("tests.load").setLevel(logging.DEBUG)
|
||||
|
||||
async def run():
|
||||
# Run benchmark
|
||||
metrics = await run_oauth_benchmark(
|
||||
num_users=users,
|
||||
duration=duration,
|
||||
mcp_url=url,
|
||||
warmup=warmup,
|
||||
user_prefix=user_prefix,
|
||||
cleanup=cleanup,
|
||||
browser_type=browser,
|
||||
headed=headed,
|
||||
)
|
||||
|
||||
# Print report
|
||||
metrics.print_report()
|
||||
|
||||
# Export to JSON if requested
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
json.dump(metrics.to_dict(), f, indent=2)
|
||||
print(f"Results exported to: {output}")
|
||||
|
||||
try:
|
||||
anyio.run(run)
|
||||
except KeyboardInterrupt:
|
||||
print("\nBenchmark interrupted by user")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
if verbose:
|
||||
raise
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Enhanced metrics collection for OAuth multi-user load testing.
|
||||
|
||||
Extends the base BenchmarkMetrics to track per-user statistics,
|
||||
workflow completion rates, and cross-user operation latencies.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any
|
||||
|
||||
from tests.load.oauth_workloads import WorkflowResult
|
||||
|
||||
|
||||
class OAuthBenchmarkMetrics:
|
||||
"""
|
||||
Enhanced metrics for OAuth multi-user load testing.
|
||||
|
||||
Tracks:
|
||||
- Per-user operation counts and latencies
|
||||
- Workflow completion rates and timings
|
||||
- Cross-user operation metrics
|
||||
- Step-by-step workflow breakdowns
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Base metrics
|
||||
self.start_time: float | None = None
|
||||
self.end_time: float | None = None
|
||||
|
||||
# Per-user tracking
|
||||
self.user_operations: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
self.user_operation_counts: dict[str, Counter] = defaultdict(Counter)
|
||||
self.user_errors: dict[str, Counter] = defaultdict(Counter)
|
||||
|
||||
# Workflow tracking
|
||||
self.workflows: list[WorkflowResult] = []
|
||||
self.workflow_counts: Counter = Counter()
|
||||
self.workflow_successes: Counter = Counter()
|
||||
self.workflow_durations: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
# Baseline operations (non-workflow)
|
||||
self.baseline_operations: list[dict[str, Any]] = []
|
||||
|
||||
def start(self):
|
||||
"""Mark the start of the benchmark."""
|
||||
import time
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
def stop(self):
|
||||
"""Mark the end of the benchmark."""
|
||||
import time
|
||||
|
||||
self.end_time = time.time()
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Total benchmark duration in seconds."""
|
||||
if self.start_time is None or self.end_time is None:
|
||||
return 0.0
|
||||
return self.end_time - self.start_time
|
||||
|
||||
def add_workflow_result(self, result: WorkflowResult):
|
||||
"""
|
||||
Add a workflow execution result.
|
||||
|
||||
Args:
|
||||
result: WorkflowResult from workflow execution
|
||||
"""
|
||||
self.workflows.append(result)
|
||||
self.workflow_counts[result.workflow_name] += 1
|
||||
if result.success:
|
||||
self.workflow_successes[result.workflow_name] += 1
|
||||
self.workflow_durations[result.workflow_name].append(result.total_duration)
|
||||
|
||||
# Track per-user operations from workflow steps
|
||||
for step in result.steps:
|
||||
self.user_operation_counts[step.user][step.step_name] += 1
|
||||
if not step.success:
|
||||
self.user_errors[step.user][step.step_name] += 1
|
||||
|
||||
self.user_operations[step.user].append(
|
||||
{
|
||||
"type": "workflow_step",
|
||||
"workflow": result.workflow_name,
|
||||
"step": step.step_name,
|
||||
"success": step.success,
|
||||
"duration": step.duration,
|
||||
"error": step.error,
|
||||
}
|
||||
)
|
||||
|
||||
def add_baseline_operation(self, operation: dict[str, Any]):
|
||||
"""
|
||||
Add a baseline (non-workflow) operation result.
|
||||
|
||||
Args:
|
||||
operation: Dict with keys: type, operation, user, success, duration, error (optional)
|
||||
"""
|
||||
self.baseline_operations.append(operation)
|
||||
|
||||
user = operation.get("user", "unknown")
|
||||
op_name = operation.get("operation", "unknown")
|
||||
success = operation.get("success", False)
|
||||
|
||||
self.user_operation_counts[user][op_name] += 1
|
||||
if not success:
|
||||
self.user_errors[user][op_name] += 1
|
||||
|
||||
self.user_operations[user].append(operation)
|
||||
|
||||
def get_user_stats(self) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get per-user statistics.
|
||||
|
||||
Returns:
|
||||
Dict mapping username to their stats
|
||||
"""
|
||||
stats = {}
|
||||
for user, operations in self.user_operations.items():
|
||||
total_ops = len(operations)
|
||||
successful_ops = sum(1 for op in operations if op.get("success", False))
|
||||
durations = [op["duration"] for op in operations if "duration" in op]
|
||||
|
||||
stats[user] = {
|
||||
"total_operations": total_ops,
|
||||
"successful_operations": successful_ops,
|
||||
"failed_operations": total_ops - successful_ops,
|
||||
"success_rate": (successful_ops / total_ops * 100)
|
||||
if total_ops > 0
|
||||
else 0.0,
|
||||
"latency": self._calculate_latency_stats(durations),
|
||||
"operations_breakdown": dict(self.user_operation_counts[user]),
|
||||
"errors_breakdown": dict(self.user_errors[user]),
|
||||
}
|
||||
return stats
|
||||
|
||||
def get_workflow_stats(self) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get workflow execution statistics.
|
||||
|
||||
Returns:
|
||||
Dict mapping workflow name to its stats
|
||||
"""
|
||||
stats = {}
|
||||
for workflow_name in self.workflow_counts:
|
||||
total = self.workflow_counts[workflow_name]
|
||||
successes = self.workflow_successes[workflow_name]
|
||||
durations = self.workflow_durations[workflow_name]
|
||||
|
||||
# Calculate per-step latencies
|
||||
step_latencies = defaultdict(list)
|
||||
for workflow in self.workflows:
|
||||
if workflow.workflow_name == workflow_name:
|
||||
for step in workflow.steps:
|
||||
if step.success:
|
||||
step_latencies[step.step_name].append(step.duration)
|
||||
|
||||
step_stats = {}
|
||||
for step_name, latencies in step_latencies.items():
|
||||
if latencies:
|
||||
step_stats[step_name] = self._calculate_latency_stats(latencies)
|
||||
|
||||
stats[workflow_name] = {
|
||||
"total_executions": total,
|
||||
"successful_executions": successes,
|
||||
"failed_executions": total - successes,
|
||||
"success_rate": (successes / total * 100) if total > 0 else 0.0,
|
||||
"latency": self._calculate_latency_stats(durations),
|
||||
"step_latencies": step_stats,
|
||||
}
|
||||
return stats
|
||||
|
||||
def get_baseline_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics for baseline operations.
|
||||
|
||||
Returns:
|
||||
Dict with baseline operation stats
|
||||
"""
|
||||
if not self.baseline_operations:
|
||||
return {
|
||||
"total_operations": 0,
|
||||
"success_rate": 0.0,
|
||||
"latency": self._calculate_latency_stats([]),
|
||||
}
|
||||
|
||||
total = len(self.baseline_operations)
|
||||
successes = sum(
|
||||
1 for op in self.baseline_operations if op.get("success", False)
|
||||
)
|
||||
durations = [
|
||||
op["duration"] for op in self.baseline_operations if "duration" in op
|
||||
]
|
||||
|
||||
# Per-operation breakdown
|
||||
operation_counts = Counter()
|
||||
operation_errors = Counter()
|
||||
for op in self.baseline_operations:
|
||||
op_name = op.get("operation", "unknown")
|
||||
operation_counts[op_name] += 1
|
||||
if not op.get("success", False):
|
||||
operation_errors[op_name] += 1
|
||||
|
||||
return {
|
||||
"total_operations": total,
|
||||
"successful_operations": successes,
|
||||
"failed_operations": total - successes,
|
||||
"success_rate": (successes / total * 100) if total > 0 else 0.0,
|
||||
"latency": self._calculate_latency_stats(durations),
|
||||
"operations_breakdown": dict(operation_counts),
|
||||
"errors_breakdown": dict(operation_errors),
|
||||
}
|
||||
|
||||
def _calculate_latency_stats(self, durations: list[float]) -> dict[str, float]:
|
||||
"""Calculate latency statistics from a list of durations."""
|
||||
if not durations:
|
||||
return {
|
||||
"min": 0.0,
|
||||
"max": 0.0,
|
||||
"mean": 0.0,
|
||||
"median": 0.0,
|
||||
"p90": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
}
|
||||
|
||||
sorted_durations = sorted(durations)
|
||||
|
||||
def percentile(data: list[float], p: float) -> float:
|
||||
k = (len(data) - 1) * p
|
||||
f = int(k)
|
||||
c = f + 1
|
||||
if c >= len(data):
|
||||
return data[-1]
|
||||
return data[f] + (k - f) * (data[c] - data[f])
|
||||
|
||||
return {
|
||||
"min": min(durations),
|
||||
"max": max(durations),
|
||||
"mean": statistics.mean(durations),
|
||||
"median": statistics.median(durations),
|
||||
"p90": percentile(sorted_durations, 0.90),
|
||||
"p95": percentile(sorted_durations, 0.95),
|
||||
"p99": percentile(sorted_durations, 0.99),
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert metrics to dictionary for JSON export."""
|
||||
return {
|
||||
"summary": {
|
||||
"duration": self.duration,
|
||||
"total_workflows": len(self.workflows),
|
||||
"total_baseline_ops": len(self.baseline_operations),
|
||||
"total_users": len(self.user_operations),
|
||||
},
|
||||
"workflows": self.get_workflow_stats(),
|
||||
"baseline": self.get_baseline_stats(),
|
||||
"users": self.get_user_stats(),
|
||||
}
|
||||
|
||||
def print_report(self):
|
||||
"""Print human-readable benchmark report."""
|
||||
print("\n" + "=" * 80)
|
||||
print("OAUTH MULTI-USER BENCHMARK RESULTS")
|
||||
print("=" * 80)
|
||||
|
||||
# Summary
|
||||
print(f"\nDuration: {self.duration:.2f}s")
|
||||
print(f"Total Users: {len(self.user_operations)}")
|
||||
print(f"Total Workflows Executed: {len(self.workflows)}")
|
||||
print(f"Total Baseline Operations: {len(self.baseline_operations)}")
|
||||
|
||||
# Workflow Stats
|
||||
if self.workflows:
|
||||
print("\n" + "-" * 80)
|
||||
print("WORKFLOW STATISTICS")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"{'Workflow':<30} {'Total':>8} {'Success':>8} {'Rate':>8} {'P50':>10} {'P95':>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
workflow_stats = self.get_workflow_stats()
|
||||
for name, stats in sorted(workflow_stats.items()):
|
||||
latency = stats["latency"]
|
||||
print(
|
||||
f"{name:<30} {stats['total_executions']:>8} "
|
||||
f"{stats['successful_executions']:>8} "
|
||||
f"{stats['success_rate']:>7.1f}% "
|
||||
f"{latency['median']:>9.4f}s {latency['p95']:>9.4f}s"
|
||||
)
|
||||
|
||||
# Per-User Stats
|
||||
print("\n" + "-" * 80)
|
||||
print("PER-USER STATISTICS")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"{'User':<20} {'Total Ops':>10} {'Success':>10} {'Errors':>8} {'Rate':>8} {'P50':>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
user_stats = self.get_user_stats()
|
||||
for username, stats in sorted(user_stats.items()):
|
||||
latency = stats["latency"]
|
||||
print(
|
||||
f"{username:<20} {stats['total_operations']:>10} "
|
||||
f"{stats['successful_operations']:>10} "
|
||||
f"{stats['failed_operations']:>8} "
|
||||
f"{stats['success_rate']:>7.1f}% "
|
||||
f"{latency['median']:>9.4f}s"
|
||||
)
|
||||
|
||||
# Baseline Stats
|
||||
if self.baseline_operations:
|
||||
print("\n" + "-" * 80)
|
||||
print("BASELINE OPERATIONS")
|
||||
print("-" * 80)
|
||||
baseline = self.get_baseline_stats()
|
||||
print(f"Total Operations: {baseline['total_operations']}")
|
||||
print(f"Success Rate: {baseline['success_rate']:.1f}%")
|
||||
latency = baseline["latency"]
|
||||
print(
|
||||
f"Latency: min={latency['min']:.4f}s, p50={latency['median']:.4f}s, "
|
||||
f"p95={latency['p95']:.4f}s, max={latency['max']:.4f}s"
|
||||
)
|
||||
|
||||
print("=" * 80 + "\n")
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
OAuth User Pool Management for Load Testing.
|
||||
|
||||
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserConfig:
|
||||
"""Configuration for a single test user."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
display_name: str
|
||||
email: str
|
||||
groups: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
"""Profile for an OAuth-authenticated user."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
token: str
|
||||
session: ClientSession | None = None
|
||||
streamable_context: Any | None = None # Store for proper cleanup
|
||||
operation_count: int = 0
|
||||
error_count: int = 0
|
||||
|
||||
|
||||
class OAuthUserPool:
|
||||
"""
|
||||
Manages a pool of OAuth-authenticated users for load testing.
|
||||
|
||||
Handles token acquisition, session management, and user lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
admin_client: Any, # NextcloudClient with admin credentials
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
callback_url: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
):
|
||||
self.admin_client = admin_client # For user management
|
||||
self.nextcloud_host = str(admin_client._client.base_url)
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.callback_url = callback_url
|
||||
self.token_endpoint = token_endpoint
|
||||
self.authorization_endpoint = authorization_endpoint
|
||||
self.users: dict[str, UserProfile] = {}
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Initialize HTTP client."""
|
||||
self._http_client = httpx.AsyncClient(verify=False, timeout=30.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Cleanup HTTP client."""
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
|
||||
async def acquire_token(self, username: str, password: str, auth_code: str) -> str:
|
||||
"""
|
||||
Exchange authorization code for OAuth access token.
|
||||
|
||||
Args:
|
||||
username: Username for logging
|
||||
password: Password (for logging/debugging)
|
||||
auth_code: Authorization code from OAuth flow
|
||||
|
||||
Returns:
|
||||
OAuth access token
|
||||
"""
|
||||
logger.info(f"Exchanging auth code for access token (user: {username})...")
|
||||
|
||||
if not self._http_client:
|
||||
raise RuntimeError(
|
||||
"HTTP client not initialized - use async context manager"
|
||||
)
|
||||
|
||||
# Exchange authorization code for access token
|
||||
token_response = await self._http_client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": self.callback_url,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError(f"No access token in response for {username}")
|
||||
|
||||
logger.info(f"Successfully acquired OAuth token for {username}")
|
||||
return access_token
|
||||
|
||||
async def add_user(self, username: str, password: str, token: str) -> UserProfile:
|
||||
"""
|
||||
Add a user to the pool with their OAuth token.
|
||||
|
||||
Args:
|
||||
username: Username
|
||||
password: Password (for future re-auth if needed)
|
||||
token: OAuth access token
|
||||
|
||||
Returns:
|
||||
UserProfile for the added user
|
||||
"""
|
||||
if username in self.users:
|
||||
logger.warning(f"User {username} already in pool, updating token")
|
||||
|
||||
profile = UserProfile(username=username, password=password, token=token)
|
||||
self.users[username] = profile
|
||||
logger.info(f"Added user {username} to pool (total: {len(self.users)})")
|
||||
return profile
|
||||
|
||||
async def create_user_session(
|
||||
self, username: str, mcp_url: str = "http://127.0.0.1:8001/mcp"
|
||||
) -> ClientSession:
|
||||
"""
|
||||
Create an MCP client session for a user.
|
||||
|
||||
Args:
|
||||
username: Username to create session for
|
||||
mcp_url: MCP server URL
|
||||
|
||||
Returns:
|
||||
Initialized ClientSession
|
||||
|
||||
Raises:
|
||||
KeyError: If user not in pool
|
||||
"""
|
||||
if username not in self.users:
|
||||
raise KeyError(f"User {username} not in pool")
|
||||
|
||||
profile = self.users[username]
|
||||
|
||||
# Create streamable HTTP connection with OAuth token in Authorization header
|
||||
# This matches the pattern from tests/conftest.py create_mcp_client_session()
|
||||
headers = {"Authorization": f"Bearer {profile.token}"}
|
||||
streamable_context = streamablehttp_client(mcp_url, headers=headers)
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
|
||||
session = ClientSession(read_stream, write_stream)
|
||||
await session.__aenter__()
|
||||
await session.initialize()
|
||||
|
||||
# Store both session and context for proper cleanup
|
||||
profile.session = session
|
||||
profile.streamable_context = streamable_context
|
||||
logger.info(f"Created MCP session for {username}")
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
# Clean up streamable context if session creation failed
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as cleanup_error:
|
||||
logger.debug(f"Error during cleanup: {cleanup_error}")
|
||||
raise e
|
||||
|
||||
async def close_user_session(self, username: str):
|
||||
"""Close the MCP session for a user."""
|
||||
if username not in self.users:
|
||||
return
|
||||
|
||||
profile = self.users[username]
|
||||
|
||||
# Close ClientSession
|
||||
if profile.session:
|
||||
try:
|
||||
await profile.session.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing session for {username}: {e}")
|
||||
profile.session = None
|
||||
|
||||
# Close streamable context
|
||||
if profile.streamable_context:
|
||||
try:
|
||||
await profile.streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing streamable context for {username}: {e}")
|
||||
profile.streamable_context = None
|
||||
|
||||
async def close_all_sessions(self):
|
||||
"""Close all user sessions."""
|
||||
for username in list(self.users.keys()):
|
||||
await self.close_user_session(username)
|
||||
|
||||
def get_user(self, username: str) -> UserProfile:
|
||||
"""Get user profile by username."""
|
||||
if username not in self.users:
|
||||
raise KeyError(f"User {username} not in pool")
|
||||
return self.users[username]
|
||||
|
||||
def get_all_users(self) -> list[UserProfile]:
|
||||
"""Get all user profiles."""
|
||||
return list(self.users.values())
|
||||
|
||||
def record_operation(self, username: str, success: bool = True):
|
||||
"""Record an operation for user stats."""
|
||||
if username in self.users:
|
||||
self.users[username].operation_count += 1
|
||||
if not success:
|
||||
self.users[username].error_count += 1
|
||||
|
||||
def get_stats(self) -> dict[str, dict[str, int | float]]:
|
||||
"""Get per-user operation statistics."""
|
||||
return {
|
||||
username: {
|
||||
"operations": profile.operation_count,
|
||||
"errors": profile.error_count,
|
||||
"success_rate": (
|
||||
(profile.operation_count - profile.error_count)
|
||||
/ max(profile.operation_count, 1)
|
||||
* 100
|
||||
),
|
||||
}
|
||||
for username, profile in self.users.items()
|
||||
}
|
||||
|
||||
async def create_nextcloud_user(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str | None = None,
|
||||
email: str | None = None,
|
||||
) -> UserConfig:
|
||||
"""
|
||||
Create a Nextcloud user via the Users API.
|
||||
|
||||
Args:
|
||||
username: Username for the new user
|
||||
password: Password for the new user
|
||||
display_name: Optional display name
|
||||
email: Optional email address
|
||||
|
||||
Returns:
|
||||
UserConfig for the created user
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If user creation fails
|
||||
"""
|
||||
logger.info(f"Creating Nextcloud user: {username}")
|
||||
|
||||
await self.admin_client.users.create_user(
|
||||
userid=username,
|
||||
password=password,
|
||||
display_name=display_name or username,
|
||||
email=email or f"{username}@benchmark.local",
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created Nextcloud user: {username}")
|
||||
|
||||
return UserConfig(
|
||||
username=username,
|
||||
password=password,
|
||||
display_name=display_name or username,
|
||||
email=email or f"{username}@benchmark.local",
|
||||
groups=[],
|
||||
)
|
||||
|
||||
async def delete_nextcloud_user(self, username: str):
|
||||
"""
|
||||
Delete a Nextcloud user via the Users API.
|
||||
|
||||
Args:
|
||||
username: Username to delete
|
||||
"""
|
||||
logger.info(f"Deleting Nextcloud user: {username}")
|
||||
|
||||
try:
|
||||
await self.admin_client.users.delete_user(userid=username)
|
||||
logger.info(f"Successfully deleted Nextcloud user: {username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete user {username}: {e}")
|
||||
|
||||
async def acquire_token_playwright(
|
||||
self,
|
||||
browser: Any,
|
||||
username: str,
|
||||
password: str,
|
||||
state: str,
|
||||
auth_states: dict[str, str],
|
||||
) -> str:
|
||||
"""
|
||||
Acquire OAuth token via Playwright browser automation.
|
||||
|
||||
Based on conftest.py playwright_oauth_token fixture.
|
||||
Automates the full OAuth flow:
|
||||
1. Navigate to authorization URL
|
||||
2. Fill login form
|
||||
3. Handle OAuth consent
|
||||
4. Wait for callback server to receive auth code
|
||||
5. Exchange code for access token
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
username: Username to authenticate
|
||||
password: Password for the user
|
||||
state: Unique state parameter for this OAuth flow
|
||||
auth_states: Dict mapping state -> auth_code (shared with callback server)
|
||||
|
||||
Returns:
|
||||
OAuth access token
|
||||
|
||||
Raises:
|
||||
TimeoutError: If callback not received within timeout
|
||||
ValueError: If token exchange fails
|
||||
"""
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
logger.info(f"Starting Playwright OAuth flow for {username}...")
|
||||
logger.debug(f"Using state: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{self.authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={self.client_id}&"
|
||||
f"redirect_uri={quote(self.callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope=openid%20profile%20email"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Navigate to authorization URL
|
||||
logger.debug("Navigating to authorization URL...")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info(f"Logging in as {username}...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
current_url = page.url
|
||||
logger.info("Login completed")
|
||||
|
||||
# Handle OAuth consent if present
|
||||
try:
|
||||
authorize_button = await page.query_selector(
|
||||
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
|
||||
)
|
||||
if authorize_button:
|
||||
logger.info("Authorizing OAuth client...")
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization needed: {e}")
|
||||
|
||||
# Wait for callback server to receive auth code
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
screenshot_path = f"/tmp/oauth_timeout_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback for {username}"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Received auth code for {username}")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info(f"Exchanging auth code for access token ({username})...")
|
||||
token_response = await self._http_client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": self.callback_url,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError(f"No access token for {username}: {token_data}")
|
||||
|
||||
logger.info(f"Successfully acquired OAuth token for {username}")
|
||||
return access_token
|
||||
|
||||
|
||||
class UserSessionWrapper:
|
||||
"""
|
||||
Wrapper for a user-specific MCP session with operation tracking.
|
||||
|
||||
Provides a convenient interface for executing operations as a specific user.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, session: ClientSession, pool: OAuthUserPool):
|
||||
self.username = username
|
||||
self.session = session
|
||||
self.pool = pool
|
||||
|
||||
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Call an MCP tool and record the operation.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to call
|
||||
arguments: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool result
|
||||
"""
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
self.pool.record_operation(self.username, success=True)
|
||||
return result
|
||||
except Exception:
|
||||
self.pool.record_operation(self.username, success=False)
|
||||
raise
|
||||
|
||||
async def read_resource(self, uri: str) -> Any:
|
||||
"""
|
||||
Read an MCP resource and record the operation.
|
||||
|
||||
Args:
|
||||
uri: Resource URI
|
||||
|
||||
Returns:
|
||||
Resource data
|
||||
"""
|
||||
try:
|
||||
result = await self.session.read_resource(uri)
|
||||
self.pool.record_operation(self.username, success=True)
|
||||
return result
|
||||
except Exception:
|
||||
self.pool.record_operation(self.username, success=False)
|
||||
raise
|
||||
|
||||
|
||||
def generate_secure_password(length: int = 20) -> str:
|
||||
"""Generate a secure random password."""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
@@ -0,0 +1,506 @@
|
||||
"""
|
||||
Multi-User Workflow Definitions for OAuth Load Testing.
|
||||
|
||||
Defines coordinated workflows that span multiple users, simulating realistic
|
||||
collaborative scenarios like note sharing, file collaboration, and permission management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from tests.load.oauth_pool import UserSessionWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowStepResult:
|
||||
"""Result of a single workflow step."""
|
||||
|
||||
step_name: str
|
||||
user: str
|
||||
success: bool
|
||||
duration: float
|
||||
error: str | None = None
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowResult:
|
||||
"""Result of a complete workflow execution."""
|
||||
|
||||
workflow_name: str
|
||||
success: bool
|
||||
total_duration: float
|
||||
steps: list[WorkflowStepResult]
|
||||
participants: list[str]
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def steps_completed(self) -> int:
|
||||
"""Count of successfully completed steps."""
|
||||
return sum(1 for step in self.steps if step.success)
|
||||
|
||||
@property
|
||||
def step_latencies(self) -> dict[str, float]:
|
||||
"""Map of step names to their durations."""
|
||||
return {step.step_name: step.duration for step in self.steps}
|
||||
|
||||
|
||||
class Workflow(ABC):
|
||||
"""
|
||||
Base class for multi-user workflows.
|
||||
|
||||
A workflow represents a coordinated sequence of operations across multiple users,
|
||||
such as creating and sharing a note, collaborative editing, or permission management.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.steps: list[WorkflowStepResult] = []
|
||||
self.start_time: float | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
|
||||
"""
|
||||
Execute the workflow with the given users.
|
||||
|
||||
Args:
|
||||
users: List of UserSessionWrapper instances to use in the workflow
|
||||
|
||||
Returns:
|
||||
WorkflowResult with execution details
|
||||
"""
|
||||
pass
|
||||
|
||||
async def _execute_step(
|
||||
self,
|
||||
step_name: str,
|
||||
user: UserSessionWrapper,
|
||||
operation: Callable[..., Awaitable[Any]],
|
||||
**kwargs,
|
||||
) -> WorkflowStepResult:
|
||||
"""
|
||||
Execute a single workflow step with timing and error handling.
|
||||
|
||||
Args:
|
||||
step_name: Name of the step for reporting
|
||||
user: User executing the step
|
||||
operation: Async callable to execute
|
||||
**kwargs: Arguments to pass to the operation
|
||||
|
||||
Returns:
|
||||
WorkflowStepResult
|
||||
"""
|
||||
start = time.time()
|
||||
try:
|
||||
result = await operation(**kwargs)
|
||||
duration = time.time() - start
|
||||
step_result = WorkflowStepResult(
|
||||
step_name=step_name,
|
||||
user=user.username,
|
||||
success=True,
|
||||
duration=duration,
|
||||
data={"result": result} if result else {},
|
||||
)
|
||||
self.steps.append(step_result)
|
||||
return step_result
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
logger.error(f"Step {step_name} failed for user {user.username}: {e}")
|
||||
step_result = WorkflowStepResult(
|
||||
step_name=step_name,
|
||||
user=user.username,
|
||||
success=False,
|
||||
duration=duration,
|
||||
error=str(e),
|
||||
)
|
||||
self.steps.append(step_result)
|
||||
return step_result
|
||||
|
||||
def _finish(self, success: bool, error: str | None = None) -> WorkflowResult:
|
||||
"""
|
||||
Finalize workflow and create result.
|
||||
|
||||
Args:
|
||||
success: Whether the overall workflow succeeded
|
||||
error: Optional error message
|
||||
|
||||
Returns:
|
||||
WorkflowResult
|
||||
"""
|
||||
duration = time.time() - self.start_time if self.start_time else 0.0
|
||||
participants = list(set(step.user for step in self.steps))
|
||||
|
||||
return WorkflowResult(
|
||||
workflow_name=self.name,
|
||||
success=success,
|
||||
total_duration=duration,
|
||||
steps=self.steps,
|
||||
participants=participants,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
class NoteShareWorkflow(Workflow):
|
||||
"""
|
||||
Workflow: User A creates a note and shares it with User B, who then reads it.
|
||||
|
||||
Steps:
|
||||
1. User A creates a note
|
||||
2. User A shares the note with User B (read-only)
|
||||
3. User B lists their shared notes (verify propagation)
|
||||
4. User B reads the shared note
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("note_share")
|
||||
|
||||
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
|
||||
"""Execute note sharing workflow."""
|
||||
self.start_time = time.time()
|
||||
|
||||
if len(users) < 2:
|
||||
return self._finish(False, error="Requires at least 2 users")
|
||||
|
||||
user_a, user_b = users[0], users[1]
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
|
||||
try:
|
||||
# Step 1: User A creates note
|
||||
create_result = await self._execute_step(
|
||||
"create_note",
|
||||
user_a,
|
||||
lambda: user_a.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Shared Note {unique_id}",
|
||||
"content": f"Content for workflow test {unique_id}",
|
||||
"category": "Workflows",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not create_result.success:
|
||||
return self._finish(False, error="Failed to create note")
|
||||
|
||||
# Extract note ID
|
||||
note_data = json.loads(create_result.data["result"].content[0].text)
|
||||
note_id = note_data["id"]
|
||||
|
||||
# Step 2: User A shares note with User B
|
||||
# Note: Sharing files/notes requires using WebDAV path
|
||||
# Create a file first, then share it
|
||||
share_result = await self._execute_step(
|
||||
"share_note",
|
||||
user_a,
|
||||
lambda: user_a.call_tool(
|
||||
"nc_share_create",
|
||||
{
|
||||
"path": f"/Notes/{note_data['category']}/{note_data['title']}.txt",
|
||||
"share_with": user_b.username,
|
||||
"share_type": 0, # User share
|
||||
"permissions": 1, # Read-only
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not share_result.success:
|
||||
logger.warning("Share creation failed, continuing anyway")
|
||||
|
||||
# Step 3: User B lists shares (measure propagation)
|
||||
await self._execute_step(
|
||||
"list_shared_with_me",
|
||||
user_b,
|
||||
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
|
||||
)
|
||||
|
||||
# Step 4: User B reads the note
|
||||
await self._execute_step(
|
||||
"read_shared_note",
|
||||
user_b,
|
||||
lambda: user_b.call_tool("nc_notes_get_note", {"note_id": note_id}),
|
||||
)
|
||||
|
||||
# Cleanup: Delete the note
|
||||
await user_a.call_tool("nc_notes_delete_note", {"note_id": note_id})
|
||||
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Note share workflow failed: {e}")
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
class CollaborativeEditWorkflow(Workflow):
|
||||
"""
|
||||
Workflow: Multiple users edit the same note concurrently.
|
||||
|
||||
Steps:
|
||||
1. User A creates a note
|
||||
2. User A shares note with Users B, C (edit permissions)
|
||||
3. All users read the note simultaneously
|
||||
4. All users update the note simultaneously (test concurrent edits)
|
||||
5. User A verifies final state
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("collaborative_edit")
|
||||
|
||||
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
|
||||
"""Execute collaborative editing workflow."""
|
||||
self.start_time = time.time()
|
||||
|
||||
if len(users) < 2:
|
||||
return self._finish(False, error="Requires at least 2 users")
|
||||
|
||||
owner = users[0]
|
||||
collaborators = users[1:]
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
|
||||
try:
|
||||
# Step 1: Owner creates note
|
||||
create_result = await self._execute_step(
|
||||
"create_note",
|
||||
owner,
|
||||
lambda: owner.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Collab Note {unique_id}",
|
||||
"content": f"Initial content {unique_id}",
|
||||
"category": "Collaboration",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not create_result.success:
|
||||
return self._finish(False, error="Failed to create note")
|
||||
|
||||
note_data = json.loads(create_result.data["result"].content[0].text)
|
||||
note_id = note_data["id"]
|
||||
|
||||
# Step 2: Read note concurrently by all users
|
||||
read_tasks = []
|
||||
for i, user in enumerate(users):
|
||||
read_tasks.append(
|
||||
self._execute_step(
|
||||
f"concurrent_read_{i}",
|
||||
user,
|
||||
lambda uid=note_id: user.call_tool(
|
||||
"nc_notes_get_note", {"note_id": uid}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*read_tasks)
|
||||
|
||||
# Step 3: Append content concurrently by all collaborators
|
||||
append_tasks = []
|
||||
for i, user in enumerate(collaborators):
|
||||
append_tasks.append(
|
||||
self._execute_step(
|
||||
f"concurrent_append_{i}",
|
||||
user,
|
||||
lambda _=i, u=user: u.call_tool(
|
||||
"nc_notes_append_content",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"content": f"Addition from {u.username} at {time.time()}",
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*append_tasks)
|
||||
|
||||
# Step 4: Owner verifies final state
|
||||
await self._execute_step(
|
||||
"verify_final_state",
|
||||
owner,
|
||||
lambda: owner.call_tool("nc_notes_get_note", {"note_id": note_id}),
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
await owner.call_tool("nc_notes_delete_note", {"note_id": note_id})
|
||||
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Collaborative edit workflow failed: {e}")
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
class FileShareAndDownloadWorkflow(Workflow):
|
||||
"""
|
||||
Workflow: User A uploads a file, shares it with User B, who then downloads it.
|
||||
|
||||
Steps:
|
||||
1. User A creates a file via WebDAV
|
||||
2. User A shares the file with User B (read-only)
|
||||
3. User B lists their shares
|
||||
4. User B reads/downloads the file
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("file_share_download")
|
||||
|
||||
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
|
||||
"""Execute file sharing workflow."""
|
||||
self.start_time = time.time()
|
||||
|
||||
if len(users) < 2:
|
||||
return self._finish(False, error="Requires at least 2 users")
|
||||
|
||||
user_a, user_b = users[0], users[1]
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
file_path = f"/LoadTest_{unique_id}.txt"
|
||||
|
||||
try:
|
||||
# Step 1: User A creates a file
|
||||
content = f"Test file content {unique_id}\nCreated for workflow testing"
|
||||
create_result = await self._execute_step(
|
||||
"create_file",
|
||||
user_a,
|
||||
lambda: user_a.call_tool(
|
||||
"nc_webdav_put_file",
|
||||
{
|
||||
"path": file_path,
|
||||
"content": content,
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not create_result.success:
|
||||
return self._finish(False, error="Failed to create file")
|
||||
|
||||
# Step 2: User A shares file with User B
|
||||
share_result = await self._execute_step(
|
||||
"share_file",
|
||||
user_a,
|
||||
lambda: user_a.call_tool(
|
||||
"nc_share_create",
|
||||
{
|
||||
"path": file_path,
|
||||
"share_with": user_b.username,
|
||||
"share_type": 0,
|
||||
"permissions": 1, # Read-only
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not share_result.success:
|
||||
logger.warning("File share failed, continuing")
|
||||
|
||||
# Step 3: User B lists shared files
|
||||
_ = await self._execute_step(
|
||||
"list_shares",
|
||||
user_b,
|
||||
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
|
||||
)
|
||||
|
||||
# Step 4: User B downloads the file
|
||||
_ = await self._execute_step(
|
||||
"download_file",
|
||||
user_b,
|
||||
lambda: user_b.call_tool("nc_webdav_get_file", {"path": file_path}),
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
await user_a.call_tool("nc_webdav_delete", {"path": file_path})
|
||||
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"File share workflow failed: {e}")
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
class MixedOAuthWorkload:
|
||||
"""
|
||||
Mixed workload combining baseline operations and coordinated workflows.
|
||||
|
||||
Distribution:
|
||||
- 50% Baseline operations (individual user CRUD)
|
||||
- 30% Note sharing workflows
|
||||
- 15% Collaborative editing workflows
|
||||
- 5% File sharing workflows
|
||||
"""
|
||||
|
||||
def __init__(self, users: list[UserSessionWrapper]):
|
||||
self.users = users
|
||||
self.workflows = {
|
||||
"note_share": NoteShareWorkflow(),
|
||||
"collaborative_edit": CollaborativeEditWorkflow(),
|
||||
"file_share": FileShareAndDownloadWorkflow(),
|
||||
}
|
||||
|
||||
async def run_operation(self) -> WorkflowResult | dict[str, Any]:
|
||||
"""
|
||||
Execute one random operation (baseline or workflow).
|
||||
|
||||
Returns:
|
||||
WorkflowResult for workflows, dict for baseline operations
|
||||
"""
|
||||
rand = random.random()
|
||||
|
||||
# 50% baseline operations (single-user)
|
||||
if rand < 0.50:
|
||||
return await self._run_baseline_operation()
|
||||
|
||||
# 30% note sharing
|
||||
elif rand < 0.80:
|
||||
users = random.sample(self.users, min(2, len(self.users)))
|
||||
return await self.workflows["note_share"].execute(users)
|
||||
|
||||
# 15% collaborative editing
|
||||
elif rand < 0.95:
|
||||
users = random.sample(self.users, min(len(self.users), 3))
|
||||
return await self.workflows["collaborative_edit"].execute(users)
|
||||
|
||||
# 5% file sharing
|
||||
else:
|
||||
users = random.sample(self.users, min(2, len(self.users)))
|
||||
return await self.workflows["file_share"].execute(users)
|
||||
|
||||
async def _run_baseline_operation(self) -> dict[str, Any]:
|
||||
"""Run a baseline single-user operation."""
|
||||
user = random.choice(self.users)
|
||||
operations = [
|
||||
(
|
||||
"search_notes",
|
||||
lambda: user.call_tool("nc_notes_search_notes", {"query": ""}),
|
||||
),
|
||||
("list_files", lambda: user.call_tool("nc_webdav_list", {"path": "/"})),
|
||||
("get_capabilities", lambda: user.read_resource("nc://capabilities")),
|
||||
]
|
||||
|
||||
op_name, operation = random.choice(operations)
|
||||
start = time.time()
|
||||
try:
|
||||
await operation()
|
||||
duration = time.time() - start
|
||||
return {
|
||||
"type": "baseline",
|
||||
"operation": op_name,
|
||||
"user": user.username,
|
||||
"success": True,
|
||||
"duration": duration,
|
||||
}
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return {
|
||||
"type": "baseline",
|
||||
"operation": op_name,
|
||||
"user": user.username,
|
||||
"success": False,
|
||||
"duration": duration,
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Workload definitions for load testing the MCP server.
|
||||
|
||||
Defines realistic operation mixes and individual operation functions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from mcp import ClientSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OperationResult:
|
||||
"""Result of a single operation execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
success: bool,
|
||||
duration: float,
|
||||
error: str | None = None,
|
||||
):
|
||||
self.operation = operation
|
||||
self.success = success
|
||||
self.duration = duration
|
||||
self.error = error
|
||||
self.timestamp = time.time()
|
||||
|
||||
|
||||
class WorkloadOperations:
|
||||
"""Collection of MCP operations for load testing."""
|
||||
|
||||
def __init__(self, session: ClientSession):
|
||||
self.session = session
|
||||
self._created_notes: list[int] = []
|
||||
self._created_boards: list[int] = []
|
||||
|
||||
async def get_capabilities(self) -> OperationResult:
|
||||
"""Fetch server capabilities (lightweight operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.read_resource("nc://capabilities")
|
||||
duration = time.time() - start
|
||||
return OperationResult("get_capabilities", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("get_capabilities", False, duration, str(e))
|
||||
|
||||
async def list_notes(self) -> OperationResult:
|
||||
"""List all notes (read operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_notes_search_notes", {"query": ""})
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_notes", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_notes", False, duration, str(e))
|
||||
|
||||
async def search_notes(self, query: str = "test") -> OperationResult:
|
||||
"""Search notes by query (read operation with filtering)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_notes_search_notes", {"query": query})
|
||||
duration = time.time() - start
|
||||
return OperationResult("search_notes", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("search_notes", False, duration, str(e))
|
||||
|
||||
async def create_note(self) -> OperationResult:
|
||||
"""Create a new note (write operation)."""
|
||||
start = time.time()
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
try:
|
||||
result = await self.session.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Load Test Note {unique_id}",
|
||||
"content": f"Content for load test note {unique_id}",
|
||||
"category": "LoadTesting",
|
||||
},
|
||||
)
|
||||
duration = time.time() - start
|
||||
|
||||
# Track created note ID for cleanup
|
||||
if result and len(result.content) > 0:
|
||||
content = result.content[0]
|
||||
if hasattr(content, "text"):
|
||||
import json
|
||||
|
||||
note_data = json.loads(content.text)
|
||||
note_id = note_data.get("id")
|
||||
if note_id:
|
||||
self._created_notes.append(note_id)
|
||||
|
||||
return OperationResult("create_note", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("create_note", False, duration, str(e))
|
||||
|
||||
async def get_note(self, note_id: int) -> OperationResult:
|
||||
"""Get a specific note by ID (read operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_notes_get_note", {"note_id": note_id})
|
||||
duration = time.time() - start
|
||||
return OperationResult("get_note", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("get_note", False, duration, str(e))
|
||||
|
||||
async def update_note(self, note_id: int, etag: str) -> OperationResult:
|
||||
"""Update an existing note (write operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": etag,
|
||||
"title": f"Updated Note {note_id}",
|
||||
"content": f"Updated content at {time.time()}",
|
||||
"category": "LoadTesting",
|
||||
},
|
||||
)
|
||||
duration = time.time() - start
|
||||
return OperationResult("update_note", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("update_note", False, duration, str(e))
|
||||
|
||||
async def delete_note(self, note_id: int) -> OperationResult:
|
||||
"""Delete a note (write operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_notes_delete_note", {"note_id": note_id})
|
||||
duration = time.time() - start
|
||||
# Remove from tracking
|
||||
if note_id in self._created_notes:
|
||||
self._created_notes.remove(note_id)
|
||||
return OperationResult("delete_note", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("delete_note", False, duration, str(e))
|
||||
|
||||
async def list_webdav_files(self, path: str = "/") -> OperationResult:
|
||||
"""List files via WebDAV (read operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_webdav_list", {"path": path})
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_webdav_files", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_webdav_files", False, duration, str(e))
|
||||
|
||||
async def list_calendars(self) -> OperationResult:
|
||||
"""List calendars (read operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_calendar_list_calendars", {})
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_calendars", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_calendars", False, duration, str(e))
|
||||
|
||||
async def list_deck_boards(self) -> OperationResult:
|
||||
"""List deck boards (read operation)."""
|
||||
start = time.time()
|
||||
try:
|
||||
await self.session.call_tool("nc_deck_list_boards", {})
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_deck_boards", True, duration)
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
return OperationResult("list_deck_boards", False, duration, str(e))
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up any resources created during testing."""
|
||||
logger.info(f"Cleaning up {len(self._created_notes)} test notes...")
|
||||
for note_id in self._created_notes[:]:
|
||||
try:
|
||||
await self.delete_note(note_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete note {note_id}: {e}")
|
||||
|
||||
|
||||
class MixedWorkload:
|
||||
"""
|
||||
Realistic mixed workload simulating typical MCP server usage.
|
||||
|
||||
Operation distribution:
|
||||
- 40% Notes read (list/get/search)
|
||||
- 20% Notes write (create/update/delete)
|
||||
- 15% Notes search
|
||||
- 10% WebDAV operations
|
||||
- 10% Calendar operations
|
||||
- 5% Other (capabilities, deck)
|
||||
"""
|
||||
|
||||
def __init__(self, operations: WorkloadOperations):
|
||||
self.ops = operations
|
||||
# Pre-create some notes for read/update operations
|
||||
self._warmup_note_ids: list[tuple[int, str]] = []
|
||||
|
||||
async def warmup(self, count: int = 10):
|
||||
"""Create initial notes for read/update operations."""
|
||||
logger.info(f"Warming up with {count} test notes...")
|
||||
for _ in range(count):
|
||||
result = await self.ops.create_note()
|
||||
if result.success and self.ops._created_notes:
|
||||
note_id = self.ops._created_notes[-1]
|
||||
# Get the note to fetch its etag
|
||||
try:
|
||||
get_result = await self.ops.session.call_tool(
|
||||
"nc_notes_get_note", {"note_id": note_id}
|
||||
)
|
||||
if get_result and len(get_result.content) > 0:
|
||||
import json
|
||||
|
||||
note_data = json.loads(get_result.content[0].text)
|
||||
etag = note_data.get("etag", "")
|
||||
self._warmup_note_ids.append((note_id, etag))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get etag for note {note_id}: {e}")
|
||||
|
||||
async def run_operation(self) -> OperationResult:
|
||||
"""Execute one random operation based on the workload distribution."""
|
||||
rand = random.random()
|
||||
|
||||
# 40% reads (list/get/search)
|
||||
if rand < 0.40:
|
||||
op_rand = random.random()
|
||||
if op_rand < 0.5:
|
||||
return await self.ops.list_notes()
|
||||
elif op_rand < 0.8 and self._warmup_note_ids:
|
||||
note_id, _ = random.choice(self._warmup_note_ids)
|
||||
return await self.ops.get_note(note_id)
|
||||
else:
|
||||
return await self.ops.search_notes()
|
||||
|
||||
# 20% writes (create/update/delete)
|
||||
elif rand < 0.60:
|
||||
op_rand = random.random()
|
||||
if op_rand < 0.5:
|
||||
return await self.ops.create_note()
|
||||
elif op_rand < 0.8 and self._warmup_note_ids:
|
||||
note_id, etag = random.choice(self._warmup_note_ids)
|
||||
return await self.ops.update_note(note_id, etag)
|
||||
elif self.ops._created_notes and len(self.ops._created_notes) > 5:
|
||||
# Only delete if we have enough notes
|
||||
note_id = random.choice(self.ops._created_notes)
|
||||
return await self.ops.delete_note(note_id)
|
||||
else:
|
||||
return await self.ops.create_note()
|
||||
|
||||
# 15% search
|
||||
elif rand < 0.75:
|
||||
queries = ["test", "load", "note", "content", ""]
|
||||
return await self.ops.search_notes(random.choice(queries))
|
||||
|
||||
# 10% WebDAV
|
||||
elif rand < 0.85:
|
||||
return await self.ops.list_webdav_files()
|
||||
|
||||
# 10% Calendar
|
||||
elif rand < 0.95:
|
||||
return await self.ops.list_calendars()
|
||||
|
||||
# 5% Other
|
||||
else:
|
||||
op_rand = random.random()
|
||||
if op_rand < 0.5:
|
||||
return await self.ops.get_capabilities()
|
||||
else:
|
||||
return await self.ops.list_deck_boards()
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Integration tests for Calendar VTODO (task) MCP tools."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_mcp_todo_complete_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test complete todo workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
todo_uid = None
|
||||
|
||||
try:
|
||||
# 1. Create todo via MCP
|
||||
logger.info(f"Creating todo in {calendar_name} via MCP")
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"summary": "MCP Test Task",
|
||||
"description": "Test task created via MCP tools",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 3,
|
||||
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
|
||||
"categories": "testing,mcp",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False
|
||||
|
||||
# Extract UID from the result
|
||||
result_data = create_result.content[0].text
|
||||
import json
|
||||
|
||||
result_json = json.loads(result_data)
|
||||
todo_uid = result_json["uid"]
|
||||
logger.info(f"Created todo with UID: {todo_uid}")
|
||||
|
||||
# 2. Verify todo creation via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
assert any(t["uid"] == todo_uid for t in todos)
|
||||
created_todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert created_todo["summary"] == "MCP Test Task"
|
||||
assert created_todo["status"] == "NEEDS-ACTION"
|
||||
assert created_todo["priority"] == 3
|
||||
|
||||
# 3. List todos via MCP
|
||||
logger.info(f"Listing todos in {calendar_name} via MCP")
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name},
|
||||
)
|
||||
assert list_result.isError is False
|
||||
|
||||
list_data = json.loads(list_result.content[0].text)
|
||||
assert "todos" in list_data
|
||||
assert any(t["uid"] == todo_uid for t in list_data["todos"])
|
||||
|
||||
# 4. Update todo via MCP
|
||||
logger.info(f"Updating todo {todo_uid} via MCP")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"todo_uid": todo_uid,
|
||||
"summary": "MCP Test Task Updated",
|
||||
"status": "IN-PROCESS",
|
||||
"priority": 1,
|
||||
"percent_complete": 50,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False
|
||||
|
||||
# 5. Verify update via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert updated_todo["summary"] == "MCP Test Task Updated"
|
||||
assert updated_todo["status"] == "IN-PROCESS"
|
||||
assert updated_todo["priority"] == 1
|
||||
assert updated_todo["percent_complete"] == 50
|
||||
|
||||
# 6. Delete todo via MCP
|
||||
logger.info(f"Deleting todo {todo_uid} via MCP")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_todo",
|
||||
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
||||
)
|
||||
assert delete_result.isError is False
|
||||
|
||||
# 7. Verify deletion via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
assert not any(t["uid"] == todo_uid for t in todos)
|
||||
|
||||
logger.info("Complete todo workflow test passed")
|
||||
|
||||
finally:
|
||||
# Cleanup in case of failure
|
||||
if todo_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_list_todos_with_filters(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test listing todos with various filters via MCP tools."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
created_uids = []
|
||||
|
||||
try:
|
||||
# Create test todos with different properties
|
||||
test_todos = [
|
||||
{
|
||||
"summary": "High Priority Task",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 1,
|
||||
"categories": "urgent,work",
|
||||
},
|
||||
{
|
||||
"summary": "In Progress Task",
|
||||
"status": "IN-PROCESS",
|
||||
"priority": 5,
|
||||
"categories": "work",
|
||||
},
|
||||
{
|
||||
"summary": "Low Priority Task",
|
||||
"status": "NEEDS-ACTION",
|
||||
"priority": 9,
|
||||
"categories": "someday",
|
||||
},
|
||||
]
|
||||
|
||||
# Create todos via client
|
||||
for todo_data in test_todos:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
created_uids.append(result["uid"])
|
||||
|
||||
# Test 1: Filter by status
|
||||
logger.info("Testing filter by status")
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
|
||||
)
|
||||
assert result.isError is False
|
||||
import json
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
||||
assert len(needs_action_todos) == 2 # Two NEEDS-ACTION todos
|
||||
|
||||
# Test 2: Filter by priority
|
||||
logger.info("Testing filter by minimum priority")
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name, "min_priority": 1},
|
||||
)
|
||||
assert result.isError is False
|
||||
data = json.loads(result.content[0].text)
|
||||
high_priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
||||
assert len(high_priority_todos) >= 1 # At least the priority 1 todo
|
||||
|
||||
# Test 3: Filter by categories
|
||||
logger.info("Testing filter by categories")
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name, "categories": "work"},
|
||||
)
|
||||
assert result.isError is False
|
||||
data = json.loads(result.content[0].text)
|
||||
work_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
||||
assert len(work_todos) >= 2 # Two todos with "work" category
|
||||
|
||||
# Test 4: Filter by summary text
|
||||
logger.info("Testing filter by summary text")
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name, "summary_contains": "Priority"},
|
||||
)
|
||||
assert result.isError is False
|
||||
data = json.loads(result.content[0].text)
|
||||
priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
||||
assert len(priority_todos) == 2 # Two have "Priority" in summary (High, Low)
|
||||
|
||||
logger.info("List todos with filters test passed")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
for uid in created_uids:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_search_todos_across_calendars(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
temporary_calendar: str,
|
||||
shared_calendar_2: str,
|
||||
):
|
||||
"""Test searching todos across multiple calendars via MCP tools.
|
||||
|
||||
Note: Uses two shared test calendars to avoid rate limiting.
|
||||
"""
|
||||
|
||||
cal1_name = temporary_calendar # First shared test calendar
|
||||
cal2_name = shared_calendar_2 # Second shared test calendar
|
||||
created_uids = []
|
||||
|
||||
try:
|
||||
# Use existing shared calendars (no creation needed, avoiding rate limits)
|
||||
|
||||
# Create todos in both calendars
|
||||
result1 = await nc_client.calendar.create_todo(
|
||||
cal1_name,
|
||||
{
|
||||
"summary": "Task in Calendar 1",
|
||||
"status": "NEEDS-ACTION",
|
||||
"categories": "cal1",
|
||||
},
|
||||
)
|
||||
created_uids.append((cal1_name, result1["uid"]))
|
||||
|
||||
result2 = await nc_client.calendar.create_todo(
|
||||
cal2_name,
|
||||
{
|
||||
"summary": "Task in Calendar 2",
|
||||
"status": "IN-PROCESS",
|
||||
"categories": "cal2",
|
||||
},
|
||||
)
|
||||
created_uids.append((cal2_name, result2["uid"]))
|
||||
|
||||
# Search across all calendars via MCP
|
||||
logger.info("Searching todos across all calendars via MCP")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_search_todos",
|
||||
{},
|
||||
)
|
||||
assert search_result.isError is False
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(search_result.content[0].text)
|
||||
assert "todos" in data
|
||||
|
||||
# Verify both todos are in the results
|
||||
found_uids = {t["uid"] for t in data["todos"]}
|
||||
assert result1["uid"] in found_uids
|
||||
assert result2["uid"] in found_uids
|
||||
|
||||
# Verify calendar_name is included
|
||||
our_todos = [
|
||||
t for t in data["todos"] if t["uid"] in [result1["uid"], result2["uid"]]
|
||||
]
|
||||
for todo in our_todos:
|
||||
assert "calendar_name" in todo
|
||||
assert todo["calendar_name"] in [cal1_name, cal2_name]
|
||||
|
||||
# Test search with status filter
|
||||
logger.info("Searching with status filter via MCP")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_search_todos",
|
||||
{"status": "IN-PROCESS"},
|
||||
)
|
||||
assert search_result.isError is False
|
||||
data = json.loads(search_result.content[0].text)
|
||||
in_process_todos = [
|
||||
t for t in data["todos"] if t["uid"] in [uid for _, uid in created_uids]
|
||||
]
|
||||
assert len(in_process_todos) >= 1
|
||||
|
||||
logger.info("Search todos across calendars test passed")
|
||||
|
||||
finally:
|
||||
# Cleanup: Only delete todos, not calendars (they're reused/built-in)
|
||||
for cal_name, uid in created_uids:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(cal_name, uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_todo_status_transitions(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test transitioning through different todo statuses via MCP tools."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
todo_uid = None
|
||||
|
||||
try:
|
||||
# Create todo
|
||||
result = await nc_client.calendar.create_todo(
|
||||
calendar_name,
|
||||
{"summary": "Status Transition Test", "status": "NEEDS-ACTION"},
|
||||
)
|
||||
todo_uid = result["uid"]
|
||||
|
||||
# Transition: NEEDS-ACTION → IN-PROCESS
|
||||
logger.info("Transitioning todo to IN-PROCESS via MCP")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"todo_uid": todo_uid,
|
||||
"status": "IN-PROCESS",
|
||||
"percent_complete": 25,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False
|
||||
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert todo["status"] == "IN-PROCESS"
|
||||
assert todo["percent_complete"] == 25
|
||||
|
||||
# Transition: IN-PROCESS → COMPLETED
|
||||
logger.info("Transitioning todo to COMPLETED via MCP")
|
||||
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"todo_uid": todo_uid,
|
||||
"status": "COMPLETED",
|
||||
"percent_complete": 100,
|
||||
"completed": completed_time,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False
|
||||
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert todo["status"] == "COMPLETED"
|
||||
assert todo["percent_complete"] == 100
|
||||
assert "completed" in todo
|
||||
|
||||
logger.info("Todo status transitions test passed")
|
||||
|
||||
finally:
|
||||
if todo_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_todo_with_dates(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and managing todos with date fields via MCP tools."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
todo_uid = None
|
||||
|
||||
try:
|
||||
now = datetime.now()
|
||||
start_date = (now + timedelta(days=1)).strftime("%Y-%m-%dT09:00:00")
|
||||
due_date = (now + timedelta(days=7)).strftime("%Y-%m-%dT17:00:00")
|
||||
|
||||
# Create todo with dates via MCP
|
||||
logger.info("Creating todo with dates via MCP")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"summary": "Task with Dates",
|
||||
"description": "Test task with various date fields",
|
||||
"status": "NEEDS-ACTION",
|
||||
"dtstart": start_date,
|
||||
"due": due_date,
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False
|
||||
|
||||
import json
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
todo_uid = result_data["uid"]
|
||||
|
||||
# Verify dates via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
created_todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert created_todo["summary"] == "Task with Dates"
|
||||
assert "dtstart" in created_todo
|
||||
assert "due" in created_todo
|
||||
|
||||
logger.info("Todo with dates test passed")
|
||||
|
||||
finally:
|
||||
if todo_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_todo_categories(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and managing todos with categories via MCP tools."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
todo_uid = None
|
||||
|
||||
try:
|
||||
# Create todo with multiple categories via MCP
|
||||
logger.info("Creating todo with categories via MCP")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"summary": "Task with Categories",
|
||||
"status": "NEEDS-ACTION",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False
|
||||
|
||||
import json
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
todo_uid = result_data["uid"]
|
||||
|
||||
# Verify categories via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
created_todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
assert "categories" in created_todo
|
||||
categories_str = created_todo["categories"]
|
||||
assert "work" in categories_str
|
||||
assert "meeting" in categories_str
|
||||
assert "important" in categories_str
|
||||
assert "quarterly" in categories_str
|
||||
|
||||
# Update categories via MCP
|
||||
logger.info("Updating todo categories via MCP")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_todo",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"todo_uid": todo_uid,
|
||||
"categories": "updated,new-category",
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False
|
||||
|
||||
# Verify updated categories
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
|
||||
categories_str = updated_todo["categories"]
|
||||
assert "updated" in categories_str
|
||||
assert "new-category" in categories_str
|
||||
|
||||
logger.info("Todo categories test passed")
|
||||
|
||||
finally:
|
||||
if todo_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
@@ -209,29 +208,20 @@ async def test_mcp_cookbook_delete_recipe(
|
||||
async def test_mcp_cookbook_import_recipe_from_url(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
test_recipe_server: str,
|
||||
):
|
||||
"""Test importing a recipe from a URL via MCP tools.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses a local test server to provide reliable, controlled test data.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
# Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it
|
||||
if platform.system() == "Linux":
|
||||
docker_host = "172.17.0.1"
|
||||
else:
|
||||
docker_host = "host.docker.internal"
|
||||
|
||||
docker_accessible_url = test_recipe_server.replace("localhost", docker_host)
|
||||
test_url = f"{docker_accessible_url}/black-pepper-tofu"
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Import recipe via MCP
|
||||
logger.info(
|
||||
f"Importing recipe from local test URL via MCP (Docker-accessible): {test_url}"
|
||||
)
|
||||
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
|
||||
import_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_import_recipe", {"url": test_url}
|
||||
)
|
||||
|
||||
@@ -40,6 +40,12 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_webdav_delete_resource",
|
||||
"nc_webdav_move_resource",
|
||||
"nc_webdav_copy_resource",
|
||||
"nc_webdav_search_files",
|
||||
"nc_webdav_find_by_name",
|
||||
"nc_webdav_find_by_type",
|
||||
"nc_webdav_list_favorites",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_list_events",
|
||||
@@ -51,6 +57,11 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"nc_calendar_list_todos",
|
||||
"nc_calendar_create_todo",
|
||||
"nc_calendar_update_todo",
|
||||
"nc_calendar_delete_todo",
|
||||
"nc_calendar_search_todos",
|
||||
"deck_create_board",
|
||||
"nc_cookbook_import_recipe",
|
||||
"nc_cookbook_list_recipes",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +46,7 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
|
||||
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_view_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -119,7 +119,7 @@ async def test_deck_board_view_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_edit_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -214,7 +214,7 @@ async def test_deck_board_edit_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_manage_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client
|
||||
):
|
||||
@@ -289,7 +289,7 @@ async def test_deck_board_manage_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that users can only see their own boards when not shared.
|
||||
|
||||
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_read_permissions(
|
||||
alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -104,7 +104,7 @@ async def test_file_share_read_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_write_permissions(
|
||||
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -210,7 +210,7 @@ async def test_file_share_write_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that file listing respects share permissions.
|
||||
@@ -326,7 +326,7 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that folder sharing works correctly.
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_read_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -82,7 +82,7 @@ async def test_notes_share_read_permissions(
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_write_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -149,7 +149,7 @@ async def test_notes_share_write_permissions(
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that users can only see their own notes when not shared.
|
||||
@@ -222,7 +222,7 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
|
||||
await nc_client.notes.delete_note(bob_note_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_oauth_mcp_clients_initialized(
|
||||
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
||||
):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||
"""Test creating a user and verifying deletion (cleanup by fixture)."""
|
||||
user_config = test_user
|
||||
@@ -28,7 +29,7 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
"""Test updating user fields."""
|
||||
user_config = test_user
|
||||
@@ -43,7 +44,7 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||
"""Test adding and removing users from groups."""
|
||||
user_config, groupid = test_user_in_group
|
||||
@@ -60,7 +61,7 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
|
||||
"""Test promoting and demoting subadmins."""
|
||||
user_config = test_user
|
||||
@@ -81,7 +82,7 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
"""Test disabling and enabling users."""
|
||||
user_config = test_user
|
||||
@@ -101,7 +102,7 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_get_editable_user_fields(nc_client: NextcloudClient):
|
||||
editable_fields = await nc_client.users.get_editable_user_fields()
|
||||
assert "displayname" in editable_fields
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Integration tests for WebDAV search MCP tools."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def normalize_search_response(data):
|
||||
"""Extract results list from SearchFilesResponse.
|
||||
|
||||
The response is a SearchFilesResponse with a 'results' field containing the list of files.
|
||||
"""
|
||||
if isinstance(data, dict) and "results" in data:
|
||||
return data["results"]
|
||||
else:
|
||||
# Fallback for unexpected format
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def search_test_files(nc_client: NextcloudClient):
|
||||
"""Create test files for WebDAV search testing via MCP."""
|
||||
test_dir = f"mcp_webdav_search_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
|
||||
# Create various test files
|
||||
test_files = [
|
||||
# Text files
|
||||
(f"{test_dir}/search_test1.txt", b"Sample document", "text/plain"),
|
||||
(f"{test_dir}/search_test2.txt", b"Another document", "text/plain"),
|
||||
(f"{test_dir}/search_report.txt", b"Report content", "text/plain"),
|
||||
# Markdown files
|
||||
(f"{test_dir}/search_readme.md", b"# README", "text/markdown"),
|
||||
(f"{test_dir}/search_notes.md", b"# Notes", "text/markdown"),
|
||||
# Images (simulated)
|
||||
(f"{test_dir}/search_image.jpg", b"\xff\xd8\xff fake jpg", "image/jpeg"),
|
||||
(f"{test_dir}/search_photo.png", b"\x89PNG fake png", "image/png"),
|
||||
# PDF (simulated)
|
||||
(f"{test_dir}/search_presentation.pdf", b"%PDF-1.4", "application/pdf"),
|
||||
]
|
||||
|
||||
# Write all test files
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created {len(test_files)} test files in {test_dir}")
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup {test_dir}: {e}")
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_name(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_name MCP tool."""
|
||||
# Find all .txt files in the test directory
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_%.txt",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
# Parse the result
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching 'search_%.txt'")
|
||||
|
||||
# Should find at least 3 .txt files
|
||||
assert len(files) >= 3, f"Expected at least 3 .txt files, got {len(files)}"
|
||||
|
||||
# Verify all results end with .txt
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("search_"), (
|
||||
f"Expected name to start with 'search_', got {name}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_name_with_limit(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_name with limit parameter."""
|
||||
# Find files with limit
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_%.txt",
|
||||
"scope": search_test_files,
|
||||
"limit": 2,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=2")
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(files) <= 2, f"Expected at most 2 files, got {len(files)}"
|
||||
assert len(files) > 0, "Expected at least 1 file"
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_type_images(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_type for images."""
|
||||
# Find all images
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_type",
|
||||
arguments={
|
||||
"mime_type": "image/%",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} image files")
|
||||
|
||||
# Should find at least 2 image files (jpg and png)
|
||||
assert len(files) >= 2, f"Expected at least 2 image files, got {len(files)}"
|
||||
|
||||
# Verify all results are images
|
||||
for file in files:
|
||||
content_type = file.get("content_type", "")
|
||||
assert content_type.startswith("image/"), (
|
||||
f"Expected image/* type, got {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_type_specific(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_type for specific MIME type."""
|
||||
# Find PDF files
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_type",
|
||||
arguments={
|
||||
"mime_type": "application/pdf",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} PDF files")
|
||||
|
||||
# Should find at least 1 PDF
|
||||
assert len(files) >= 1, f"Expected at least 1 PDF file, got {len(files)}"
|
||||
|
||||
# Verify result is PDF
|
||||
for file in files:
|
||||
content_type = file.get("content_type", "")
|
||||
assert content_type == "application/pdf", (
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_basic(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with basic filters."""
|
||||
# Search for markdown files
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "%.md",
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} markdown files")
|
||||
|
||||
# Should find at least 2 .md files
|
||||
assert len(files) >= 2, f"Expected at least 2 .md files, got {len(files)}"
|
||||
|
||||
# Verify all results are .md files
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
assert name.endswith(".md"), f"Expected .md file, got {name}"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_combined(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with combined filters."""
|
||||
# Search for text files with specific name pattern
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "search_test%.txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching combined filters")
|
||||
|
||||
# Should find search_test1.txt and search_test2.txt
|
||||
assert len(files) >= 2, f"Expected at least 2 files, got {len(files)}"
|
||||
|
||||
# Verify all results match both conditions
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
content_type = file.get("content_type", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("search_test"), (
|
||||
f"Expected 'search_test' prefix, got {name}"
|
||||
)
|
||||
assert content_type == "text/plain", f"Expected text/plain, got {content_type}"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_with_limit(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with result limit."""
|
||||
# Search with limit
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "search_%",
|
||||
"limit": 3,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=3")
|
||||
|
||||
# Should return at most 3 results
|
||||
assert len(files) <= 3, f"Expected at most 3 files, got {len(files)}"
|
||||
assert len(files) > 0, "Expected at least 1 file"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_no_results(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test search that returns no results."""
|
||||
# Search for non-existent pattern
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "nonexistent_xyz123.txt",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
# Handle case where empty results might return empty content
|
||||
if result.content and len(result.content) > 0:
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
else:
|
||||
files = []
|
||||
|
||||
logger.info("Search correctly returned no results")
|
||||
|
||||
# Should return empty array
|
||||
assert len(files) == 0, f"Expected no results, got {len(files)}"
|
||||
|
||||
|
||||
async def test_search_result_properties(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test that search results include expected properties."""
|
||||
# Search for a specific file
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_readme.md",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
assert len(files) >= 1, "Should find at least one file"
|
||||
|
||||
file = files[0]
|
||||
|
||||
# Check for expected properties
|
||||
assert "name" in file, "Should include name property"
|
||||
assert "path" in file, "Should include path property"
|
||||
assert "is_directory" in file, "Should include is_directory property"
|
||||
assert file["is_directory"] is False, "File should not be a directory"
|
||||
|
||||
# Check for extended properties from search
|
||||
extended_props = ["file_id", "etag", "size", "content_type", "last_modified"]
|
||||
present_props = [prop for prop in extended_props if prop in file]
|
||||
|
||||
logger.info(f"Search result properties: {list(file.keys())}")
|
||||
assert len(present_props) > 0, f"Should have at least one of {extended_props}"
|
||||
-674
@@ -1,674 +0,0 @@
|
||||
=========================
|
||||
Instruction set for users
|
||||
=========================
|
||||
|
||||
Add a new user
|
||||
--------------
|
||||
|
||||
Create a new user on the Nextcloud server. Authentication is done by sending a
|
||||
basic HTTP authentication header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: userid - string, the required username for the new user
|
||||
* POST argument: password - string, the password for the new user, leave empty to send welcome mail
|
||||
* POST argument: displayName - string, the display name for the new user
|
||||
* POST argument: email - string, the email for the new user, required if password empty
|
||||
* POST argument: groups - array, the groups for the new user
|
||||
* POST argument: subadmin - array, the groups in which the new user is subadmin
|
||||
* POST argument: quota - string, quota for the new user
|
||||
* POST argument: language - string, language for the new user
|
||||
|
||||
Status codes:
|
||||
|
||||
* 101 - invalid argument
|
||||
* 102 - user already exists
|
||||
* 103 - cannot create sub-admins for admin group
|
||||
* 104 - group does not exist
|
||||
* 105 - insufficient privileges for group
|
||||
* 106 - no group specified (required for sub-admins)
|
||||
* 107 - hint exceptions
|
||||
* 108 - an email address is required, to send a password link to the user.
|
||||
* 109 - sub-admin group does not exist
|
||||
* 110 - required email address was not provided
|
||||
* 111 - could not create non-existing user ID
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
::
|
||||
|
||||
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true"
|
||||
|
||||
* Creates the user ``Frank`` with password ``frankspassword``
|
||||
* optionally groups can be specified by one or more ``groups[]`` query parameters:
|
||||
``URL -d groups[]="admin" -D groups[]="Team1"``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Search/get users
|
||||
----------------
|
||||
|
||||
Retrieves a list of users from the Nextcloud server. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users**
|
||||
|
||||
* HTTP method: GET
|
||||
* url arguments: search - string, optional search string
|
||||
* url arguments: limit - int, optional limit value
|
||||
* url arguments: offset - int, optional offset value
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns list of users matching the search string.
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<users>
|
||||
<element>Frank</element>
|
||||
</users>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Get data of a single user
|
||||
-------------------------
|
||||
|
||||
Retrieves information about a single user. Authentication is done by sending a
|
||||
Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns information on the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<enabled>true</enabled>
|
||||
<id>Frank</id>
|
||||
<quota>0</quota>
|
||||
<email>frank@example.org</email>
|
||||
<displayname>Frank K.</displayname>
|
||||
<display-name>Frank K.</display-name>
|
||||
<phone>0123 / 456 789</phone>
|
||||
<address>Foobar 12, 12345 Town</address>
|
||||
<website>https://nextcloud.com</website>
|
||||
<twitter>Nextcloud</twitter>
|
||||
<groups>
|
||||
<element>group1</element>
|
||||
<element>group2</element>
|
||||
</groups>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Edit data of a single user
|
||||
--------------------------
|
||||
|
||||
Edits attributes related to a user. Users are able to edit email, displayname
|
||||
and password; admins can also edit the quota value. Further restrictions may apply,
|
||||
check the `List of editable data fields`_ endpoint. Authentication
|
||||
is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: PUT
|
||||
* PUT argument: key, the field to edit:
|
||||
|
||||
+ email
|
||||
+ quota
|
||||
+ displayname
|
||||
+ display (**deprecated** use `displayname` instead)
|
||||
+ phone
|
||||
+ address
|
||||
+ website
|
||||
+ twitter
|
||||
+ password
|
||||
|
||||
* PUT argument: value, the new value for the field
|
||||
|
||||
Status codes:
|
||||
|
||||
* 101 - invalid argument
|
||||
* 107 - password policy (hint exception)
|
||||
* 112 - Setting the password is not supported by the users backend
|
||||
* 113 - editing field not allowed / field doesn’t exist
|
||||
|
||||
Examples
|
||||
^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true"
|
||||
|
||||
* Updates the email address for the user ``Frank``
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true"
|
||||
|
||||
* Updates the quota for the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
.. _editable_field_list:
|
||||
|
||||
List of editable data fields
|
||||
----------------------------
|
||||
|
||||
Edits attributes related to a user. Users are able to edit email, displayname
|
||||
and password; admins can also edit the quota value. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/user/fields**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Examples
|
||||
^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true"
|
||||
|
||||
* Gets the list of fields
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message>OK</message>
|
||||
</meta>
|
||||
<data>
|
||||
<element>displayname</element>
|
||||
<element>email</element>
|
||||
<element>phone</element>
|
||||
<element>address</element>
|
||||
<element>website</element>
|
||||
<element>twitter</element>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
|
||||
Disable a user
|
||||
--------------
|
||||
|
||||
Disables a user on the Nextcloud server so that the user cannot login anymore.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/disable**
|
||||
|
||||
* HTTP method: PUT
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true"
|
||||
|
||||
* Disables the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Enable a user
|
||||
-------------
|
||||
|
||||
Enables a user on the Nextcloud server so that the user can login again.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/enable**
|
||||
|
||||
* HTTP method: PUT
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true"
|
||||
|
||||
* Enables the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Delete a user
|
||||
-------------
|
||||
|
||||
Deletes a user from the Nextcloud server. Authentication is done by sending a
|
||||
Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: DELETE
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Deletes the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Get user's groups
|
||||
-----------------
|
||||
|
||||
Retrieves a list of groups the specified user is a member of. Authentication is
|
||||
done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true"
|
||||
|
||||
* Retrieves a list of groups of which ``Frank`` is a member
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<groups>
|
||||
<element>admin</element>
|
||||
<element>group1</element>
|
||||
</groups>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Add user to group
|
||||
-----------------
|
||||
|
||||
Adds the specified user to the specified group. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: groupid, string - the group to add the user to
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - no group specified
|
||||
* 102 - group does not exist
|
||||
* 103 - user does not exist
|
||||
* 104 - insufficient privileges
|
||||
* 105 - failed to add user to group
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Adds the user ``Frank`` to the group ``newgroup``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Remove user from group
|
||||
----------------------
|
||||
|
||||
Removes the specified user from the specified group. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: DELETE
|
||||
* DELETE argument: groupid, string - the group to remove the user from
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - no group specified
|
||||
* 102 - group does not exist
|
||||
* 103 - user does not exist
|
||||
* 104 - insufficient privileges
|
||||
* 105 - failed to remove user from group
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Removes the user ``Frank`` from the group ``newgroup``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Promote user to subadmin
|
||||
------------------------
|
||||
|
||||
Makes a user the subadmin of a group. Authentication is done by sending a Basic
|
||||
HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: groupid, string - the group of which to make the user a
|
||||
subadmin
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - group does not exist
|
||||
* 103 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true"
|
||||
|
||||
* Makes the user ``Frank`` a subadmin of the ``group`` group
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Demote user from subadmin
|
||||
-------------------------
|
||||
|
||||
Removes the subadmin rights for the user specified from the group specified.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: DELETE
|
||||
* DELETE argument: groupid, string - the group from which to remove the user's
|
||||
subadmin rights
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - user is not a subadmin of the group / group does not exist
|
||||
* 103 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Get user's subadmin groups
|
||||
--------------------------
|
||||
|
||||
Returns the groups in which the user is a subadmin. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns the groups of which ``Frank`` is a subadmin
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data>
|
||||
<element>testgroup</element>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Resend the welcome email
|
||||
------------------------
|
||||
|
||||
The request to this endpoint triggers the welcome email for this user again.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/welcome**
|
||||
|
||||
* HTTP method: POST
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - email address not available
|
||||
* 102 - sending email failed
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true"
|
||||
|
||||
* Sends the welcome email to ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
@@ -45,73 +45,104 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.3.0"
|
||||
version = "25.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caldav"
|
||||
version = "2.0.2.dev38+g1aa2be35e"
|
||||
source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#1aa2be35e94883b44efd42f1cd82d281f8f58e60" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "icalendar" },
|
||||
{ name = "lxml" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
version = "2025.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.3"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -160,89 +191,89 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.7"
|
||||
version = "7.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -340,6 +371,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -368,13 +421,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.1"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -392,25 +459,25 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipython"
|
||||
version = "9.5.0"
|
||||
version = "9.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -425,9 +492,9 @@ dependencies = [
|
||||
{ name = "traitlets" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/71/a86262bf5a68bf211bcc71fe302af7e05f18a2852fdc610a854d20d085e6/ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113", size = 4389137, upload-time = "2025-08-29T12:15:21.519Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72", size = 612426, upload-time = "2025-08-29T12:15:18.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl", hash = "sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196", size = 616170, upload-time = "2025-09-29T10:55:47.676Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -493,6 +560,108 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
@@ -630,9 +799,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.14.3"
|
||||
version = "0.17.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "caldav" },
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "icalendar" },
|
||||
@@ -648,14 +818,15 @@ dev = [
|
||||
{ name = "ipython" },
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-playwright-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "caldav", git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx" },
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
@@ -671,9 +842,9 @@ dev = [
|
||||
{ name = "ipython", specifier = ">=9.2.0" },
|
||||
{ name = "playwright", specifier = ">=1.49.1" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||
{ name = "ruff", specifier = ">=0.11.13" },
|
||||
]
|
||||
|
||||
@@ -854,7 +1025,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.9"
|
||||
version = "2.12.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -862,74 +1033,102 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1039,6 +1238,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/1e/f71a3131bb03a57631d77a47cebba93b694033759f69f08a6f06c375fc30/pytest_playwright_asyncio-0.7.1-py3-none-any.whl", hash = "sha256:1cc25aed49879161cc1b1aa0f9e1a3d36d9ebdde412b6e5074440d71dc0d87e3", size = 16963, upload-time = "2025-09-08T08:10:56.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -1176,18 +1387,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recurring-ical-events"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
{ name = "x-wr-timezone" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/90/05dfcc02ecf58bd170305c88db9e3e3933aa73a1f8abac2b326c7fdc1a98/recurring_ical_events-3.8.0.tar.gz", hash = "sha256:3e8c7c35d9bd8956a7ab91afad51477c60d972e1236d3fd1b55087a66bce7d04", size = 602665, upload-time = "2025-06-10T13:23:50.662Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/25/88a4218cccae06ce6b15e41d2f263dd4a73e8e8cbe41537cd7784a17479b/recurring_ical_events-3.8.0-py3-none-any.whl", hash = "sha256:cf958eb17c92d4dca5c621e44c2b3fffd4ba700dca0db66287c5dc11438f63ba", size = 238228, upload-time = "2025-06-10T13:23:49.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1207,15 +1433,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.1.0"
|
||||
version = "14.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1328,28 +1554,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.2"
|
||||
version = "0.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1438,41 +1664,51 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1519,14 +1755,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1549,15 +1785,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1627,3 +1863,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x-wr-timezone"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user